mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-10 01:49:54 +00:00
Compare commits
1 Commits
ui-refacto
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2af67c7023 |
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate FreeBSD port diff
|
||||
run: bash -x release_files/freebsd-port-diff.sh
|
||||
run: bash release_files/freebsd-port-diff.sh
|
||||
|
||||
- name: Generate FreeBSD port issue body
|
||||
run: bash -x release_files/freebsd-port-issue-body.sh
|
||||
run: bash release_files/freebsd-port-issue-body.sh
|
||||
|
||||
- name: Check if diff was generated
|
||||
id: check_diff
|
||||
@@ -367,7 +367,10 @@ jobs:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64
|
||||
# GTK4/WebKitGTK 6.0 dev libs for the default build + GTK3/WebKit2GTK 4.1
|
||||
# dev libs for the legacy -tags gtk3 build (netbird-ui-gtk3). Both stacks
|
||||
# coexist on the same runner; goreleaser builds both Linux variants here.
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Decode GPG signing key
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
4
.github/workflows/wasm-build-validation.yml
vendored
4
.github/workflows/wasm-build-validation.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,25 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). -tags gtk3 flips Wails to
|
||||
# the GTK3 stack and drops our GTK4-only XEmbed tray host (see
|
||||
# xembed_host_gtk3_linux.go). Removed upstream in Wails v3.1.
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=gtk3
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
- id: netbird-ui-windows-amd64
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
@@ -60,6 +79,10 @@ archives:
|
||||
name_template: "{{ .ProjectName }}-linux_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
- id: windows-arch
|
||||
name_template: "{{ .ProjectName }}-windows_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
@@ -116,6 +139,59 @@ nfpms:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# Legacy GTK3 deb for Ubuntu 22.04 / Debian 12 (no WebKitGTK 6.0). Same
|
||||
# package_name as the GTK4 deb -- the two are mutually-exclusive alternatives
|
||||
# served from the matching distro repo; the package manager resolves by deps.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Legacy GTK3 rpm for RHEL 9 / Fedora <=39 (no WebKitGTK 6.0).
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
ids:
|
||||
@@ -132,3 +208,22 @@ uploads:
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
# Legacy GTK3 packages share the netbird-ui name, so they must live in a repo
|
||||
# path the old distros point at -- here a dedicated `gtk3` distribution/path.
|
||||
# TODO: confirm the final repo layout with the pkgs.wiretrustee.com owner.
|
||||
- name: debian-gtk3
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
@@ -3,7 +3,6 @@ package iptables
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
@@ -422,17 +421,12 @@ func (m *aclManager) updateState() {
|
||||
currentState.Lock()
|
||||
defer currentState.Unlock()
|
||||
|
||||
// Clone the maps so the persisted state holds a private snapshot. The
|
||||
// live maps keep being mutated by subsequent rule operations while the
|
||||
// state manager marshals the state from its periodic-save goroutine.
|
||||
// Sharing them by reference races the two and aborts the process with a
|
||||
// concurrent map iteration and write.
|
||||
if m.v6 {
|
||||
currentState.ACLEntries6 = maps.Clone(m.entries)
|
||||
currentState.ACLIPsetStore6 = m.ipsetStore.clone()
|
||||
currentState.ACLEntries6 = m.entries
|
||||
currentState.ACLIPsetStore6 = m.ipsetStore
|
||||
} else {
|
||||
currentState.ACLEntries = maps.Clone(m.entries)
|
||||
currentState.ACLIPsetStore = m.ipsetStore.clone()
|
||||
currentState.ACLEntries = m.entries
|
||||
currentState.ACLIPsetStore = m.ipsetStore
|
||||
}
|
||||
|
||||
if err := m.stateManager.UpdateState(currentState); err != nil {
|
||||
|
||||
@@ -4,7 +4,6 @@ package iptables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -750,17 +749,11 @@ func (r *router) updateState() {
|
||||
currentState.Lock()
|
||||
defer currentState.Unlock()
|
||||
|
||||
// Clone the rule map so the persisted state holds a private snapshot. The
|
||||
// live map keeps being mutated by subsequent rule operations while the
|
||||
// state manager marshals the state from its periodic-save goroutine.
|
||||
// Sharing it by reference races the two and aborts the process with a
|
||||
// concurrent map iteration and write. The ipset counter guards itself
|
||||
// during marshaling, so it can be shared directly.
|
||||
if r.v6 {
|
||||
currentState.RouteRules6 = maps.Clone(r.rules)
|
||||
currentState.RouteRules6 = r.rules
|
||||
currentState.RouteIPsetCounter6 = r.ipsetCounter
|
||||
} else {
|
||||
currentState.RouteRules = maps.Clone(r.rules)
|
||||
currentState.RouteRules = r.rules
|
||||
currentState.RouteIPsetCounter = r.ipsetCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package iptables
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
)
|
||||
import "encoding/json"
|
||||
|
||||
type ipList struct {
|
||||
ips map[string]struct{}
|
||||
@@ -22,14 +19,6 @@ func (s *ipList) addIP(ip string) {
|
||||
s.ips[ip] = struct{}{}
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ipList with its own ips map.
|
||||
func (s *ipList) clone() *ipList {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &ipList{ips: maps.Clone(s.ips)}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (s *ipList) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
@@ -66,19 +55,6 @@ func newIpsetStore() *ipsetStore {
|
||||
}
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ipsetStore with its own ipsets map and
|
||||
// independent ipList entries.
|
||||
func (s *ipsetStore) clone() *ipsetStore {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
|
||||
for name, list := range s.ipsets {
|
||||
cloned.ipsets[name] = list.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
|
||||
r, ok := s.ipsets[ipsetName]
|
||||
return r, ok
|
||||
|
||||
@@ -806,8 +806,6 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
AllowPartial: true,
|
||||
}
|
||||
|
||||
g.maskSecrets()
|
||||
|
||||
jsonBytes, err := options.Marshal(g.syncResponse)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate json: %w", err)
|
||||
@@ -820,27 +818,6 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) maskSecrets() {
|
||||
if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Flow != nil {
|
||||
g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
|
||||
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Relay != nil {
|
||||
g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
|
||||
}
|
||||
|
||||
for i := range g.syncResponse.NetbirdConfig.Turns {
|
||||
if g.syncResponse.NetbirdConfig.Turns[i] != nil {
|
||||
g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addStateFile() error {
|
||||
sm := profilemanager.NewServiceManager("")
|
||||
path := sm.GetStatePath()
|
||||
|
||||
@@ -777,24 +777,13 @@ func (s *DefaultServer) applyHostConfig() {
|
||||
// context is released rather than leaked until GC.
|
||||
func (s *DefaultServer) registerFallback() {
|
||||
originalNameservers := s.hostManager.getOriginalNameservers()
|
||||
|
||||
serverIP := s.service.RuntimeIP()
|
||||
var servers []netip.AddrPort
|
||||
for _, ns := range originalNameservers {
|
||||
if ns == serverIP {
|
||||
log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
|
||||
continue
|
||||
}
|
||||
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
if len(originalNameservers) == 0 {
|
||||
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
|
||||
s.clearFallback()
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
|
||||
log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
|
||||
|
||||
handler, err := newUpstreamResolver(
|
||||
s.ctx,
|
||||
@@ -808,6 +797,11 @@ func (s *DefaultServer) registerFallback() {
|
||||
return
|
||||
}
|
||||
handler.selectedRoutes = s.selectedRoutes
|
||||
|
||||
var servers []netip.AddrPort
|
||||
for _, ns := range originalNameservers {
|
||||
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
||||
}
|
||||
handler.addRace(servers)
|
||||
|
||||
prev := s.fallbackHandler
|
||||
|
||||
@@ -716,13 +716,6 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
||||
// RouteSelector stores routes with default-on semantics, so without this every
|
||||
// available exit node would report selected at once.
|
||||
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||
// An explicit user "deselect all" must not be overridden by management auto-apply.
|
||||
// Auto-applying an exit node here would call SelectRoutes, which clears the
|
||||
// deselect-all flag and re-enables every route the user turned off.
|
||||
if m.routeSelector.IsDeselectAll() {
|
||||
return
|
||||
}
|
||||
|
||||
info := m.collectExitNodeInfo(clientRoutes)
|
||||
if len(info.allIDs) == 0 {
|
||||
return
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
|
||||
haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
|
||||
return route.HAMap{
|
||||
haID: []*route.Route{
|
||||
{
|
||||
ID: "r-" + route.ID(netID),
|
||||
NetID: netID,
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
NetworkType: route.IPv4Network,
|
||||
Enabled: true,
|
||||
SkipAutoApply: skipAutoApply,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement(t *testing.T) {
|
||||
t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
routes := exitNodeRoutes("exit1", false)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
|
||||
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
|
||||
})
|
||||
|
||||
t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
routes := exitNodeRoutes("exit1", true)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
|
||||
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
|
||||
})
|
||||
|
||||
t.Run("user selection is not overridden by management", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
|
||||
routes := exitNodeRoutes("exit1", true)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
|
||||
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
|
||||
})
|
||||
|
||||
t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
m.routeSelector.DeselectAllRoutes()
|
||||
routes := exitNodeRoutes("exit1", false)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
|
||||
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
|
||||
})
|
||||
}
|
||||
@@ -116,14 +116,6 @@ func (rs *RouteSelector) DeselectAllRoutes() {
|
||||
clear(rs.selectedRoutes)
|
||||
}
|
||||
|
||||
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
||||
func (rs *RouteSelector) IsDeselectAll() bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
return rs.deselectAll
|
||||
}
|
||||
|
||||
// IsSelected checks if a specific route is selected.
|
||||
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
|
||||
@@ -98,7 +98,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created
|
||||
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
|
||||
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
|
||||
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/errors.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
|
||||
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
|
||||
|
||||
The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
|
||||
|
||||
@@ -110,8 +110,6 @@ The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel
|
||||
|
||||
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
|
||||
|
||||
**Bundle shape is Chrome-extension JSON**: each key maps to `{ "message": "...", "description": "..." }`, not a bare string. `description` is **translator context for Crowdin** (which reads it natively from the source file) and is ignored at runtime — only `en/common.json` needs descriptions; target bundles carry just `message`. Both loaders strip back to a flat `key→message` map: Go's `loadBundle` (`bundle.go`) unmarshals into `map[string]bundleEntry` and flattens (so `BundleFor`/`Translate` signatures are unchanged); `frontend/src/lib/i18n.ts` maps each entry's `message` into the i18next `resources`. When editing a string, edit `message`; when a key's purpose isn't obvious from its name, add/update its `description` so translators (and screenshots auto-tagging) have context.
|
||||
|
||||
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
|
||||
|
||||
Package layout:
|
||||
@@ -125,6 +123,8 @@ Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.*
|
||||
|
||||
The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`.
|
||||
|
||||
**Legacy `-tags gtk3` build:** Wails v3 defaults to GTK4/WebKitGTK 6.0; the legacy GTK3/WebKit2GTK 4.1 path (`-tags gtk3`, for Ubuntu 22.04 / Debian 12 / RHEL 9 / Fedora ≤39, removed upstream in Wails v3.1) is shipped as a second `netbird-ui` package built via `EXTRA_TAGS=gtk3` / a separate goreleaser lane. `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` are GTK4-only (`//go:build … && !gtk3`); on gtk3 builds `xembed_host_gtk3_linux.go` stubs them out (`xembedTrayAvailable()` → false), so the minimal-WM XEmbed fallback is **absent on gtk3** (tray still works on SNI-capable desktops). Keep the C files' `//go:build` constraints in sync with the Go file.
|
||||
|
||||
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed.
|
||||
@@ -133,7 +133,7 @@ The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — e
|
||||
|
||||
### Errors → custom Error window
|
||||
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/errors.ts` (alongside `formatErrorMessage`), which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
|
||||
|
||||
**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator e
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
|
||||
## Legacy GTK3 build (`-tags gtk3`)
|
||||
|
||||
The XEmbed host (`xembed_host_linux.go` + `xembed_tray_linux.{c,h}`) hard-links GTK4 and uses GTK4-only popup-menu APIs (`GdkSurface`, `GtkEventControllerFocus`, `gtk_window_set_child`, `gdk_display_get_monitors`→`GListModel`, …), so it cannot compile against GTK3. On the legacy `-tags gtk3` build those files are excluded (`//go:build … && !gtk3`) and `xembed_host_gtk3_linux.go` provides a pure-Go stub where `xembedTrayAvailable()` returns false. The watcher probe then exits immediately, so the in-process XEmbed fallback is **absent on GTK3 builds** — the tray works only where the desktop ships its own `StatusNotifierWatcher` (KDE, GNOME+AppIndicator, Cinnamon/xapp, XFCE), not on minimal WMs. Rather than port the ~150-line C menu layer to GTK3 we accept this gap; `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
|
||||
@@ -13,6 +13,21 @@ WebView.
|
||||
Windows `tcp://127.0.0.1:41731`)
|
||||
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
|
||||
|
||||
### Legacy GTK3 build
|
||||
|
||||
Wails v3 builds on GTK4 / WebKitGTK 6.0 by default. Distros that don't ship
|
||||
WebKitGTK 6.0 yet (Ubuntu 22.04, Debian 12, RHEL 9, Fedora ≤ 39) need the
|
||||
legacy GTK3 / WebKit2GTK 4.1 build, produced with `-tags gtk3` (e.g.
|
||||
`task build EXTRA_TAGS=gtk3`). It needs `libgtk-3-dev` + `libwebkit2gtk-4.1-dev`
|
||||
instead of the GTK4 libs above. `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
|
||||
> **Tray limitation:** the GTK3 build drops the in-process XEmbed
|
||||
> `StatusNotifierWatcher` (its menu layer is GTK4-only — see
|
||||
> [`LINUX-TRAY.md`](LINUX-TRAY.md) and `xembed_host_gtk3_linux.go`). The tray
|
||||
> still works on desktops that ship their own watcher (KDE, GNOME+AppIndicator,
|
||||
> Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/dwm)
|
||||
> is unavailable on GTK3 packages.
|
||||
|
||||
## Develop without rebuilding
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# NetBird Wails UI — Frontend Working Notes
|
||||
|
||||
The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||||
This is the React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus two auxiliary windows (`/#/settings` and `/#/browser-login`) opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||||
|
||||
> **Keep these notes current.** Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase.
|
||||
> **Keep these notes current.** When working in this directory with Claude, update this file whenever you change conventions, rename a context/provider, shift the route table, add or remove a top-level dependency, or introduce a new cross-cutting feature (i18n, theming, telemetry, etc.). The aim is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
|
||||
|
||||
> **Work in progress.** Big chunks of the UI are still mocked, prototyped, or duplicated across screens that pre-date the current AppLayout. Anything marked "prototype" / "mocked" / "legacy" below should be assumed half-wired. The polished surface today is: the main connect toggle, the Settings window, the debug-bundle flow, the auto-update overlay, and the profile selector. Everything else is in flight.
|
||||
|
||||
## Stack & tooling
|
||||
|
||||
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by the connect toggle. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
|
||||
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by `NetBirdConnectToggle`. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
|
||||
|
||||
## Path aliases & bindings
|
||||
|
||||
@@ -14,173 +16,200 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
|
||||
`bindings/` is gitignored and fully generated. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` fails until you run `pnpm bindings` (or `wails3 generate bindings -clean=true -ts` from `client/ui/`) once. `wails3 dev` regenerates on its own.
|
||||
|
||||
## Routing (`app.tsx`)
|
||||
## Routing (app.tsx)
|
||||
|
||||
`HashRouter`. Dialog routes are grouped under a parent `<Route path="dialog">` (URL grouping only, no shared layout); the two in-window routes sit under `<AppLayout>`. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`.
|
||||
`HashRouter` with the following routes:
|
||||
|
||||
| Path | Component (module) | Layout | Window |
|
||||
| Path | Component | Layout | Where it opens |
|
||||
|---|---|---|---|
|
||||
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Settings auxiliary window |
|
||||
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | SSO browser-wait, always-on-top |
|
||||
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Install progress, always-on-top |
|
||||
| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Session expiry warning, always-on-top |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | First-launch onboarding |
|
||||
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | App's single error surface, always-on-top |
|
||||
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route |
|
||||
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
|
||||
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). |
|
||||
| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpiration(seconds)`, always-on-top, mm:ss countdown via `?seconds=`). Drives both the soon-to-expire warning and (when seconds elapse to zero) the expired state. |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
|
||||
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in `services/windowmanager.go` — see `../CLAUDE.md`. Frontend-relevant notes per window:
|
||||
In `app.tsx` the dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
|
||||
|
||||
- **Settings** — opened via `WindowManager.OpenSettings(tab)`. The window stays at `/#/settings` for its whole lifetime (no `SetURL` between opens, so `AppLayout`'s providers never remount). Active tab is React local state in `SettingsPage`, set from the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is driven in React by a `document.visibilitychange` listener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which races `Hide` and flashes the previous tab for one frame).
|
||||
- **install-progress** — owns the install-result polling + 5s daemon-down-grace, calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (user-driven enforced branch) and on the `installing` flip from `netbird:update:state` (force-install branch).
|
||||
- **session-expiration** — `?seconds=` drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emit `trigger-login`; Logout calls `Connection.Logout`.
|
||||
- **welcome** — opened from Go's `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
- **error** — `errorDialog({Title, Message})` in `lib/errors.ts` opens this (not a native OS box). `title` is the window chrome title (set Go-side, not in the body); `message` is read from `useSearchParams` and rendered next to a danger `SquareIcon`, with a Close button (Escape also closes → `WindowManager.CloseError()`).
|
||||
`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).
|
||||
|
||||
## Layouts
|
||||
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:
|
||||
- `Header` reads `useViewMode` (view-mode dropdown) and `useClientVersion` (update badge).
|
||||
- `ViewModeProvider` wraps the whole of `Main` because both `Header` and `MainBody` read view mode. It calls `Window.SetSize` on the current Wails window, so it must not be visible to the Settings window.
|
||||
- `NavSectionProvider` is mounted only inside the advanced-mode branch (`MainBody → AdvancedRightPanel`) — the default-mode view has no Peers/Resources/Exit Nodes tabs and no consumer of `useNavSection`. Default mode therefore skips the provider entirely.
|
||||
- `Header.tsx`, `Navigation.tsx`, and `ConnectionStatusSwitch.tsx` are siblings of `Main.tsx` in `pages/main/` because nothing else uses them.
|
||||
- **`pages/Settings.tsx`** owns the `h-12` `wails-draggable` strip at the top (so the macOS traffic-light buttons that float over the `MacTitleBarHiddenInset` window don't overlap content), then renders the vertical tabs — no view-mode, no nav, no header.
|
||||
|
||||
`AppLayout` is the only router-level layout. It mounts the shared provider stack and renders `<Outlet/>`:
|
||||
## Directory layout (src/)
|
||||
|
||||
```
|
||||
DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider
|
||||
```
|
||||
- `app.tsx` — root render + route table. The canonical registry of every route; scan this file to enumerate pages.
|
||||
- `layouts/AppLayout.tsx` — the router-level layout. Mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) and renders `<Outlet/>`. (`layouts/` also holds `AppRightPanel.tsx`, see below.)
|
||||
- `modules/<feature>/` — every feature owns its own folder: page entry (named `<Feature>Page.tsx`), local components, and everything else it needs:
|
||||
- `modules/main/` — `MainPage.tsx` + main-window chrome (`Header.tsx`, `ConnectionStatusSwitch.tsx`).
|
||||
- `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`.
|
||||
- `modules/settings/` — `SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`.
|
||||
- `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
|
||||
- `modules/session/` — `SessionExpirationDialog.tsx` (session expiration warning + expired state).
|
||||
- `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
|
||||
- `modules/error/` — `ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`.
|
||||
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
|
||||
|
||||
- `DialogProvider` is outermost (and outside the daemon gate) so `useConfirm()` works regardless of daemon state.
|
||||
- `StatusProvider` owns the single `DaemonFeed.Get` + `netbird:status` subscription and **only renders its children when the daemon is reachable** — otherwise it short-circuits to `<DaemonUnavailableOverlay/>`. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return.
|
||||
- Order matters: `SettingsContext` (mounted in `SettingsPage`) depends on `ProfileContext`; `ClientVersionContext` reads `StatusContext` events.
|
||||
|
||||
`AppRightPanel` (in `layouts/`) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it).
|
||||
|
||||
Page-specific chrome and providers live in the page, not the layout:
|
||||
|
||||
- **`MainPage`** (main window only) mounts `ViewModeProvider` (wraps the whole page — both `MainHeader` and `MainBody` read view mode; it calls `Window.SetSize`, so it must not be visible to the Settings window), `NetworksProvider`, and `PeerDetailProvider`. `NavSectionProvider` is mounted **only** inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer of `useNavSection`.
|
||||
- **`SettingsPage`** owns the `wails-draggable` strip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs.
|
||||
|
||||
## Directory layout (`src/`)
|
||||
|
||||
- `app.tsx` — root render + route table. The canonical registry of every route. Also wires init-time bootstrap (`initLogForwarding`, `welcome`, `initI18n`, `initPlatform`) before first render.
|
||||
- `layouts/` — `AppLayout.tsx` (the only router-level layout) and `AppRightPanel.tsx` (shared content-panel shell).
|
||||
- `modules/<feature>/` — each feature owns its folder: a `*Page.tsx` entry where applicable, plus its local components.
|
||||
- `main/` — `MainPage.tsx`, `MainHeader.tsx`, `MainConnectionStatusSwitch.tsx` (connect toggle + the `startLogin` SSO orchestrator), `MainExitNodeSwitcher.tsx`.
|
||||
- `main/advanced/` — advanced-mode-only surfaces: `Navigation.tsx` (Peers/Networks tab switch) plus `peers/` (`Peers.tsx`, `PeerDetailPanel.tsx`, `PeerFilters.tsx`) and `networks/` (`Networks.tsx`, `NetworkFilters.tsx`). There is no exit-nodes sub-module — exit-node state lives in `NetworksContext` and the UI is `MainExitNodeSwitcher` (shown in default mode too).
|
||||
- `settings/` — `SettingsPage.tsx`, `SettingsNavigation.tsx`, `SettingsSection.tsx`, `SettingsSkeleton.tsx`, and the tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSecurity`, `SettingsSSH`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). The Profiles tab is `modules/profiles/ProfilesTab.tsx`.
|
||||
- `profiles/` — `ProfileDropdown.tsx` (header), `ProfileCreationModal.tsx`, `ProfilesTab.tsx` (settings table), `ProfileAvatar.tsx`. Context in `contexts/ProfileContext.tsx`. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + `useManagementUrl`); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the `managementUrl` onto it (keyed by profile name, before switching), then switches. Row actions confirm via `useConfirm()`.
|
||||
- `welcome/` — `WelcomeDialog.tsx` (orchestrator) + `WelcomeStepTray.tsx`, `WelcomeStepManagement.tsx`. The management step renders only when active profile is `"default"`, the profile email is empty, and the management URL is cloud-default-or-empty (`shouldShowManagementStep`). Self-hosted URL reachability is a soft warning (`useManagementUrl.checkManagementUrlReachable`) — the user can re-click Continue to proceed past a failed check.
|
||||
- `login/` — `LoginWaitingForBrowserDialog.tsx` (SSO browser-wait window).
|
||||
- `session/` — `SessionExpirationDialog.tsx`.
|
||||
- `auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`.
|
||||
- `error/` — `ErrorDialog.tsx`.
|
||||
- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `components/` — presentational primitives, no daemon RPCs, no router:
|
||||
- `buttons/` — `Button`, `IconButton`.
|
||||
- `inputs/` — `Input`, `SearchInput`.
|
||||
- `dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation, usually driven via `useConfirm()`).
|
||||
- `switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
|
||||
- `typography/` — `Label`, `HelpText`.
|
||||
- `empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`, `DaemonUnavailableOverlay`.
|
||||
- Flat at root: `Badge`, `CopyToClipboard`, `DropdownMenu`, `SquareIcon`, `Tooltip`, `TruncatedText`, `VerticalTabs`, `LanguagePicker`, `ManagementServerSwitch`.
|
||||
- `hooks/` — `useAutoSizeWindow.ts` (auto-size + `Window.Show` for auxiliary dialogs), `useKeyboardShortcut.ts`, `useManagementUrl.ts` (management-URL helpers: `CLOUD_MANAGEMENT_URL`, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`).
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts` (`formatErrorMessage` + the `errorDialog({Title, Message})` window wrapper), `formatters.ts` (byte/latency/relative-time + `shortenDns`), `sorting.ts` (`reconcileOrder` — order-preserving list reconciliation shared by the peers/networks/profiles lists), `i18n.ts`, `logs.ts` (forwards console + uncaught errors to the Go log pipeline), `platform.ts` (`isMacOS`/`isWindows`), `welcome.ts`.
|
||||
- `assets/` — fonts, logos, flags.
|
||||
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
|
||||
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `components/` — presentational primitives, no domain coupling. Grouped by family:
|
||||
- `components/buttons/` — `Button`, `IconButton`.
|
||||
- `components/inputs/` — `Input`, `SearchInput`.
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
|
||||
- `components/switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
|
||||
- `components/typography/` — `Label`, `HelpText`.
|
||||
- `components/empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`.
|
||||
- Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives).
|
||||
- `layouts/` — `AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`.
|
||||
- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`).
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller.
|
||||
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
|
||||
|
||||
## Wails event bus
|
||||
|
||||
Subscribe with `Events.On(name, handler)`; the handler receives `{ data: <typed payload> }`. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each `Events.On` adds an emit-time fan-out.
|
||||
Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed payload> }`. The event name strings live next to their usage (no central registry on the TS side).
|
||||
|
||||
| Event name | Payload | Emitted by | Consumed by |
|
||||
| Event name (string) | Payload | Emitted by | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `netbird:status` | `Status` | `services/peers.go` | `StatusContext` (the only subscriber) |
|
||||
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `ProfileContext` — refreshes so a tray-initiated switch paints in the UI |
|
||||
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing` |
|
||||
| `netbird:settings:open` | `string` (tab id) | `services/windowmanager.go OpenSettings` (before `Show`) | `SettingsPage` — `setActive(e.data)`. Reset-on-close is the `visibilitychange` listener, not this event. |
|
||||
| `netbird:preferences:changed` | `{ language }` | Go after `SetLanguage` / `SetViewMode` | `lib/i18n.ts` — calls `i18next.changeLanguage` so a flip from any window paints everywhere |
|
||||
| `browser-login:cancel` | (none) | `LoginWaitingForBrowserDialog` Cancel button **or** Go on window close | `MainConnectionStatusSwitch`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
|
||||
| `trigger-login` | (none) | `services.EventTriggerLogin` (reserved; no Go emitter today) | `MainConnectionStatusSwitch` subscribes and runs `startLogin()` |
|
||||
| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `contexts/StatusContext` (`useStatus`) |
|
||||
| `netbird:event` | `SystemEvent` | `services/peers.go toastStreamLoop` | Not currently subscribed on the TS side — Status is read via `useStatus().status.events` instead. The tray (Go) consumes it for OS notifications. |
|
||||
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `contexts/ProfileContext` refreshes so a tray-initiated switch paints in the React UI. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | `services/peers.go fanOutUpdateEvents` | Not directly subscribed on the TS side; `ClientVersionContext` derives `updateVersion` from `status.events` metadata instead. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | same | Drives the tray. UI side: `WindowManager.OpenInstallProgress` is what opens the install window; the React listener for `installing` flips lives in `ClientVersionContext`. |
|
||||
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `modules/auto-update/ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing`. |
|
||||
| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `pages/main/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
|
||||
| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); `pages/main/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()` when fired. No Go-side emitter today. |
|
||||
| `netbird:settings:open` | `string` (tab id, e.g. `"general"`, `"profiles"`) | `services/windowmanager.go OpenSettings` (before Go calls `Show`) | `modules/settings/SettingsPage.tsx` — just `setActive(e.data)`. Reset-on-close is **not** driven by this event — see the `visibilitychange` listener in the same file. |
|
||||
|
||||
`netbird:event`, `netbird:update:available`, and `netbird:update:progress` are emitted Go-side for the tray but **not** subscribed on the TS side — the UI derives the same info from `useStatus().status.events`.
|
||||
If you wire a new daemon-event subscriber on the TS side, prefer subscribing once at the context level rather than per-screen — the Wails event bus is process-wide and each `Events.On` adds an emit-time fan-out.
|
||||
|
||||
## Contexts and state
|
||||
|
||||
State that crosses screens/windows lives in context, each provider mounted exactly once.
|
||||
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
|
||||
|
||||
- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag.
|
||||
- **`ProfileContext`** — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` + a `refresh()`.
|
||||
- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null`. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect — `save` drops the field when it equals the mask.
|
||||
- **`DebugBundleContext`** — stages `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
|
||||
- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches:
|
||||
1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases.
|
||||
2. `available && enforced && !installing` — user-driven; `triggerUpdate` opens the install-progress window then calls `Update.Trigger()`.
|
||||
3. `available && enforced && installing` — daemon already installing; the flip auto-opens the install-progress window.
|
||||
- **`NetworksContext`** — routed networks + exit nodes derived from `status.networks`; optimistic overrides for instant toggle feedback. **`PeerDetailContext`** — which peer detail panel is open in advanced view. **`NavSectionContext`** — the advanced-mode Peers/Networks tab selection.
|
||||
- **`useStatus`** (`contexts/StatusContext.tsx`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. The provider owns a single `Peers.Get()` + `netbird:status` subscription and renders `<DaemonUnavailableOverlay/>`. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down.
|
||||
|
||||
### View mode + no client-side persistence
|
||||
- **`ProfileContext`** (`modules/profiles/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`.
|
||||
|
||||
`ViewModeProvider` (`contexts/ViewModeContext.tsx`, mounted in `MainPage`) owns `viewMode: "default" | "advanced"`, consumed via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH`: Default 380, Advanced 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is `setFrame:` (frame, incl. ~28px title bar) while initial `windowNew` uses `initWithContentRect:` (content). Passing a constant would chop ~28px off the content area on the first switch. `main.go` opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from `Preferences.Get()` on mount without triggering a resize.
|
||||
- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null` so tabs never see null. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"`.
|
||||
|
||||
**No `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job: settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`.
|
||||
- **`DebugBundleProvider` + `useDebugBundle`** (`contexts/DebugBundleContext.tsx`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
|
||||
|
||||
- **`ClientVersionContext`** (`modules/auto-update/`) — seeds from `Update.GetState()` and subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. **Three branches**:
|
||||
1. `available && !enforced` — download-only. `UpdateVersionCard` shows "Version X is available for download" + "Download installer" → opens GitHub releases.
|
||||
2. `available && enforced && !installing` — user-driven enforced. `UpdateVersionCard` shows "Version X is available for install" + "Install now" → `triggerUpdate` opens `/install-progress` window then calls `Update.Trigger()`.
|
||||
3. `available && enforced && installing` — daemon already installing (force-install). The `installing` flip auto-opens `/install-progress` via `WindowManager.OpenInstallProgress`.
|
||||
|
||||
### Default/Advanced view + no client-side persistence
|
||||
|
||||
The `ViewModeProvider` (`src/lib/viewMode.tsx`, mounted in `AppLayout`) owns a `viewMode: "default" | "advanced"` state and is consumed by `Header.tsx`'s "more" dropdown via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH` at the top of `viewMode.tsx`: Default = 380, Advanced = 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is implemented as `setFrame:` (frame, incl. ~28px title bar) while the initial `windowNew` uses `initWithContentRect:` (content). Passing a constant 640 would chop ~28px off the content area on the first switch and visually shift everything inside (the connect toggle is `justify-center` in a column that depends on the parent's height). Reusing the live height keeps content area stable across all switches. The view is persisted user-side (see Go-side `preferences.Store`): `main.go` opens the main window at the saved width so the user never sees a 380→900 flash on launch, and the provider hydrates its React state from `Preferences.Get()` in a mount effect (no resize triggered there — Go already sized it). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`).
|
||||
|
||||
## Localisation (i18n)
|
||||
|
||||
Bootstrap in `src/lib/i18n.ts`, awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, glob-imports every bundle from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to `netbird:preferences:changed` so a flip from any window calls `i18next.changeLanguage` here.
|
||||
Bootstrap lives in `src/lib/i18n.ts` and is awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, statically imports every bundle JSON (`en/common.json`, `de/common.json`, `hu/common.json` today) from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), initialises i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to the `netbird:preferences:changed` Wails event so a flip from any window (tray, settings, another renderer) calls `i18next.changeLanguage` here.
|
||||
|
||||
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go "unset" signal). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each, and picks the first base code (`de` from `de-DE`) with a shipped bundle — then `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to `en`. From the second launch the persisted value wins.
|
||||
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go-side "unset" signal — `preferences.Store` no longer pre-fills a default). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each tag, and picks the first base code (`de` from `de-DE`) that has a shipped bundle — then calls `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back without re-detecting. If nothing matches (or the store is unreachable) the session falls through to `en`. From the second launch onward, the Go-side persisted value wins and detection is skipped. The tray (`localizer.go`) treats empty as English via its own fallback to `i18n.DefaultLanguage` so the first menu render before SetLanguage round-trips is still readable.
|
||||
|
||||
**Usage.** Default to the hook:
|
||||
The frontend deliberately uses **no `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job (settings via `SettingsContext.save → SetConfig`, language via `Preferences.SetLanguage`). The previous wide-panel and settings-tab persistence experiments were removed; every window opens at its baseline state.
|
||||
|
||||
**Usage in components.** Default to the hook:
|
||||
|
||||
```ts
|
||||
import { useTranslation } from "react-i18next";
|
||||
const { t } = useTranslation();
|
||||
t("settings.tabs.general");
|
||||
t("update.card.versionAvailable", { version: updateVersion }); // placeholders
|
||||
return <span>{t("settings.tabs.general")}</span>;
|
||||
// with placeholders:
|
||||
t("update.card.versionAvailable", { version: updateVersion })
|
||||
```
|
||||
|
||||
Outside React (module-scope event handlers, error titles) import the instance directly: `import i18next from "@/lib/i18n"`.
|
||||
For strings outside React (event handlers in modules, `Dialogs.Error` titles set from `useDebugBundle`, `useManagementUrl`, `ProfileContext`, `SettingsContext`) import the i18next instance directly:
|
||||
|
||||
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI).
|
||||
```ts
|
||||
import i18next from "@/i18n";
|
||||
await Dialogs.Error({ Title: i18next.t("settings.error.saveTitle"), Message: ... });
|
||||
```
|
||||
|
||||
**Translating bundles.** `client/ui/i18n/TRANSLATING.md` is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/`\n`/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change.
|
||||
**Confirm dialogs.** `Dialogs.Warning` resolves with the **button label string** — not an index. After translation, those labels change per language. Pin the label into a variable so the comparison stays correct:
|
||||
|
||||
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` (follow `TRANSLATING.md`) and append the row to `_index.json`. No flag asset is needed — `LanguagePicker.tsx` deliberately ships no flags ("flags represent countries, not languages"). `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale.
|
||||
```ts
|
||||
const confirmLabel = t("profile.delete.message"); // wrong example — show your real key
|
||||
const cancelLabel = t("common.cancel");
|
||||
const result = await Dialogs.Warning({ Title, Message, Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
]});
|
||||
if (result !== confirmLabel) return;
|
||||
```
|
||||
|
||||
**What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated.
|
||||
Compare against the variable, never against an English literal.
|
||||
|
||||
## Login flow (`startLogin` in `MainConnectionStatusSwitch.tsx`)
|
||||
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
|
||||
|
||||
The SSO flow is a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
|
||||
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship** — `LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
|
||||
|
||||
**Language picker.** `src/components/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to.
|
||||
|
||||
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpiration/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
|
||||
|
||||
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
|
||||
|
||||
The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
|
||||
|
||||
1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user.
|
||||
2. If SSO is needed (`needsSsoLogin`):
|
||||
- `WindowManager.OpenBrowserLogin(uri)` opens the sign-in popup (hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`).
|
||||
- The dialog fires `Connection.OpenURL(uri)` from its mount effect (done from the dialog, not `startLogin`, so the browser doesn't race the still-hidden popup).
|
||||
- `Promise.race(WaitSSOLogin, browser-login:cancel)`.
|
||||
- On cancel: cancel the in-flight `WaitSSOLogin` gRPC so the daemon drops the abandoned device code.
|
||||
2. If the daemon needs SSO (`needsSsoLogin`):
|
||||
- `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window (Hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`).
|
||||
- `LoginWaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top.
|
||||
- `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first.
|
||||
- On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`).
|
||||
3. `Connection.Up({})` to bring the new session up.
|
||||
|
||||
`onSettled` (releasing the caller's React-level guard) fires the instant the flow ends — **before** the error dialog — never gated on the dialog. Errors that aren't cancellations surface via `errorDialog`. This is the only SSO entry point; there's no `/login` route — wire any new SSO trigger through here.
|
||||
Errors that aren't cancellations surface via `Dialogs.Error`.
|
||||
|
||||
This is the only SSO entry point used by the polished Main UI. There is no `/login` route in `app.tsx`; if you add one, wire it up here rather than introducing a parallel SSO flow.
|
||||
|
||||
## Components
|
||||
|
||||
`src/components/` holds presentational primitives (no daemon RPCs, no router) — see the directory listing. Settings rows use `FancyToggleSwitch` inside `<SectionGroup title=…>` (section-group dimming via `disabled` → greyed + `pointer-events-none`). In-app modals use the Radix `Dialog` primitive in the main webview; the two auxiliary OS windows (Settings, BrowserLogin) are created Go-side via `WindowManager`.
|
||||
|
||||
## Dialogs convention
|
||||
|
||||
**Errors → `errorDialog({Title, Message})` from `src/lib/errors.ts`** (which also exports `formatErrorMessage`), never `Dialogs.*` from `@wailsio/runtime`. Despite the name it opens the custom always-on-top `/#/dialog/error` window via `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **`errorDialog()` resolves as soon as the window opens — it does not block until dismissed.**
|
||||
**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow.
|
||||
|
||||
For **confirmations**, use `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` mounted at the provider level. Used by the Profiles tab and the management-server cloud switch.
|
||||
Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time.
|
||||
|
||||
**Skip dialogs entirely** for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full rationale in `../CLAUDE.md`.
|
||||
For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background `nb-gray-950`); `netbird` is brand orange (`#f68330`). New code uses `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under `src/assets/fonts/`.
|
||||
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = `nb-gray-950`); `netbird` is brand orange (`#f68330`). The Flowbite-style `gray`/`red`/`yellow`/`...` palettes are legacy — only use them inside `screens/*`; new code sticks to `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by `NetBirdConnectToggle`. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), shipped under `src/assets/fonts/`.
|
||||
|
||||
## Wails-specific quirks
|
||||
|
||||
- **Window dragging.** Class `wails-draggable` on regions that should drag the OS window (headers, the Settings title strip, dialog wrappers). `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Webview asset access.** Reference assets through Vite: `import url from "@/assets/.../foo.svg"`. Absolute filesystem paths don't work in dev or prod.
|
||||
- **`Window.SetSize(w, h)`.** Called from `ViewModeContext`'s `setViewMode`. Height is read fresh from `Window.Size()` and re-passed — see the View mode section for why a constant would shrink the content area.
|
||||
- **Main-window width.** Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (`MainPage` → `isWindows() ? 364 : 380`; see wails/wails#3260).
|
||||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` (legal links) and the BrowserLogin "Try again". `SettingsAbout` has a `window.open` fallback for when Wails refuses (non-http schemes are rejected).
|
||||
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, dialog wrappers like `ConfirmDialog`). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod.
|
||||
- **`Window.SetSize(w, h)`.** Called from `viewMode.tsx`'s `setViewMode` when the user flips the view-mode dropdown. Width comes from `VIEW_WIDTH` (380 / 900); height is read fresh from `Window.Size()` and re-passed, because Wails' macOS `windowSetSize` treats height as the frame (including title bar) while initial window creation treats it as content — re-asserting a constant would shrink the content area by one title-bar height. See the "Default/Advanced view" section above.
|
||||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails).
|
||||
|
||||
## Things in flight (don't be surprised by)
|
||||
|
||||
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table.
|
||||
- **`modules/session/SessionExpirationDialog.tsx`** is the always-on-top auxiliary window for the SSO expiration warning. Triggered by the tray (`tray_session.go openSessionExpiration` at T-FinalWarningLead; `openSessionExtendFlow` from the "Expires in …" tray row). Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. When the countdown hits zero the same component flips to the "expired" copy (`sessionExpiration.expired*` keys).
|
||||
|
||||
## Wails Go API reference
|
||||
|
||||
Full per-service binding signatures, push-event payloads, and model field shapes live in `WAILS-API.md` (sibling). Every service method returns `$CancellablePromise<T>` — `await` and ignore `.cancel()` in practice. Regenerate bindings via `pnpm bindings` after any Go-side change.
|
||||
|
||||
## Useful references
|
||||
|
||||
- `WAILS-API.md` (sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns `$CancellablePromise<T>` (`await` and ignore `.cancel()` in practice). Regenerate via `pnpm bindings` after any Go-side change.
|
||||
- `WAILS-API.md` (sibling) — full binding signatures, push events, and model shapes.
|
||||
- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
- Wails v3 docs (may 403 from some clients): https://v3.wails.io/
|
||||
- `../CLAUDE.md` — Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals.
|
||||
- `../CLAUDE.md` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals.
|
||||
|
||||
@@ -15,47 +15,44 @@ import { welcome } from "@/lib/welcome";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
import { initPlatform } from "@/lib/platform";
|
||||
import { initLogForwarding } from "@/lib/logs";
|
||||
|
||||
// Must run first so even init-time logs reach the Go log pipeline.
|
||||
initLogForwarding();
|
||||
|
||||
welcome();
|
||||
|
||||
Promise.all([
|
||||
initI18n().catch((e) => {
|
||||
// Surface init failures in the console so a misconfigured glob
|
||||
// doesn't quietly blank the UI; render anyway with i18next in
|
||||
// whatever state it ended up in (t() will fall back to keys).
|
||||
console.error("i18n init failed:", e);
|
||||
}),
|
||||
initPlatform().catch((e) => {
|
||||
console.error("platform init failed:", e);
|
||||
}),
|
||||
]).finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
])
|
||||
.finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="dialog">
|
||||
<Route
|
||||
path="browser-login"
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
|
||||
<Route path="install-progress" element={<UpdateInProgressDialog />} />
|
||||
<Route
|
||||
path="session-expiration"
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path="session-expiration" element={<SessionExpirationDialog />} />
|
||||
<Route path="welcome" element={<WelcomeDialog />} />
|
||||
<Route path="error" element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to={"/"} replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,12 @@ import { cn } from "@/lib/cn";
|
||||
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
|
||||
|
||||
type Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
/** Visual color scheme. Defaults to `info` (sky), used as the
|
||||
* "Active profile" indicator. */
|
||||
variant?: BadgeVariant;
|
||||
/** Optional leading lucide icon. */
|
||||
icon?: ComponentType<LucideProps>;
|
||||
/** Override icon size. Defaults to 10px to match the compact pill. */
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
@@ -19,6 +23,10 @@ const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
danger: "bg-red-900 border border-red-700 text-red-200",
|
||||
};
|
||||
|
||||
// Pill shape sized for inline use next to text. `top-px` nudges the badge
|
||||
// down so its midline aligns with the surrounding text baseline; `leading-none`
|
||||
// lets the small text sit flush in the pill without the line-height padding
|
||||
// inflating it.
|
||||
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
|
||||
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
|
||||
ref,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Static map — Tailwind JIT only picks up literal class names, so dynamic
|
||||
// template strings would be invisible to it.
|
||||
const VARIANT_HOVER = {
|
||||
default: "group-hover/copy:[&_*]:text-nb-gray-300",
|
||||
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
|
||||
@@ -17,6 +19,10 @@ type CopyToClipboardProps = {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// variant picks the text colour the wrapped content fades into on hover.
|
||||
// - "default" → nb-gray-300 (peer-details, settings, etc.)
|
||||
// - "bright" → nb-gray-200 (deeper-surface contexts like the main
|
||||
// connection card where text needs more lift)
|
||||
variant?: CopyToClipboardVariant;
|
||||
};
|
||||
|
||||
@@ -30,15 +36,8 @@ export const CopyToClipboard = ({
|
||||
alwaysShowIcon = false,
|
||||
variant = "default",
|
||||
}: CopyToClipboardProps) => {
|
||||
const wrapperRef = useRef<HTMLButtonElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -48,25 +47,29 @@ export const CopyToClipboard = ({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 500);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left",
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative truncate min-w-0",
|
||||
// [&_*] is Tailwind's arbitrary descendant variant: & is
|
||||
// this element, _ is the CSS descendant combinator, * is
|
||||
// every descendant. The generated selector has higher
|
||||
// specificity than a child's own text-nb-gray-* class, so
|
||||
// the hover colour wins the cascade.
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
@@ -102,6 +105,6 @@ export const CopyToClipboard = ({
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
@@ -10,9 +11,14 @@ import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
|
||||
// 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 labelFor = (lang: Language): string =>
|
||||
lang.englishName && lang.englishName !== lang.displayName
|
||||
@@ -105,7 +111,7 @@ export function LanguagePicker() {
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)]",
|
||||
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
|
||||
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
|
||||
"origin-[var(--radix-popover-content-transform-origin)]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
|
||||
@@ -7,6 +7,10 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { ComponentType } from "react";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
|
||||
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
|
||||
// icon centered inside. The `variant` selects the semantic colour scheme — all
|
||||
// variants keep the neutral dark tile + border; only the icon colour changes
|
||||
// to match the action's severity.
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
|
||||
@@ -12,7 +12,11 @@ type Props = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -56,7 +60,10 @@ export const Tooltip = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
|
||||
<RTooltip.Provider
|
||||
delayDuration={delayDuration}
|
||||
disableHoverableContent={!interactive}
|
||||
>
|
||||
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<RTooltip.Trigger
|
||||
asChild
|
||||
@@ -79,7 +86,9 @@ export const Tooltip = ({
|
||||
alignOffset={alignOffset}
|
||||
onPointerEnter={interactive ? cancelClose : undefined}
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
|
||||
onPointerDownOutside={
|
||||
interactive ? undefined : (e) => e.preventDefault()
|
||||
}
|
||||
className={cn(
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
|
||||
@@ -8,7 +8,17 @@ type Props = {
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -3,32 +3,32 @@ import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
|
||||
function VerticalTabsRoot({ className, ...props }, ref) {
|
||||
const Root = forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<Tabs.TabsProps, "orientation">
|
||||
>(function VerticalTabsRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
orientation={"vertical"}
|
||||
className={cn("flex flex-1 min-h-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||
function VerticalTabsList({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
orientation={"vertical"}
|
||||
className={cn("flex flex-1 min-h-0", className)}
|
||||
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TriggerProps = Tabs.TabsTriggerProps & {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
@@ -36,47 +36,54 @@ type TriggerProps = Tabs.TabsTriggerProps & {
|
||||
adornment?: ReactNode;
|
||||
};
|
||||
|
||||
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
|
||||
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function VerticalTabsTrigger(
|
||||
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"shrink-0 ml-2 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<h2
|
||||
className={cn(
|
||||
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
});
|
||||
<Icon
|
||||
size={iconSize}
|
||||
className={cn(
|
||||
"shrink-0 ml-2 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<h2
|
||||
className={cn(
|
||||
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />;
|
||||
});
|
||||
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
|
||||
function VerticalTabsContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<Tabs.Content
|
||||
ref={ref}
|
||||
className={cn("outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -10,6 +10,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -131,13 +134,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
ref,
|
||||
) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const iconSize = size === "xs" ? 12 : 14;
|
||||
return (
|
||||
<button
|
||||
@@ -160,8 +156,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
.writeText(copy)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ import { ReactNode, forwardRef } from "react";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
// ConfirmDialog is the shared layout wrapper used by dialog-style window
|
||||
// surfaces (SessionExpiration, …). Purely a layout
|
||||
// primitive — callers compose the contents (SquareIcon, DialogHeading,
|
||||
// DialogDescription, DialogActions) so each dialog can tweak its own
|
||||
// internal structure without growing the ConfirmDialog API.
|
||||
//
|
||||
// Callers that mount the dialog inside its own Wails window pair this
|
||||
// with useAutoSizeWindow by forwarding the returned ref onto the content
|
||||
// wrapper so the window height tracks the rendered content.
|
||||
type ConfirmDialogProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -6,13 +6,25 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
|
||||
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
|
||||
// title + (optionally multi-line) description with Cancel / confirm buttons
|
||||
// in the footer. It's the in-window counterpart to a native confirm dialog.
|
||||
//
|
||||
// Most call sites should not render this directly: use the imperative
|
||||
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
|
||||
// single instance at the provider level. Render ConfirmModal yourself only
|
||||
// when you need bespoke control over its open/busy lifecycle.
|
||||
type ConfirmModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
/** Confirm button label. */
|
||||
confirmLabel: string;
|
||||
/** Cancel button label; defaults to the shared "Cancel" string. */
|
||||
cancelLabel?: string;
|
||||
/** Use the destructive (red) confirm button variant. */
|
||||
danger?: boolean;
|
||||
/** Disable the buttons (and ignore dismiss) while an action runs. */
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -31,7 +43,9 @@ export const ConfirmModal = ({
|
||||
}: ConfirmModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Retain last content so it survives Radix's close animation.
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// DialogActions wraps a vertical stack of Buttons inside a dialog surface.
|
||||
// The wails-no-draggable class lets the user click the buttons even when
|
||||
// the dialog window itself is draggable from any background region.
|
||||
type DialogActionsProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DialogActions = ({ children, className }: DialogActionsProps) => (
|
||||
<div className={cn("wails-no-draggable flex flex-col gap-3 w-full mx-auto", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable flex flex-col gap-3 w-full mx-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ReactNode } from "react";
|
||||
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> = {
|
||||
@@ -15,12 +17,18 @@ type DialogDescriptionProps = {
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className,
|
||||
align = "center",
|
||||
}: DialogDescriptionProps) => (
|
||||
<p className={cn("w-full text-sm text-nb-gray-300 select-none", alignClass[align], className)}>
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
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> = {
|
||||
@@ -16,6 +19,12 @@ type DialogHeadingProps = {
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export const DaemonUnavailableOverlay = () => {
|
||||
@@ -21,6 +21,10 @@ export const DaemonUnavailableOverlay = () => {
|
||||
className={
|
||||
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable"
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
|
||||
<div
|
||||
|
||||
@@ -3,10 +3,6 @@ import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
|
||||
// Knob to shift the centered main-window content up/down together.
|
||||
export const CONTENT_VERTICAL_OFFSET = "-1.4rem";
|
||||
export const contentTop = (base: string) => `calc(${base} + ${CONTENT_VERTICAL_OFFSET})`;
|
||||
|
||||
type Props = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
@@ -19,9 +15,8 @@ export const EmptyState = ({ icon, title, description, className }: Props) => {
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-start max-w-sm mx-auto relative"
|
||||
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]"
|
||||
}
|
||||
style={{ top: contentTop("7.8rem") }}
|
||||
>
|
||||
<SquareIcon icon={icon} className={"mb-3"} />
|
||||
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "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";
|
||||
@@ -22,6 +14,9 @@ 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;
|
||||
@@ -57,151 +52,6 @@ const inputVariants = cva("", {
|
||||
},
|
||||
});
|
||||
|
||||
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
|
||||
const stepAttr = el.step === "" ? 1 : Number(el.step);
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min === "" ? -Infinity : Number(el.min);
|
||||
const max = el.max === "" ? Infinity : Number(el.max);
|
||||
const current = el.value === "" ? 0 : Number(el.value);
|
||||
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||
if (next < min) next = min;
|
||||
if (next > max) next = max;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildInputClassName(
|
||||
opts: Readonly<{
|
||||
variant: InputVariants["variant"];
|
||||
hasCustomPrefix: boolean;
|
||||
hasSuffix: boolean;
|
||||
hasIcon: boolean;
|
||||
readOnly?: boolean;
|
||||
showStepper: boolean;
|
||||
className?: string;
|
||||
}>,
|
||||
): string {
|
||||
return cn(
|
||||
inputVariants({ variant: opts.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",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
opts.hasCustomPrefix && "!border-l-0 !rounded-l-none",
|
||||
opts.hasSuffix && "!pr-9",
|
||||
opts.hasIcon && "!pl-10",
|
||||
"border",
|
||||
opts.readOnly && "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
|
||||
opts.showStepper &&
|
||||
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||
opts.className,
|
||||
);
|
||||
}
|
||||
|
||||
function InputAffix({
|
||||
content,
|
||||
error,
|
||||
disabled,
|
||||
className,
|
||||
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
disabled && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
|
||||
disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputSuffixSlot({
|
||||
suffix,
|
||||
disabled,
|
||||
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
|
||||
disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberStepper({
|
||||
error,
|
||||
disabled,
|
||||
onStep,
|
||||
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-[40px] shrink-0 overflow-hidden",
|
||||
"border border-l-0 rounded-r-md",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "opacity-40 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.increase")}
|
||||
onClick={() => onStep(1)}
|
||||
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.decrease")}
|
||||
onClick={() => onStep(-1)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldMessage({ error, warning }: Readonly<{ error?: string; warning?: string }>) {
|
||||
if (!error && !warning) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-2 inline-flex items-center gap-1",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
@@ -232,29 +82,28 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
const reactId = useId();
|
||||
const inputId = id ?? (label ? `input-${reactId}` : undefined);
|
||||
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = (el: HTMLInputElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref) ref.current = el;
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||
};
|
||||
|
||||
const stepBy = (delta: 1 | -1) => {
|
||||
const el = internalRef.current;
|
||||
if (!el || el.disabled || el.readOnly) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
globalThis.HTMLInputElement.prototype,
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const next = computeNextStepValue(el, delta);
|
||||
const stepAttr = el.step !== "" ? Number(el.step) : 1;
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min !== "" ? Number(el.min) : -Infinity;
|
||||
const max = el.max !== "" ? Number(el.max) : Infinity;
|
||||
const current = el.value === "" ? 0 : Number(el.value);
|
||||
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||
if (next < min) next = min;
|
||||
if (next > max) next = max;
|
||||
setter?.call(el, String(next));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
@@ -272,14 +121,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
) : null;
|
||||
|
||||
const onCopy = async () => {
|
||||
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
|
||||
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,33 +145,37 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
|
||||
const suffix = passwordToggle || copyToggle || customSuffix;
|
||||
const showStepper = isNumber;
|
||||
const warningVariant = warning ? "warning" : variant;
|
||||
const resolvedVariant = error ? "error" : warningVariant;
|
||||
|
||||
const inputClassName = buildInputClassName({
|
||||
variant: resolvedVariant,
|
||||
hasCustomPrefix: !!customPrefix,
|
||||
hasSuffix: !!suffix,
|
||||
hasIcon: !!icon,
|
||||
readOnly: props.readOnly,
|
||||
showStepper,
|
||||
className,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<InputAffix
|
||||
content={customPrefix}
|
||||
error={error}
|
||||
disabled={props.disabled}
|
||||
className={prefixClassName}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({
|
||||
prefixSuffixVariant: error ? "error" : "default",
|
||||
}),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName,
|
||||
)}
|
||||
>
|
||||
{customPrefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
|
||||
props.disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-grow min-w-0">
|
||||
<input
|
||||
@@ -330,17 +183,82 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
type={inputType}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
className={inputClassName}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
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",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-9",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
|
||||
showStepper &&
|
||||
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
|
||||
{suffix && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
|
||||
props.disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStepper && (
|
||||
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-[40px] shrink-0 overflow-hidden",
|
||||
"border border-l-0 rounded-r-md",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
props.disabled && "opacity-40 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={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"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
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",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FieldMessage error={error} warning={warning} />
|
||||
{(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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,39 +7,49 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
shortcut?: ReactNode;
|
||||
};
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
||||
{ iconSize = 16, className, disabled, shortcut, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
|
||||
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
|
||||
<input
|
||||
ref={ref}
|
||||
type={"text"}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
||||
function SearchInput(
|
||||
{ iconSize = 16, className, disabled, shortcut, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"outline-none border-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
"flex items-center gap-2 px-1 h-10",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
{shortcut && (
|
||||
<span
|
||||
>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 shrink-0"}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type={"text"}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
className={cn(
|
||||
"shrink-0 select-none",
|
||||
"inline-flex items-center justify-center",
|
||||
"h-5 min-w-[20px] px-1.5 rounded",
|
||||
"border border-nb-gray-850 bg-nb-gray-920",
|
||||
"text-[10px] font-medium text-nb-gray-400",
|
||||
"wails-no-draggable",
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"outline-none border-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
/>
|
||||
{shortcut && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 select-none",
|
||||
"inline-flex items-center justify-center",
|
||||
"h-5 min-w-[20px] px-1.5 rounded",
|
||||
"border border-nb-gray-850 bg-nb-gray-920",
|
||||
"text-[10px] font-medium text-nb-gray-400",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -31,27 +31,39 @@ export default function FancyToggleSwitch({
|
||||
labelClassName,
|
||||
textWrapperClassName = "max-w-lg",
|
||||
}: Readonly<Props>) {
|
||||
const childrenRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
if (loading) {
|
||||
// Match the global SkeletonTheme in app.tsx (#25282d base /
|
||||
// #33373e highlight) so the loading row blends in with
|
||||
// SettingsSkeleton. box-decoration-clone gives every wrapped line
|
||||
// of text its own rounded corners instead of just the first/last.
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div className={cn("inline-block text-left w-full", className)} aria-busy>
|
||||
<div
|
||||
className={cn("inline-block text-left w-full", className)}
|
||||
aria-busy
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label className={labelClassName}>
|
||||
<span className={shimmer}>{label}</span>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
|
||||
<span
|
||||
className={cn(
|
||||
shimmer,
|
||||
"text-[0.6rem] leading-relaxed",
|
||||
)}
|
||||
>
|
||||
{helpText}
|
||||
</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
|
||||
className={
|
||||
"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,19 +71,16 @@ export default function FancyToggleSwitch({
|
||||
);
|
||||
}
|
||||
|
||||
const fromChildren = (target: EventTarget | null) =>
|
||||
target instanceof Node && childrenRef.current?.contains(target);
|
||||
|
||||
const handleToggle = (event: React.MouseEvent) => {
|
||||
if (disabled || fromChildren(event.target)) return;
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled || fromChildren(event.target)) return;
|
||||
if (disabled) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onChange(!value);
|
||||
handleToggle();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +108,7 @@ export default function FancyToggleSwitch({
|
||||
</div>
|
||||
</div>
|
||||
{children && value ? (
|
||||
<div className="mt-4" ref={childrenRef}>
|
||||
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createContext, ReactNode, useContext, useId, useMemo } from "react";
|
||||
import { createContext, ReactNode, useContext, useId } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SwitchItemGroupContextValue = {
|
||||
value: string;
|
||||
layoutId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
|
||||
@@ -33,10 +34,9 @@ export const SwitchItemGroup = ({
|
||||
disabled = false,
|
||||
}: Props) => {
|
||||
const layoutId = useId();
|
||||
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
||||
|
||||
return (
|
||||
<SwitchItemGroupContext.Provider value={contextValue}>
|
||||
<SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}>
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
|
||||
@@ -27,7 +27,11 @@ export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}>
|
||||
<LabelPrimitive.Root
|
||||
ref={ref as Ref<HTMLLabelElement>}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,18 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
|
||||
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
|
||||
// (for Trigger) handled by the install window's polling-grace branch; a
|
||||
// second popup on top of those is pure noise. Every Update RPC routes
|
||||
// through the shared gRPC conn, so the Unavailable code is the marker.
|
||||
const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
@@ -75,6 +81,9 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Force-install branch: daemon's progress_window:show flipped installing
|
||||
// to true while the UI was idle. Open the install window so the user
|
||||
// sees the progress UI without having to click anything.
|
||||
const prevInstallingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (state.installing && !prevInstallingRef.current) {
|
||||
@@ -83,11 +92,19 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
prevInstallingRef.current = state.installing;
|
||||
}, [state.installing, state.version]);
|
||||
|
||||
// Enforced user-driven branch: kick Trigger() in the background, then
|
||||
// hand off to the install window. The window owns the polling loop and
|
||||
// the final Quit() — this provider just fires the trigger.
|
||||
const triggerUpdate = useCallback(() => {
|
||||
setUpdating(true);
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
UpdateSvc.Trigger()
|
||||
.catch(async (e) => {
|
||||
// The daemon may already be down (force-install branch raced
|
||||
// us). The install window's polling loop handles that case.
|
||||
// Anything else is a real failure — close the install window
|
||||
// (otherwise it spins forever on a daemon that won't ever
|
||||
// produce a result) and surface the error.
|
||||
if (isDaemonUnavailable(e)) return;
|
||||
WindowManager.CloseInstallProgress().catch(console.error);
|
||||
await errorDialog({
|
||||
@@ -110,5 +127,9 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
[state, triggerUpdate, updating],
|
||||
);
|
||||
|
||||
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
|
||||
return (
|
||||
<ClientVersionContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientVersionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { createContext, useContext, useRef, useState, type ReactNode } from "react";
|
||||
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import {
|
||||
Connection as ConnectionSvc,
|
||||
Debug as DebugSvc,
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -37,64 +47,8 @@ const sleep = (ms: number, signal: AbortSignal) =>
|
||||
signal.addEventListener("abort", onAbort);
|
||||
});
|
||||
|
||||
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const setLogLevelBestEffort = async (level: string) => {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level });
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
};
|
||||
|
||||
type LevelState = { original: string; raised: boolean };
|
||||
|
||||
const runTracePhase = async (
|
||||
signal: AbortSignal,
|
||||
level: LevelState,
|
||||
setStage: (s: DebugStage) => void,
|
||||
target: { profileName: string; username: string },
|
||||
traceMinutes: number,
|
||||
) => {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) level.original = cur.level;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
level.raised = true;
|
||||
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await ConnectionSvc.Up(target);
|
||||
|
||||
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: level.original });
|
||||
level.raised = false;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
};
|
||||
const isAbort = (e: unknown) =>
|
||||
e instanceof DOMException && e.name === "AbortError";
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const { activeProfile, username } = useProfile();
|
||||
@@ -121,24 +75,66 @@ const useDebugBundle = () => {
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
const signal = ctrl.signal;
|
||||
const checkAbort = () => {
|
||||
if (signal.aborted)
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
const level: LevelState = { original: "info", raised: false };
|
||||
let originalLevel = "info";
|
||||
let raisedLevel = false;
|
||||
|
||||
try {
|
||||
if (trace) {
|
||||
await runTracePhase(
|
||||
signal,
|
||||
level,
|
||||
setStage,
|
||||
{ profileName: activeProfile, username },
|
||||
traceMinutes,
|
||||
);
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) originalLevel = cur.level;
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
checkAbort();
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
raisedLevel = true;
|
||||
|
||||
checkAbort();
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// already down
|
||||
}
|
||||
checkAbort();
|
||||
await ConnectionSvc.Up({
|
||||
profileName: activeProfile,
|
||||
username,
|
||||
});
|
||||
|
||||
const totalSec =
|
||||
Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({
|
||||
kind: "capturing",
|
||||
remainingSec: remaining,
|
||||
totalSec,
|
||||
});
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
raisedLevel = false;
|
||||
} catch {
|
||||
// restore is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
checkAbort();
|
||||
setStage({ kind: "bundling" });
|
||||
const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
|
||||
const logFileCount = trace
|
||||
? TRACE_LOG_FILE_COUNT
|
||||
: PLAIN_LOG_FILE_COUNT;
|
||||
|
||||
if (uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
@@ -147,7 +143,7 @@ const useDebugBundle = () => {
|
||||
uploadUrl,
|
||||
logFileCount,
|
||||
});
|
||||
throwIfAborted(signal);
|
||||
checkAbort();
|
||||
if (result.path) setLastBundlePath(result.path);
|
||||
setStage({
|
||||
kind: "done",
|
||||
@@ -156,7 +152,13 @@ const useDebugBundle = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbort(e)) {
|
||||
if (level.raised) await setLogLevelBestEffort(level.original);
|
||||
if (raisedLevel) {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
setStage({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
@@ -172,9 +174,7 @@ const useDebugBundle = () => {
|
||||
|
||||
const openBundleDir = () => {
|
||||
if (!lastBundlePath) return;
|
||||
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
|
||||
console.error("[DebugBundleContext] reveal failed", err),
|
||||
);
|
||||
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -204,13 +204,19 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
|
||||
return (
|
||||
<DebugBundleContext.Provider value={value}>
|
||||
{children}
|
||||
</DebugBundleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDebugBundleContext = () => {
|
||||
const ctx = useContext(DebugBundleContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
|
||||
throw new Error(
|
||||
"useDebugBundleContext must be used inside DebugBundleProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
|
||||
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
|
||||
|
||||
// DialogContext exposes an imperative `confirm(...)` that resolves to a
|
||||
// boolean — the in-app equivalent of a native confirmation dialog. The
|
||||
// single <ConfirmModal/> lives here at the provider level, so call sites
|
||||
// just `await confirm({...})` instead of each wiring up their own modal
|
||||
// component + open/busy state.
|
||||
//
|
||||
// const confirm = useConfirm();
|
||||
// if (await confirm({ title, description, confirmLabel })) { …do it… }
|
||||
//
|
||||
// Mounted once (outermost in AppLayout) so it's available in every in-window
|
||||
// route across both the main and settings windows.
|
||||
export type ConfirmOptions = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
|
||||
cancelLabel?: string;
|
||||
/** Use the destructive (red) confirm button variant. */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
@@ -23,7 +28,7 @@ type DialogContextValue = {
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
|
||||
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);
|
||||
@@ -36,16 +41,17 @@ export function DialogProvider({ children }: Readonly<{ children: ReactNode }>)
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>
|
||||
<DialogContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
@@ -12,13 +12,18 @@ const NavSectionContext = createContext<NavSectionContextValue | null>(null);
|
||||
export const useNavSection = (): NavSectionContextValue => {
|
||||
const ctx = useContext(NavSectionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNavSection must be used inside NavSectionProvider");
|
||||
throw new Error(
|
||||
"useNavSection must be used inside NavSectionProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [section, setSection] = useState<NavSection>("peers");
|
||||
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
|
||||
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
|
||||
return (
|
||||
<NavSectionContext.Provider value={{ section, setSection }}>
|
||||
{children}
|
||||
</NavSectionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,9 +12,10 @@ import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
|
||||
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
|
||||
export const isExitNode = (range: string): boolean =>
|
||||
// A range is treated as an exit-node candidate when any of its CIDRs is a
|
||||
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
|
||||
// comma-joined range string for one peer.
|
||||
export const isDefaultRoute = (range: string): boolean =>
|
||||
range.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
|
||||
@@ -44,61 +45,33 @@ export const useNetworks = () => {
|
||||
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { status } = useStatus();
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
// Optimistic overrides: id → expected `selected` value. Applied on top of
|
||||
// the server-side `routes` so toggles paint instantly. Entries are cleared
|
||||
// either when the next server snapshot agrees (success path) or when the
|
||||
// RPC throws (rollback). Linear-style optimistic mutation tracking.
|
||||
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
|
||||
// Mirror of `pending` for use inside async callbacks without re-binding
|
||||
// them on every change.
|
||||
const pendingRef = useRef(pending);
|
||||
useEffect(() => {
|
||||
pendingRef.current = pending;
|
||||
}, [pending]);
|
||||
|
||||
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
|
||||
const STUCK_OVERRIDE_MS = 4000;
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const clearTimer = useCallback((id: string) => {
|
||||
const tid = timersRef.current.get(id);
|
||||
if (tid !== undefined) {
|
||||
clearTimeout(tid);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
const setPendingFor = useCallback((updates: Array<[string, boolean]>) => {
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, sel] of updates) next.set(id, sel);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearPendingFor = useCallback(
|
||||
(ids: string[]) => {
|
||||
for (const id of ids) clearTimer(id);
|
||||
setPending((prev) => {
|
||||
if (ids.every((id) => !prev.has(id))) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const setPendingFor = useCallback(
|
||||
(updates: Array<[string, boolean]>) => {
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, sel] of updates) next.set(id, sel);
|
||||
return next;
|
||||
});
|
||||
for (const [id] of updates) {
|
||||
clearTimer(id);
|
||||
timersRef.current.set(
|
||||
id,
|
||||
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
|
||||
);
|
||||
}
|
||||
},
|
||||
[clearTimer, clearPendingFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const tid of timers.values()) clearTimeout(tid);
|
||||
timers.clear();
|
||||
};
|
||||
const clearPendingFor = useCallback((ids: string[]) => {
|
||||
setPending((prev) => {
|
||||
if (ids.every((id) => !prev.has(id))) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -110,11 +83,19 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The daemon bumps networksRevision whenever the routed-network set or a
|
||||
// selection changes (from any surface) and pushes it on the status stream.
|
||||
// Refetch on every bump so the list stays live without polling — and on
|
||||
// mount, since the revision is already defined by the time this provider
|
||||
// renders (StatusProvider only mounts children once the daemon is reachable).
|
||||
const networksRevision = status?.networksRevision;
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
|
||||
void refresh();
|
||||
}, [refresh, networksRevision]);
|
||||
|
||||
// When the server snapshot agrees with a pending optimistic value, the
|
||||
// mutation is confirmed — drop the override so the row tracks the server
|
||||
// again. Runs whenever routes change.
|
||||
useEffect(() => {
|
||||
if (pendingRef.current.size === 0) return;
|
||||
const confirmed: string[] = [];
|
||||
@@ -135,10 +116,13 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
} else {
|
||||
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
|
||||
}
|
||||
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
|
||||
// Don't clear pending here — let the revision-driven refresh
|
||||
// confirm via the snapshot-match effect. That avoids a flash
|
||||
// back to old state if the refresh races the RPC return.
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Roll back to the last server-observed value for each id.
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id] of rollback) next.delete(id);
|
||||
@@ -159,6 +143,9 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
[mutate, setPendingFor],
|
||||
);
|
||||
|
||||
// Batch toggle for the bottom-bar select-all switch. The daemon's
|
||||
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
|
||||
// per-ID calls — one round-trip + one refresh.
|
||||
const setNetworksSelected = useCallback(
|
||||
async (ids: string[], selected: boolean) => {
|
||||
if (ids.length === 0) return;
|
||||
@@ -173,7 +160,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
[mutate, setPendingFor, routes],
|
||||
);
|
||||
|
||||
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
|
||||
// Exit nodes are mutually exclusive, but the daemon enforces that now —
|
||||
// selecting one deselects the other exit nodes. Append so activating an
|
||||
// exit node doesn't wipe the user's network-route selections. We also
|
||||
// mirror that mutual-exclusion locally so the optimistic paint matches
|
||||
// the daemon's eventual state.
|
||||
const toggleExitNode = useCallback(
|
||||
async (id: string, selected: boolean) => {
|
||||
const target = !selected;
|
||||
@@ -181,7 +172,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const rollback: Array<[string, boolean]> = [[id, selected]];
|
||||
if (target) {
|
||||
for (const r of routes) {
|
||||
if (r.id !== id && isExitNode(r.range) && r.selected) {
|
||||
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
|
||||
updates.push([r.id, false]);
|
||||
rollback.push([r.id, true]);
|
||||
}
|
||||
@@ -194,6 +185,9 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
);
|
||||
|
||||
const value = useMemo<NetworksContextValue>(() => {
|
||||
// Apply pending overrides on top of the server snapshot. The override
|
||||
// map is usually empty or tiny (one entry per in-flight toggle), so
|
||||
// the per-route lookup is effectively free.
|
||||
const effective =
|
||||
pending.size === 0
|
||||
? routes
|
||||
@@ -203,8 +197,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
? r
|
||||
: { ...r, selected: override };
|
||||
});
|
||||
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
|
||||
const exitNodes = effective.filter((r) => isExitNode(r.range));
|
||||
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
|
||||
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
|
||||
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
|
||||
return {
|
||||
routes: effective,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
|
||||
type PeerDetailContextValue = {
|
||||
@@ -11,13 +11,18 @@ const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
|
||||
export const usePeerDetail = (): PeerDetailContextValue => {
|
||||
const ctx = useContext(PeerDetailContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
|
||||
throw new Error(
|
||||
"usePeerDetail must be used inside PeerDetailProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [selected, setSelected] = useState<PeerStatus | null>(null);
|
||||
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
|
||||
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
|
||||
return (
|
||||
<PeerDetailContext.Provider value={{ selected, setSelected }}>
|
||||
{children}
|
||||
</PeerDetailContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,19 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import {
|
||||
Connection,
|
||||
ProfileSwitcher,
|
||||
Profiles as ProfilesSvc,
|
||||
} from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
@@ -54,7 +58,10 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
setActiveProfile(active.profileName || "default");
|
||||
setProfiles(list);
|
||||
} catch (e) {
|
||||
// Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here.
|
||||
// Daemon-down is already surfaced globally by
|
||||
// DaemonUnavailableOverlay; a second popup on top of it is
|
||||
// pure noise. Every profile RPC routes through the same gRPC
|
||||
// conn, so the Unavailable code is the reliable marker.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("code = Unavailable")) {
|
||||
return;
|
||||
@@ -69,14 +76,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
// The tray and other windows drive switches through the same
|
||||
// ProfileSwitcher.SwitchActive RPC, which emits this event on success.
|
||||
// Without the subscription, a tray-initiated switch leaves this
|
||||
// window painting the old activeProfile until the next mount.
|
||||
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
|
||||
refresh().catch((err: unknown) =>
|
||||
console.error("[ProfileContext] refresh failed", err),
|
||||
);
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
@@ -115,30 +124,21 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const value = useMemo<ProfileContextValue>(
|
||||
() => ({
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
}),
|
||||
[
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
],
|
||||
return (
|
||||
<ProfileContext.Provider
|
||||
value={{
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
);
|
||||
|
||||
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -3,22 +3,20 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
type SettingsContextValue = {
|
||||
@@ -49,7 +47,9 @@ export const useSettings = () => {
|
||||
export const useAutostartSetting = () => {
|
||||
const ctx = useContext(AutostartContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
|
||||
throw new Error(
|
||||
"useAutostartSetting must be used inside AutostartSettingsProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -97,7 +97,10 @@ const useSettingsState = () => {
|
||||
|
||||
const save = useCallback(
|
||||
async (next: Config) => {
|
||||
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
|
||||
// The daemon masks an existing PSK as "**********" in GetConfig.
|
||||
// Sending the mask back round-trips it into the saved config and
|
||||
// wgtypes.ParseKey fails on the next connect. Drop the mask so
|
||||
// unrelated toggles don't corrupt the stored PSK.
|
||||
const { preSharedKey, ...rest } = next;
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
@@ -123,7 +126,7 @@ const useSettingsState = () => {
|
||||
const next = { ...c, [k]: v };
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
save(next).catch(logSaveError);
|
||||
void save(next);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return next;
|
||||
});
|
||||
@@ -172,19 +175,26 @@ const useSettingsState = () => {
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
);
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
return (
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{value ? (
|
||||
<SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
|
||||
) : (
|
||||
{!config ? (
|
||||
<SettingsSkeleton />
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
config,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -222,10 +232,9 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AutostartContextValue>(
|
||||
() => ({ autostart, setAutostartEnabled }),
|
||||
[autostart, setAutostartEnabled],
|
||||
return (
|
||||
<AutostartContext.Provider value={{ autostart, setAutostartEnabled }}>
|
||||
{children}
|
||||
</AutostartContext.Provider>
|
||||
);
|
||||
|
||||
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { DaemonFeed } from "@bindings/services";
|
||||
import { Status } from "@bindings/services/models.js";
|
||||
import type { Status } from "@bindings/services/models.js";
|
||||
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
|
||||
|
||||
const EVENT_STATUS = "netbird:status";
|
||||
|
||||
// StatusContext is the single subscription point for the daemon status
|
||||
// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener,
|
||||
// and the synthetic DaemonUnavailable handling. The provider also renders
|
||||
// the DaemonUnavailableOverlay so every layout that mounts it inherits the
|
||||
// same blocker without re-importing the component.
|
||||
//
|
||||
// Boolean flags consumers should prefer over hand-rolled checks:
|
||||
// - isReady first DaemonFeed.Get has resolved
|
||||
// - isDaemonUnavailable ready and status === "DaemonUnavailable"
|
||||
// - isDaemonAvailable ready and status !== "DaemonUnavailable"
|
||||
type StatusContextValue = {
|
||||
status: Status | null;
|
||||
error: string | null;
|
||||
@@ -43,14 +45,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
|
||||
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
|
||||
// DaemonFeed.Get returns a gRPC error when the socket itself is
|
||||
// unreachable (daemon not running, missing socket, etc.); only
|
||||
// the streaming path synthesizes a DaemonUnavailable status.
|
||||
// Synthesize one here too so the overlay paints on cold start
|
||||
// without a daemon — otherwise the whole UI stays blank since
|
||||
// `isReady` would never flip and StatusProvider's short-circuit
|
||||
// wouldn't render either children or the overlay.
|
||||
setStatus({ status: "DaemonUnavailable" } as Status);
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
|
||||
void refresh();
|
||||
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
|
||||
setStatus(ev.data);
|
||||
setError(null);
|
||||
@@ -64,20 +72,23 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
||||
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
|
||||
const isDaemonAvailable = isReady && !isDaemonUnavailable;
|
||||
|
||||
const value = useMemo<StatusContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
error,
|
||||
refresh,
|
||||
isReady,
|
||||
isDaemonUnavailable,
|
||||
isDaemonAvailable,
|
||||
}),
|
||||
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable],
|
||||
);
|
||||
|
||||
// Don't mount children until the first DaemonFeed.Get has resolved and the
|
||||
// daemon is reachable. Consumers (ProfileContext, SettingsContext, …)
|
||||
// can then assume any daemon RPC they make at mount will reach the
|
||||
// socket — no per-context availability gating. When the daemon flips
|
||||
// back to unavailable the children unmount and remount fresh once it
|
||||
// returns.
|
||||
return (
|
||||
<StatusContext.Provider value={value}>
|
||||
<StatusContext.Provider
|
||||
value={{
|
||||
status,
|
||||
error,
|
||||
refresh,
|
||||
isReady,
|
||||
isDaemonUnavailable,
|
||||
isDaemonAvailable,
|
||||
}}
|
||||
>
|
||||
{isDaemonAvailable && children}
|
||||
<DaemonUnavailableOverlay />
|
||||
</StatusContext.Provider>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -14,8 +13,13 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ViewMode = "default" | "advanced";
|
||||
|
||||
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
|
||||
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
|
||||
// Window widths per view. Height stays at whatever the window was first
|
||||
// created with — we deliberately don't pass a fixed height to
|
||||
// Window.SetSize because Wails' macOS implementation interprets it as the
|
||||
// outer frame (windowSetSize → setFrame:), while the initial creation
|
||||
// uses initWithContentRect:. The two differ by one title-bar height
|
||||
// (~28px), so re-asserting 640 here would chop ~28px off the content
|
||||
// area on the first switch and visually shift everything inside.
|
||||
export const VIEW_WIDTH: Record<ViewMode, number> = {
|
||||
default: 380,
|
||||
advanced: 900,
|
||||
@@ -29,12 +33,18 @@ type ViewModeContextValue = {
|
||||
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
|
||||
|
||||
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [mode, setMode] = useState<ViewMode>("default");
|
||||
const [viewMode, setMode] = useState<ViewMode>("default");
|
||||
// Mirror of viewMode for dedup inside the async setViewMode without
|
||||
// adding the state to the callback's dep array (which would re-create
|
||||
// the callback on every change).
|
||||
const modeRef = useRef<ViewMode>("default");
|
||||
|
||||
// Hydrate from the persisted preference. The Go side has already sized
|
||||
// the main window to match (see main.go), so this only catches the
|
||||
// React state and dropdown checkmark up — no resize is triggered here.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
void Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
const saved = prefs?.viewMode as ViewMode | undefined;
|
||||
@@ -49,30 +59,31 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
|
||||
// Resize the window BEFORE flipping React state — otherwise the new
|
||||
// layout (e.g., advanced-mode right panel mounting) paints into a
|
||||
// window that hasn't grown yet, causing a brief flex-overflow that
|
||||
// wobbles the connect toggle's position. Cost: one IPC roundtrip
|
||||
// (~30ms) before the dropdown checkmark updates.
|
||||
const setViewMode = useCallback((mode: ViewMode) => {
|
||||
if (modeRef.current === mode) return;
|
||||
modeRef.current = mode;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
// Reuse the live frame height instead of asserting a
|
||||
// constant — keeps content area stable across switches
|
||||
// (see VIEW_WIDTH comment above).
|
||||
const size = await Window.Size().catch(() => null);
|
||||
const width = VIEW_WIDTH[mode];
|
||||
const height = size?.height ?? 640;
|
||||
await Window.SetSize(width, height).catch(() => {});
|
||||
setMode(mode);
|
||||
const pref =
|
||||
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
|
||||
Preferences.SetViewMode(pref).catch((err: unknown) =>
|
||||
console.error("[ViewModeContext] SetViewMode failed", err),
|
||||
);
|
||||
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
|
||||
void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ViewModeContextValue>(
|
||||
() => ({ viewMode: mode, setViewMode }),
|
||||
[mode, setViewMode],
|
||||
return (
|
||||
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
|
||||
{children}
|
||||
</ViewModeContext.Provider>
|
||||
);
|
||||
|
||||
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useViewMode = () => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
|
||||
font-family: "JetBrains Mono Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@@ -19,8 +19,8 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -32,14 +32,14 @@ body,
|
||||
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
|
||||
*/
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
}
|
||||
|
||||
.wails-draggable {
|
||||
--wails-draggable: drag;
|
||||
cursor: default;
|
||||
--wails-draggable: drag;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wails-no-draggable {
|
||||
--wails-draggable: no-drag;
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,35 @@ import { useLayoutEffect, useRef } from "react";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
|
||||
// Sizes the current Wails window to the measured content height (keeping `width`),
|
||||
// then shows it. Re-applies on content resize and language change.
|
||||
// useAutoSizeWindow resizes the current Wails window so its height matches
|
||||
// the measured height of the content element the returned ref is attached
|
||||
// to. Width stays fixed (Wails has no "fit-content-width" notion and the
|
||||
// dialog-style session windows want a stable horizontal footprint).
|
||||
//
|
||||
// On first measurement the hook also calls Window.Show()/Focus() — the
|
||||
// Go-side opens the window with Hidden: true so the user never sees the
|
||||
// initial placeholder size snap to the measured size. Subsequent
|
||||
// measurements (content changes after mount) only adjust the size.
|
||||
//
|
||||
// Re-measures via ResizeObserver so adding/removing content (e.g. the
|
||||
// SessionExpiration title swapping at countdown zero) keeps the chrome
|
||||
// tight to the content with no scrollbar.
|
||||
//
|
||||
// Also re-measures on i18next `languageChanged`. The ResizeObserver in
|
||||
// theory catches the same reflow when translated strings replace each
|
||||
// other (DE/HU strings often wrap to more lines than EN), but in practice
|
||||
// the observer can settle on a stale size before React's commit and the
|
||||
// font's glyph metrics finish updating. An explicit double-rAF after the
|
||||
// language flip guarantees the final layout is the one we measure.
|
||||
//
|
||||
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
|
||||
// while the caller is still resolving its initial content (e.g. waiting
|
||||
// on an async probe) so the window stays Hidden instead of briefly
|
||||
// rendering placeholder padding at the wrong size — Linux/GNOME in
|
||||
// particular paints whatever the frame ends up at, and a transient
|
||||
// half-height frame can leak through. Flip ready=true once the real
|
||||
// content is in the DOM; the effect re-runs, measures the final size,
|
||||
// and shows the window.
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
||||
const ref = useRef<T | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
@@ -12,25 +39,39 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
||||
let shown = false;
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
const showOnce = () => {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
Window.Show().catch(() => {});
|
||||
Window.Focus().catch(() => {});
|
||||
};
|
||||
const apply = () => {
|
||||
if (!ready) return;
|
||||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||||
if (h <= 0) return;
|
||||
// Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
|
||||
Window.Size()
|
||||
// Wails Window.SetSize takes the *frame* size on every platform
|
||||
// (Windows: SetWindowPos, macOS: setFrame:, Linux: GTK frame).
|
||||
// The OS title bar lives inside the frame, so we have to add the
|
||||
// chrome height before calling SetSize, or the title bar eats
|
||||
// pixels from the bottom and the rendered content gets clipped.
|
||||
//
|
||||
// window.outerHeight / window.innerHeight are useless here:
|
||||
// WebView2 (and WKWebView) report the WebView's own outer == inner
|
||||
// because the WebView itself has no chrome — the OS title bar is
|
||||
// outside the WebView's window object entirely. The only way to
|
||||
// recover the chrome height is to compare the OS frame height
|
||||
// (Wails-side Window.Size()) against the WebView viewport
|
||||
// (window.innerHeight).
|
||||
void Window.Size()
|
||||
.then((frame) => {
|
||||
const chrome = Math.max(0, frame.height - window.innerHeight);
|
||||
return Window.SetSize(width, h + chrome);
|
||||
})
|
||||
.then(showOnce)
|
||||
.then(() => {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
void Window.Show().catch(() => {});
|
||||
void Window.Focus().catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
// Double rAF: first frame lands after React commits the new
|
||||
// translated strings, second frame lands after the browser has
|
||||
// recomputed layout, so apply() sees the final box.
|
||||
const scheduleApply = () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
|
||||
export type Shortcut = {
|
||||
key: string;
|
||||
cmd?: boolean;
|
||||
key: string; // e.g. "k", "Escape", "/"
|
||||
cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
// When true (default), preventDefault is called on a match.
|
||||
preventDefault?: boolean;
|
||||
};
|
||||
|
||||
export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
|
||||
// Listens for a keyboard shortcut on the window and invokes `callback` on
|
||||
// match. Disable conditionally via `enabled` to avoid stealing keys while a
|
||||
// dialog/panel is in the foreground.
|
||||
export const useKeyboardShortcut = (
|
||||
shortcut: Shortcut,
|
||||
callback: () => void,
|
||||
enabled = true,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -21,8 +28,8 @@ export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, en
|
||||
if (shortcut.preventDefault !== false) e.preventDefault();
|
||||
callback();
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [
|
||||
shortcut.key,
|
||||
shortcut.cmd,
|
||||
@@ -34,13 +41,16 @@ export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, en
|
||||
]);
|
||||
};
|
||||
|
||||
// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
|
||||
export const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
|
||||
export const formatShortcut = (shortcut: Shortcut): string => {
|
||||
// navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
|
||||
const mac = isMacOS();
|
||||
const parts: string[] = [];
|
||||
if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
|
||||
if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
|
||||
if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
|
||||
if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
|
||||
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
|
||||
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
|
||||
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
|
||||
return parts.join(mac ? "" : "+");
|
||||
return parts.join(isMac ? "" : "+");
|
||||
};
|
||||
|
||||
@@ -5,18 +5,21 @@ import { useConfirm } from "@/contexts/DialogContext.tsx";
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
|
||||
// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
|
||||
// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
|
||||
// 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(
|
||||
String.raw`^(https?:\/\/)?` +
|
||||
String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
|
||||
String.raw`((\d{1,3}\.){3}\d{1,3}))` +
|
||||
String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
|
||||
String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
|
||||
String.raw`(\#[-a-z\d_]*)?$`,
|
||||
"^(https?:\\/\\/)?" +
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" +
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i",
|
||||
);
|
||||
|
||||
// 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 "";
|
||||
@@ -24,18 +27,28 @@ export function normalizeManagementUrl(input: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block.
|
||||
// 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,
|
||||
@@ -67,10 +80,15 @@ export function useManagementUrl() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { config, saveField } = useSettings();
|
||||
const [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl));
|
||||
const [mode, setModeState] = useState<ManagementMode>(
|
||||
modeFromUrl(config.managementUrl),
|
||||
);
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
// Self-hosted reachability soft-check, mirrored from the onboarding /
|
||||
// profile-creation flows: a failed probe is a non-blocking orange warning,
|
||||
// and a second Save with the same URL goes through regardless.
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
|
||||
@@ -81,12 +99,19 @@ export function useManagementUrl() {
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
// Clear the stale warning whenever the target changes.
|
||||
useEffect(() => {
|
||||
setUnreachable(false);
|
||||
}, [url, modeState]);
|
||||
}, [url, mode]);
|
||||
|
||||
const setMode = async (next: ManagementMode) => {
|
||||
if (next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
if (
|
||||
next === ManagementMode.Cloud &&
|
||||
config.managementUrl !== CLOUD_MANAGEMENT_URL
|
||||
) {
|
||||
// Switching from a self-hosted management server to NetBird Cloud
|
||||
// re-points the client at a different deployment and forces a
|
||||
// reconnect/re-login. Confirm via the in-app modal before applying.
|
||||
const ok = await confirm({
|
||||
title: t("settings.general.management.switchCloudTitle"),
|
||||
description: t("settings.general.management.switchCloudMessage"),
|
||||
@@ -94,9 +119,7 @@ export function useManagementUrl() {
|
||||
});
|
||||
if (!ok) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
|
||||
console.error("save managementUrl failed", err),
|
||||
);
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
return;
|
||||
}
|
||||
setModeState(next);
|
||||
@@ -104,14 +127,19 @@ export function useManagementUrl() {
|
||||
|
||||
const normalizedUrl = normalizeManagementUrl(url);
|
||||
const urlValid = isValidManagementUrl(url);
|
||||
const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||
const targetUrl =
|
||||
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||
const dirty = targetUrl !== config.managementUrl;
|
||||
const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||
const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
const showError =
|
||||
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = async () => {
|
||||
if (modeState === ManagementMode.SelfHosted && !unreachable) {
|
||||
// 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);
|
||||
@@ -125,7 +153,7 @@ export function useManagementUrl() {
|
||||
};
|
||||
|
||||
return {
|
||||
mode: modeState,
|
||||
mode,
|
||||
setMode,
|
||||
url,
|
||||
setUrl,
|
||||
|
||||
@@ -5,6 +5,13 @@ 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.
|
||||
// Order matters: SettingsContext depends on ProfileContext; ClientVersionContext
|
||||
// reads StatusContext events.
|
||||
//
|
||||
// Page-specific surface (the main Header, the settings draggable strip,
|
||||
// view-mode + nav-section providers) lives inside the page components, not here.
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
|
||||
@@ -9,6 +9,9 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// iOS-style push transition: incoming pane slides in from the right while
|
||||
// the outgoing pane shifts slightly left. Same easing on both sides so
|
||||
// they feel like one motion.
|
||||
const PANEL_TRANSITION = {
|
||||
duration: 0.32,
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
|
||||
@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
30
client/ui/frontend/src/lib/dialogs.ts
Normal file
30
client/ui/frontend/src/lib/dialogs.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
// Options for errorDialog. Kept as a {Title, Message} object so the many
|
||||
// existing call sites read unchanged after the switch from the native OS
|
||||
// MessageBox to the custom window below.
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
// errorDialog surfaces a user-actionable failure. It opens the custom,
|
||||
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
|
||||
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
|
||||
// more, despite the name.
|
||||
//
|
||||
// Why the native box is gone: on Windows a native MessageBox attached to a
|
||||
// parent window disables that window (WS_DISABLED) for its lifetime, and the
|
||||
// main window's WindowClosing hook hides instead of closing — the two raced
|
||||
// and could leave the main window unable to process its close (X) button after
|
||||
// an error was shown. The custom window has its own chrome and never touches
|
||||
// another window's enabled state, so that class of bug is gone (and with it
|
||||
// the old `Detached: true` Windows-only workaround, plus the warning/info/
|
||||
// question wrappers that nothing called).
|
||||
//
|
||||
// Title and message must already be localised. Resolves as soon as the window
|
||||
// is opened (it does not block until the user dismisses it), so `await`ing
|
||||
// callers continue immediately after the dialog appears.
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
@@ -1,34 +1,40 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClientError = { short?: string; long?: string };
|
||||
|
||||
const asClientError = (obj: object): ClientError => {
|
||||
const withCause = obj as { cause?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const parseMessageJson = (message: unknown): ClientError | null => {
|
||||
if (typeof message !== "string") return null;
|
||||
const m = message.trim();
|
||||
if (!m.startsWith("{") || !m.endsWith("}")) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") return asClientError(parsed);
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractClientError = (e: unknown): ClientError | null => {
|
||||
// Shared error formatter for native dialog bodies.
|
||||
//
|
||||
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
|
||||
// wraps daemon errors in a ClientError struct exposed to the TS side as
|
||||
// {code, short, long}. Short is already localised (Go reads the current
|
||||
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
|
||||
// Long always carries the unwrapped raw daemon message so the operator can
|
||||
// see the JWT / mgm stack when the short text is too generic.
|
||||
//
|
||||
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
|
||||
// .message holds the JSON-stringified payload and the structured object
|
||||
// lives on .cause — Object.keys(err) is empty in that case. We therefore
|
||||
// probe .cause first, then fall back to parsing .message as JSON, then
|
||||
// to plain .message text for callers that still hand us a raw Error.
|
||||
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const withCause = e as { cause?: unknown; message?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause;
|
||||
return withCause.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parseMessageJson(withCause.message);
|
||||
if (typeof withCause.message === "string") {
|
||||
const m = withCause.message.trim();
|
||||
if (m.startsWith("{") && m.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
|
||||
return parsed.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parsed as { short?: string; long?: string };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to plain-message handling
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
@@ -44,12 +50,3 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
try {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) +
|
||||
" " +
|
||||
sizes[i]
|
||||
);
|
||||
} catch {
|
||||
return "0 B";
|
||||
}
|
||||
};
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
@@ -14,7 +22,10 @@ export const latencyColor = (ms: number): string => {
|
||||
return "text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
export const formatRelative = (
|
||||
unixSeconds: number,
|
||||
nowMs: number = Date.now(),
|
||||
): string | null => {
|
||||
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
|
||||
const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
@@ -23,22 +34,13 @@ export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()):
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
|
||||
// shortenDns drops the domain suffix off a DNS name, returning just the
|
||||
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
|
||||
// is operator-configurable so we keep everything before the first dot rather
|
||||
// than matching against a known suffix. The full DNS name still lands on
|
||||
// the clipboard via the copy helpers' explicit message prop.
|
||||
export const shortenDns = (fqdn: string | undefined | null): string => {
|
||||
if (!fqdn) return "";
|
||||
const dot = fqdn.indexOf(".");
|
||||
return dot === -1 ? fqdn : fqdn.slice(0, dot);
|
||||
};
|
||||
|
||||
// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
|
||||
export const formatRemaining = (seconds: number): string => {
|
||||
const s = Math.max(0, Math.trunc(seconds));
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const secs = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
return `${pad(minutes)}:${pad(secs)}`;
|
||||
};
|
||||
|
||||
@@ -5,26 +5,34 @@ import { Events } from "@wailsio/runtime";
|
||||
import { Preferences, I18n } from "@bindings/services";
|
||||
import { LanguageCode } from "@bindings/i18n/models.js";
|
||||
|
||||
// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
|
||||
type BundleEntry = { message: string; description?: string };
|
||||
const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
|
||||
// Vite glob-imports every shipped bundle at build time. The locales tree
|
||||
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
|
||||
// and the React app share one JSON source. Adding a language only
|
||||
// requires dropping the new folder there and the row in `_index.json` —
|
||||
// no edit to this file. The `eager: true` import keeps the bundles
|
||||
// inlined in the main JS chunk, same shape as a static import. Path is
|
||||
// relative on purpose — alias-based globs (`@/…`) silently resolve to an
|
||||
// empty match in some Vite dev-mode setups. `server.fs.allow` in
|
||||
// `vite.config.ts` whitelists the parent directory so the dev server
|
||||
// serves the JSON.
|
||||
const bundleModules = import.meta.glob<Record<string, string>>(
|
||||
"../../../i18n/locales/*/common.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = /locales\/([^/]+)\/common\.json$/.exec(path);
|
||||
const match = path.match(/locales\/([^/]+)\/common\.json$/);
|
||||
if (match) {
|
||||
const entries = bundleModules[path];
|
||||
const messages: Record<string, string> = {};
|
||||
for (const key in entries) {
|
||||
messages[key] = entries[key].message;
|
||||
}
|
||||
resources[match[1]] = { common: messages };
|
||||
resources[match[1]] = { common: bundleModules[path] };
|
||||
}
|
||||
}
|
||||
|
||||
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||
// and returns the first shipped bundle that matches. We try an exact
|
||||
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
|
||||
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
|
||||
// null when nothing matches, so the caller can fall back to English.
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
@@ -40,7 +48,13 @@ function detectBrowserLanguage(available: string[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An empty persisted language code is the Go-side signal for first run.
|
||||
// initI18n is awaited from app.tsx before the first render. The Go-side
|
||||
// preferences.Store returns an empty language code when no preference has
|
||||
// ever been persisted — that's the signal for first-run browser-locale
|
||||
// detection. We pick a shipped bundle that matches navigator.language /
|
||||
// navigator.languages (falling back to "en" when nothing matches) and
|
||||
// fire-and-forget the persist via Preferences.SetLanguage so subsequent
|
||||
// launches read the value back without re-detecting.
|
||||
export async function initI18n(): Promise<void> {
|
||||
const available = Object.keys(resources);
|
||||
let language = "en";
|
||||
@@ -54,10 +68,13 @@ export async function initI18n(): Promise<void> {
|
||||
language = detectBrowserLanguage(available) ?? "en";
|
||||
}
|
||||
} catch {
|
||||
// Daemon / preferences store unreachable — fall through with "en".
|
||||
}
|
||||
|
||||
if (firstRun) {
|
||||
Preferences.SetLanguage(language as LanguageCode).catch(() => {});
|
||||
// Fire-and-forget: the chosen language already drives this session;
|
||||
// persisting just locks it in so the next launch skips detection.
|
||||
void Preferences.SetLanguage(language as LanguageCode).catch(() => {});
|
||||
}
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
@@ -74,12 +91,14 @@ export async function initI18n(): Promise<void> {
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
// The event name + payload type come from Wails' generated module
|
||||
// augmentation (bindings/.../wails/v3/internal/eventdata.d.ts) which
|
||||
// extends @wailsio/runtime's CustomEvents interface, so e.data is
|
||||
// typed as UIPreferences without any hand-written cast.
|
||||
Events.On("netbird:preferences:changed", (e) => {
|
||||
const next = e.data?.language;
|
||||
if (next && next !== i18next.language) {
|
||||
i18next.changeLanguage(next).catch((err: unknown) => {
|
||||
console.error("changeLanguage failed", err);
|
||||
});
|
||||
void i18next.changeLanguage(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { UILog } from "@bindings/services";
|
||||
|
||||
type Level = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
const METHOD_LEVELS: Record<string, Level> = {
|
||||
trace: "trace",
|
||||
debug: "debug",
|
||||
log: "info",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const IGNORED_SOURCES = new Set(["welcome.ts"]);
|
||||
|
||||
const RATE_LIMIT = 50;
|
||||
const RATE_WINDOW_MS = 1000;
|
||||
|
||||
let installed = false;
|
||||
let inForward = false;
|
||||
let windowStart = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
function format(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function callerSource(): string {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return "";
|
||||
for (const line of stack.split("\n").slice(1)) {
|
||||
if (line.includes("/logs.ts")) continue;
|
||||
const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line);
|
||||
if (m) return `${m[1]}:${m[2]}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function forward(level: Level, args: unknown[]) {
|
||||
if (inForward) return;
|
||||
inForward = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (now - windowStart >= RATE_WINDOW_MS) {
|
||||
windowStart = now;
|
||||
windowCount = 0;
|
||||
}
|
||||
if (++windowCount > RATE_LIMIT) return;
|
||||
|
||||
const source = callerSource();
|
||||
if (IGNORED_SOURCES.has(source.split(":")[0])) return;
|
||||
// Don't touch console here — it would recurse back into forward().
|
||||
UILog.Log(level, source, format(args)).catch(() => {});
|
||||
} catch {
|
||||
} finally {
|
||||
inForward = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initLogForwarding() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const c = console as unknown as Record<string, (...a: unknown[]) => void>;
|
||||
for (const [method, level] of Object.entries(METHOD_LEVELS)) {
|
||||
const original = c[method]?.bind(console);
|
||||
c[method] = (...args: unknown[]) => {
|
||||
original?.(...args);
|
||||
forward(level, args);
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.addEventListener("error", (e) => {
|
||||
forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
|
||||
});
|
||||
globalThis.addEventListener("unhandledrejection", (e) => {
|
||||
forward("error", ["unhandled promise rejection:", e.reason]);
|
||||
});
|
||||
}
|
||||
416
client/ui/frontend/src/lib/mock.ts
Normal file
416
client/ui/frontend/src/lib/mock.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Network, PeerStatus } from "@bindings/services/models.js";
|
||||
|
||||
// Flip to true to override the live daemon data in the Peers / Resources /
|
||||
// Exit Nodes tabs with the hand-crafted fixtures below. The fixtures are
|
||||
// designed to surface overflow / truncation bugs (very long FQDNs, ICE
|
||||
// endpoints, domain lists, network ids) and exercise the three connection
|
||||
// states + relayed / P2P / Rosenpass variants.
|
||||
export const MOCK_ENABLED = false;
|
||||
|
||||
// Replace `real` with `mock` when MOCK_ENABLED. Pulled out so call sites read
|
||||
// as "use the live X, or the mock if enabled" without per-site if/else noise.
|
||||
export const mockOr = <T>(real: T, mock: T): T => (MOCK_ENABLED ? mock : real);
|
||||
|
||||
const SECONDS = (s: number) => Math.floor(Date.now() / 1000) - s;
|
||||
const MINUTES = (m: number) => SECONDS(m * 60);
|
||||
const HOURS = (h: number) => MINUTES(h * 60);
|
||||
const DAYS = (d: number) => HOURS(d * 24);
|
||||
|
||||
export const mockPeers: PeerStatus[] = [
|
||||
// Kitchen-sink peer — every optional field populated at the same time so
|
||||
// the detail panel renders every Row (latency + bytes + handshake + ICE
|
||||
// local/remote + relay + networks + rosenpass + pubkey). Useful for
|
||||
// eyeballing layout at maximum density.
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.1",
|
||||
ipv6: "fd00:dead:beef::1",
|
||||
pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: SECONDS(4),
|
||||
relayed: true,
|
||||
localIceCandidateType: "prflx",
|
||||
remoteIceCandidateType: "relay",
|
||||
localIceCandidateEndpoint: "[2001:db8:abcd:0012:0000:0000:0000:0001]:51820",
|
||||
remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
|
||||
fqdn: "everything-maxed-out-kitchen-sink-peer-with-long-name.subdomain.dev.example-company.netbird.cloud",
|
||||
bytesRx: 87_654_321_098,
|
||||
bytesTx: 43_210_987_654,
|
||||
latencyMs: 213,
|
||||
relayAddress:
|
||||
"rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment/with/many/parts",
|
||||
lastHandshakeUnix: SECONDS(3),
|
||||
rosenpassEnabled: true,
|
||||
networks: [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.100.0.0/16",
|
||||
"10.50.50.0/24",
|
||||
"2001:db8::/32",
|
||||
"203.0.113.0/24",
|
||||
"198.51.100.0/24",
|
||||
],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.2",
|
||||
pubKey: "MockKeyAlpha000000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: MINUTES(7),
|
||||
relayed: false,
|
||||
localIceCandidateType: "host",
|
||||
remoteIceCandidateType: "srflx",
|
||||
localIceCandidateEndpoint: "192.168.1.10:51820",
|
||||
remoteIceCandidateEndpoint: "203.0.113.42:51820",
|
||||
fqdn: "alpha.netbird.cloud",
|
||||
bytesRx: 12_345_678,
|
||||
bytesTx: 9_876_543,
|
||||
latencyMs: 18,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(12),
|
||||
rosenpassEnabled: false,
|
||||
networks: ["10.0.0.0/24"],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.3",
|
||||
pubKey: "MockKeyLongFqdn000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: HOURS(2),
|
||||
relayed: false,
|
||||
localIceCandidateType: "srflx",
|
||||
remoteIceCandidateType: "srflx",
|
||||
localIceCandidateEndpoint: "198.51.100.7:41234",
|
||||
remoteIceCandidateEndpoint: "203.0.113.99:51820",
|
||||
fqdn: "very-long-hostname-with-many-segments-to-test-overflow-handling.subdomain.example-company.netbird.cloud",
|
||||
bytesRx: 4_500_000_000,
|
||||
bytesTx: 1_200_000_000,
|
||||
latencyMs: 87,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(45),
|
||||
rosenpassEnabled: false,
|
||||
networks: [],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.4",
|
||||
pubKey: "MockKeyConnecting00000000000000000000000000=",
|
||||
connStatus: "Connecting",
|
||||
connStatusUpdateUnix: SECONDS(3),
|
||||
relayed: false,
|
||||
localIceCandidateType: "",
|
||||
remoteIceCandidateType: "",
|
||||
localIceCandidateEndpoint: "",
|
||||
remoteIceCandidateEndpoint: "",
|
||||
fqdn: "edge-server.netbird.cloud",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
latencyMs: 0,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: 0,
|
||||
rosenpassEnabled: false,
|
||||
networks: [],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.5",
|
||||
pubKey: "MockKeyOfflineOld0000000000000000000000000=",
|
||||
connStatus: "Idle",
|
||||
connStatusUpdateUnix: DAYS(4),
|
||||
relayed: false,
|
||||
localIceCandidateType: "",
|
||||
remoteIceCandidateType: "",
|
||||
localIceCandidateEndpoint: "",
|
||||
remoteIceCandidateEndpoint: "",
|
||||
fqdn: "old-peer-offline.netbird.cloud",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
latencyMs: 0,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: DAYS(4),
|
||||
rosenpassEnabled: false,
|
||||
networks: [],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.6",
|
||||
pubKey: "MockKeyRelayed00000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: MINUTES(30),
|
||||
relayed: true,
|
||||
localIceCandidateType: "relay",
|
||||
remoteIceCandidateType: "relay",
|
||||
localIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
|
||||
remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
|
||||
fqdn: "relayed-host-behind-strict-nat.corp.example.com",
|
||||
bytesRx: 250_000,
|
||||
bytesTx: 180_000,
|
||||
latencyMs: 142,
|
||||
relayAddress: "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment",
|
||||
lastHandshakeUnix: SECONDS(8),
|
||||
rosenpassEnabled: false,
|
||||
networks: ["10.10.0.0/16", "192.168.50.0/24"],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.7",
|
||||
pubKey: "MockKeyIPv6000000000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: HOURS(1),
|
||||
relayed: false,
|
||||
localIceCandidateType: "host",
|
||||
remoteIceCandidateType: "host",
|
||||
localIceCandidateEndpoint: "[2001:db8:85a3:0000:0000:8a2e:0370:7334]:51820",
|
||||
remoteIceCandidateEndpoint: "[fe80::1ff:fe23:4567:890a]:51820",
|
||||
fqdn: "ipv6-only-host.netbird.cloud",
|
||||
bytesRx: 999_999,
|
||||
bytesTx: 1_500_000,
|
||||
latencyMs: 64,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(22),
|
||||
rosenpassEnabled: false,
|
||||
networks: [],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.8",
|
||||
pubKey: "MockKeyRosenpass0000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: MINUTES(15),
|
||||
relayed: false,
|
||||
localIceCandidateType: "prflx",
|
||||
remoteIceCandidateType: "prflx",
|
||||
localIceCandidateEndpoint: "10.0.0.50:51820",
|
||||
remoteIceCandidateEndpoint: "203.0.113.200:51820",
|
||||
fqdn: "rosenpass-secure.netbird.cloud",
|
||||
bytesRx: 50_000,
|
||||
bytesTx: 50_000,
|
||||
latencyMs: 24,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(5),
|
||||
rosenpassEnabled: true,
|
||||
networks: [],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.9",
|
||||
pubKey: "MockKeyMultiNet00000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: HOURS(5),
|
||||
relayed: false,
|
||||
localIceCandidateType: "host",
|
||||
remoteIceCandidateType: "srflx",
|
||||
localIceCandidateEndpoint: "10.0.0.51:51820",
|
||||
remoteIceCandidateEndpoint: "203.0.113.201:51820",
|
||||
fqdn: "multi-network-router.netbird.cloud",
|
||||
bytesRx: 12_000_000_000,
|
||||
bytesTx: 8_000_000_000,
|
||||
latencyMs: 31,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(2),
|
||||
rosenpassEnabled: false,
|
||||
networks: [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.100.0.0/16",
|
||||
"10.50.50.0/24",
|
||||
"2001:db8::/32",
|
||||
],
|
||||
}),
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.10",
|
||||
pubKey: "MockKeyA000000000000000000000000000000000000=",
|
||||
connStatus: "Idle",
|
||||
connStatusUpdateUnix: MINUTES(45),
|
||||
relayed: false,
|
||||
localIceCandidateType: "",
|
||||
remoteIceCandidateType: "",
|
||||
localIceCandidateEndpoint: "",
|
||||
remoteIceCandidateEndpoint: "",
|
||||
fqdn: "a.nb",
|
||||
bytesRx: 0,
|
||||
bytesTx: 0,
|
||||
latencyMs: 0,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: MINUTES(45),
|
||||
rosenpassEnabled: false,
|
||||
networks: [],
|
||||
}),
|
||||
// IPv6-NetBird-IP peer: the daemon assigns a v6 ULA inside fd00::/8 when
|
||||
// running on a v6-native overlay. Exercises the row layout when the IP
|
||||
// column is wide.
|
||||
new PeerStatus({
|
||||
ip: "fd00:1234:5678:abcd::42",
|
||||
pubKey: "MockKeyV6Native0000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: MINUTES(20),
|
||||
relayed: false,
|
||||
localIceCandidateType: "host",
|
||||
remoteIceCandidateType: "host",
|
||||
localIceCandidateEndpoint: "[2001:db8:cafe:0001::10]:51820",
|
||||
remoteIceCandidateEndpoint: "[2001:db8:cafe:0002::20]:51820",
|
||||
fqdn: "ipv6-overlay-peer.netbird.cloud",
|
||||
bytesRx: 800_000_000,
|
||||
bytesTx: 950_000_000,
|
||||
latencyMs: 41,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(7),
|
||||
rosenpassEnabled: false,
|
||||
networks: ["2001:db8:1::/48", "2001:db8:2::/48", "fc00:dead:beef::/48"],
|
||||
}),
|
||||
// Dual-stack peer with mixed IPv4 / IPv6 ICE endpoints to test rows
|
||||
// where local and remote columns differ in width.
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.11",
|
||||
pubKey: "MockKeyDualStack0000000000000000000000000000=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: MINUTES(10),
|
||||
relayed: false,
|
||||
localIceCandidateType: "host",
|
||||
remoteIceCandidateType: "srflx",
|
||||
localIceCandidateEndpoint: "10.0.0.99:51820",
|
||||
remoteIceCandidateEndpoint: "[2606:4700:4700:0000:0000:0000:0000:1111]:51820",
|
||||
fqdn: "dual-stack.netbird.cloud",
|
||||
bytesRx: 320_000_000,
|
||||
bytesTx: 410_000_000,
|
||||
latencyMs: 28,
|
||||
relayAddress: "",
|
||||
lastHandshakeUnix: SECONDS(14),
|
||||
rosenpassEnabled: false,
|
||||
networks: ["10.20.30.0/24", "2001:db8:beef::/48"],
|
||||
}),
|
||||
];
|
||||
|
||||
// Resources / routed networks. Mixes the three resource types the UI knows
|
||||
// about (host = /32 or /128, subnet, domain) plus a deliberately overlapping
|
||||
// pair to exercise the "overlapping" badge in NetworkFilters.
|
||||
export const mockNetworkRoutes: Network[] = [
|
||||
new Network({
|
||||
id: "host-jenkins",
|
||||
range: "10.0.0.1/32",
|
||||
selected: true,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "subnet-corp-lan",
|
||||
range: "192.168.1.0/24",
|
||||
selected: true,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "subnet-wide-internal",
|
||||
range: "10.0.0.0/8",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "subnet-overlap-a",
|
||||
range: "172.16.0.0/16",
|
||||
selected: true,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "subnet-overlap-b",
|
||||
range: "172.16.0.0/16",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "dns-example",
|
||||
range: "invalid Prefix",
|
||||
selected: true,
|
||||
domains: ["example.com"],
|
||||
resolvedIps: { "example.com": ["93.184.216.34"] },
|
||||
}),
|
||||
new Network({
|
||||
id: "dns-very-long-internal-domain-with-many-segments",
|
||||
range: "invalid Prefix",
|
||||
selected: false,
|
||||
domains: [
|
||||
"very-long-internal-service-name.dev.subdomain.example-company.internal",
|
||||
"another-long-domain-for-overflow-testing.example-company.internal",
|
||||
"third-long-domain-in-the-list.example-company.internal",
|
||||
],
|
||||
resolvedIps: {
|
||||
"very-long-internal-service-name.dev.subdomain.example-company.internal": [
|
||||
"10.20.30.40",
|
||||
"10.20.30.41",
|
||||
],
|
||||
},
|
||||
}),
|
||||
new Network({
|
||||
id: "ipv6-host",
|
||||
range: "2001:db8::1/128",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "ipv6-subnet-corp",
|
||||
range: "2001:db8:abcd::/48",
|
||||
selected: true,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "ipv6-subnet-large",
|
||||
range: "fc00:dead:beef::/32",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "dns-dual-stack",
|
||||
range: "invalid Prefix",
|
||||
selected: true,
|
||||
domains: ["dual-stack.internal.example.com"],
|
||||
resolvedIps: {
|
||||
"dual-stack.internal.example.com": [
|
||||
"10.20.30.40",
|
||||
"10.20.30.41",
|
||||
"2001:db8:abcd::40",
|
||||
"2001:db8:abcd::41",
|
||||
],
|
||||
},
|
||||
}),
|
||||
new Network({
|
||||
id: "dns-ipv6-only",
|
||||
range: "invalid Prefix",
|
||||
selected: false,
|
||||
domains: ["ipv6-only-service.example.com"],
|
||||
resolvedIps: {
|
||||
"ipv6-only-service.example.com": ["2606:4700:4700::1111", "2606:4700:4700::1001"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Exit nodes are radio-style (mutually exclusive in the UI). Include one
|
||||
// selected and one absurdly-long-id to test row truncation.
|
||||
export const mockExitNodes: Network[] = [
|
||||
new Network({
|
||||
id: "us-east-1",
|
||||
range: "0.0.0.0/0",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "eu-central-frankfurt-primary",
|
||||
range: "0.0.0.0/0",
|
||||
selected: true,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "very-long-exit-node-region-identifier-with-multiple-segments-and-numbers-12345-test",
|
||||
range: "0.0.0.0/0",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
new Network({
|
||||
id: "ap-southeast-2",
|
||||
range: "0.0.0.0/0",
|
||||
selected: false,
|
||||
domains: [],
|
||||
resolvedIps: {},
|
||||
}),
|
||||
];
|
||||
@@ -1,37 +1,42 @@
|
||||
import { System } from "@wailsio/runtime";
|
||||
|
||||
export type Platform = {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
};
|
||||
|
||||
let cached: Platform | null = null;
|
||||
|
||||
export async function initPlatform(): Promise<void> {
|
||||
if (cached) return;
|
||||
if (cached) return;
|
||||
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
// Sync getters read the page-injected `window._wails.environment`, which can
|
||||
// be empty if the injection hasn't landed yet — keep them only as a fallback.
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
// The async Environment() call round-trips to the Go backend and is the
|
||||
// authoritative source for OS.
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
|
||||
const os = (env?.OS ?? "").toLowerCase();
|
||||
cached = {
|
||||
isWindows: os ? os === "windows" : syncIsWindows,
|
||||
isMacOS: os ? os === "darwin" : syncIsMac,
|
||||
};
|
||||
// Prefer the async env.OS; fall back to the sync getters if it's missing.
|
||||
const os = (env?.OS ?? "").toLowerCase();
|
||||
cached = {
|
||||
isWindows: os ? os === "windows" : syncIsWindows,
|
||||
isMacOS: os ? os === "darwin" : syncIsMac,
|
||||
};
|
||||
}
|
||||
|
||||
function get(): Platform {
|
||||
if (!cached) {
|
||||
throw new Error("platform: initPlatform() must complete before sync getters are used");
|
||||
}
|
||||
return cached;
|
||||
if (!cached) {
|
||||
throw new Error("platform: initPlatform() must complete before sync getters are used");
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const isWindows = (): boolean => get().isWindows;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
|
||||
// on every status push (peers, networks, profiles). Re-sorting on each refresh would
|
||||
// make rows jump around under the user, so instead:
|
||||
// - items already on screen keep their existing order (from `prev`),
|
||||
// - items that vanished are dropped,
|
||||
// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
|
||||
// Net effect: the only visible movement is new rows landing at the bottom.
|
||||
//
|
||||
// Must stay pure and idempotent: callers write the returned `order` into a ref
|
||||
// during render (useMemo), so a rerun must reproduce the first pass — never
|
||||
// branch on run count or read external mutable state.
|
||||
export function reconcileOrder<T>(
|
||||
prev: string[],
|
||||
items: T[],
|
||||
keyOf: (item: T) => string,
|
||||
compareFresh: (a: T, b: T) => number,
|
||||
): { order: string[]; items: T[] } {
|
||||
const byKey = new Map(items.map((i) => [keyOf(i), i]));
|
||||
const kept = prev.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = items
|
||||
.filter((i) => !known.has(keyOf(i)))
|
||||
.sort(compareFresh)
|
||||
.map(keyOf);
|
||||
const order = [...kept, ...fresh];
|
||||
return { order, items: order.map((k) => byKey.get(k)!) };
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight).
|
||||
// Sustained gRPC failure during install is taken as success — the daemon
|
||||
// gets restarted by the installer mid-flight, mirroring the legacy Fyne
|
||||
// UI's branch in client/ui/update.go.
|
||||
const DAEMON_DOWN_GRACE_MS = 5000;
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
@@ -34,87 +36,79 @@ export default function UpdateInProgressDialog() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let done = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const start = Date.now();
|
||||
let firstUnreachableAt: number | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || done) return;
|
||||
const timer = setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
if (phaseRef.current.kind !== "running") return;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
setPhase({ kind: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
if (cancelled || done || phaseRef.current.kind !== "running") return;
|
||||
firstUnreachableAt = null;
|
||||
if (r.success) {
|
||||
done = true;
|
||||
UpdateSvc.Quit().catch(console.error);
|
||||
clearInterval(timer);
|
||||
UpdateSvc.Quit();
|
||||
return;
|
||||
}
|
||||
if (r.errorMsg) {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
setPhase(mapInstallError(r.errorMsg));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (cancelled || done || phaseRef.current.kind !== "running") return;
|
||||
const now = Date.now();
|
||||
if (firstUnreachableAt === null) {
|
||||
firstUnreachableAt = now;
|
||||
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
|
||||
done = true;
|
||||
UpdateSvc.Quit().catch(console.error);
|
||||
return;
|
||||
clearInterval(timer);
|
||||
UpdateSvc.Quit();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled && !done) {
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isError = phase.kind !== "running";
|
||||
const errorInfo = isError ? classifyPhase(phase, version, t) : null;
|
||||
const updatingHeading = version
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating");
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
{isError ? (
|
||||
<SquareIcon icon={XCircle} className={"bg-red-500 [&_svg]:text-white"} />
|
||||
<SquareIcon
|
||||
icon={XCircle}
|
||||
className={"bg-red-500 [&_svg]:text-white"}
|
||||
/>
|
||||
) : (
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{errorInfo ? errorInfo.title : updatingHeading}
|
||||
{isError
|
||||
? errorInfo!.title
|
||||
: version
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{errorInfo ? (
|
||||
{isError ? (
|
||||
<>
|
||||
{errorInfo.description}
|
||||
{errorInfo.message && (
|
||||
{errorInfo!.description}
|
||||
{errorInfo!.message && (
|
||||
<>
|
||||
<br />
|
||||
<span className={"first-letter:uppercase"}>
|
||||
{errorInfo.message}
|
||||
{errorInfo!.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -132,7 +126,9 @@ export default function UpdateInProgressDialog() {
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() => WindowManager.CloseInstallProgress().catch(console.error)}
|
||||
onClick={() =>
|
||||
WindowManager.CloseInstallProgress().catch(console.error)
|
||||
}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
|
||||
@@ -9,9 +9,7 @@ import { cn } from "@/lib/cn";
|
||||
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => {
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export function UpdateVersionCard() {
|
||||
@@ -64,7 +62,7 @@ export function UpdateVersionCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
|
||||
function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -77,11 +75,11 @@ function Card({ children, className }: Readonly<{ children: ReactNode; className
|
||||
);
|
||||
}
|
||||
|
||||
function Title({ children }: Readonly<{ children: ReactNode }>) {
|
||||
function Title({ children }: { children: ReactNode }) {
|
||||
return <p className={"text-sm font-semibold"}>{children}</p>;
|
||||
}
|
||||
|
||||
function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
|
||||
function Link({ url, children }: { url: string; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
|
||||
@@ -13,6 +13,16 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
|
||||
// ErrorDialog is the app's error surface — a frameless, always-on-top
|
||||
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
|
||||
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
|
||||
// native OS MessageBox. Title and message arrive as query params (see
|
||||
// services/windowmanager.go errorDialogURL); both are caller-localised. The
|
||||
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
|
||||
// it's repeated as the heading here so it stays visible on macOS, where the
|
||||
// hidden-inset title bar doesn't render the chrome title. The single Close
|
||||
// button (and the Escape key) dismisses the window via WindowManager.CloseError
|
||||
// — the Go side destroys it on close.
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
@@ -25,12 +35,15 @@ export default function ErrorDialog() {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Escape closes — keyboard-accessible cancellation, matching the native
|
||||
// dialog's behaviour. The primary button is autoFocused below so Enter
|
||||
// also dismisses.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Connection } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
@@ -11,7 +12,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_CANCEL = "browser-login:cancel";
|
||||
const WINDOW_WIDTH = 360;
|
||||
@@ -33,7 +34,12 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
[t],
|
||||
);
|
||||
|
||||
// Open the browser only after mount, or it lands on top of the still-hidden popup.
|
||||
// Open the system browser only after the dialog has mounted (which
|
||||
// means useAutoSizeWindow has called Window.Show). startLogin used to
|
||||
// fire OpenURL itself but the browser typically beat React's mount
|
||||
// and landed on top of the still-hidden NetBird popup. The ref guard
|
||||
// keeps StrictMode's intentional double-invoke in dev (and any future
|
||||
// remount) from launching two browser tabs.
|
||||
useEffect(() => {
|
||||
if (!uri || openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
@@ -46,17 +52,20 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
}, [uri, reportOpenFailure]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
|
||||
console.error("emit browser-login cancel", err),
|
||||
);
|
||||
void Events.Emit(EVENT_CANCEL);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
<SquareIcon
|
||||
icon={Loader2}
|
||||
className={"[&_svg]:animate-spin"}
|
||||
/>
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{t("browserLogin.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{t("browserLogin.notSeeing")}{" "}
|
||||
<button
|
||||
|
||||
@@ -3,29 +3,70 @@ import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { contentTop } from "@/components/empty-state/EmptyState";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
|
||||
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
|
||||
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
|
||||
// device code.
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
|
||||
// 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";
|
||||
|
||||
// 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;
|
||||
|
||||
// onSettled (re-arm guards) must fire before the error dialog, never gated on it:
|
||||
// a hanging dialog would silently drop every later login until restart.
|
||||
// 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;
|
||||
}
|
||||
@@ -76,7 +117,7 @@ async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
|
||||
if (cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
void waitPromise.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -87,6 +128,9 @@ async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
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?.();
|
||||
}
|
||||
@@ -106,6 +150,8 @@ enum ConnectionState {
|
||||
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",
|
||||
@@ -115,6 +161,8 @@ const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
|
||||
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;
|
||||
@@ -128,18 +176,37 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
|
||||
const unreachable = daemonState === "DaemonUnavailable";
|
||||
|
||||
// Tracks an in-flight user action so we can show a transitional label
|
||||
// and disable the switch without lying about the daemon's actual state.
|
||||
//
|
||||
// "connect" — user clicked Up; waiting for daemon to settle
|
||||
// "logging-in" — SSO flow is driving the daemon (Login → browser →
|
||||
// Up). Keeps the switch in "Connecting" while the
|
||||
// daemon flaps NeedsLogin → Idle → NeedsLogin →
|
||||
// Connecting that Login's internal Down causes.
|
||||
// "disconnect" — user clicked Down; waiting for daemon to settle
|
||||
type Action = "connect" | "logging-in" | "disconnect" | null;
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
// Guards startLogin from being fired twice in parallel (effect path +
|
||||
// tray trigger-login + handleSwitch). startLogin's module-level
|
||||
// loginInFlight already drops the second daemon call, but its
|
||||
// Promise would resolve immediately and the .finally clear our
|
||||
// "logging-in" latch while the first flow is still running.
|
||||
const loginGuard = useRef(false);
|
||||
const driveLogin = useCallback(() => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
// Release the React-level guard via onSettled — fired the instant the
|
||||
// flow ends, before startLogin's error dialog. Gating it on the full
|
||||
// startLogin() promise would keep loginGuard wedged for the whole
|
||||
// dialog lifetime, leaving the tray's trigger-login dropped at the
|
||||
// guard check until the client is restarted.
|
||||
void startLogin(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
|
||||
void refresh();
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
@@ -160,6 +227,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
case "LoginFailed":
|
||||
case "SessionExpired":
|
||||
case "DaemonUnavailable":
|
||||
// NeedsLogin / SessionExpired without an in-flight user
|
||||
// action read as Disconnected — the switch only flips to
|
||||
// Connecting once the user (or the tray's trigger-login)
|
||||
// kicks off the SSO flow, which sets action = "logging-in"
|
||||
// and is handled by the guard above.
|
||||
return ConnectionState.Disconnected;
|
||||
default:
|
||||
return ConnectionState.Disconnected;
|
||||
@@ -182,6 +254,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
// Don't clear action here on success — the daemon's first status
|
||||
// push (Connecting / NeedsLogin / ...) may land after Up returns,
|
||||
// and clearing eagerly would let connState fall back to
|
||||
// Disconnected for one render. The effect below clears the latch
|
||||
// once daemonState catches up.
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
@@ -197,10 +274,23 @@ export const MainConnectionStatusSwitch = () => {
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
// See connect() above — clear via the effect, not eagerly.
|
||||
};
|
||||
|
||||
// Tracks whether the daemon has entered Connecting during the
|
||||
// current "connect" action. Lets us distinguish "still waiting for
|
||||
// the daemon to start" (Idle → Idle) from "the connect flow was
|
||||
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
|
||||
// while the UI was Connecting). Reset whenever action returns to
|
||||
// null.
|
||||
const sawConnectingRef = useRef(false);
|
||||
|
||||
// Release the action latch when the daemon settles on a terminal
|
||||
// state for the user's intent — and, in the connect → NeedsLogin
|
||||
// case, hand off to driveLogin so the user doesn't have to click
|
||||
// the switch a second time. "logging-in" is cleared by driveLogin's
|
||||
// .finally, not here: Login's internal Down makes the daemon flap
|
||||
// through Idle, which would otherwise look like a terminal state.
|
||||
useEffect(() => {
|
||||
if (action === null) {
|
||||
sawConnectingRef.current = false;
|
||||
@@ -218,6 +308,10 @@ export const MainConnectionStatusSwitch = () => {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
// Cancelled externally (e.g. tray Disconnect during our
|
||||
// Connecting): the daemon went back to Idle after we'd
|
||||
// observed Connecting. Clear the latch so the UI stops
|
||||
// showing Connecting forever.
|
||||
if (sawConnectingRef.current && daemonState === "Idle") {
|
||||
setAction(null);
|
||||
}
|
||||
@@ -230,6 +324,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
}
|
||||
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
|
||||
|
||||
// The tray clicks Connect via its own gRPC call. When the daemon flips
|
||||
// to NeedsLogin afterwards, the tray emits trigger-login so the React
|
||||
// UI (which owns the SSO orchestration and the browser-login window)
|
||||
// takes over. driveLogin's loginGuard handles concurrent tray +
|
||||
// switch clicks.
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
|
||||
driveLogin();
|
||||
@@ -260,6 +359,9 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const isOn =
|
||||
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
|
||||
|
||||
// When the daemon hangs in Connecting/Disconnecting, give the user an
|
||||
// escape hatch: after the delay, the switch becomes clickable again so a
|
||||
// tap fires Connection.Down (plus cancels any in-flight SSO flow).
|
||||
const [canForceCancel, setCanForceCancel] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTransitioning) {
|
||||
@@ -272,9 +374,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
|
||||
const forceCancel = async () => {
|
||||
if (action === "logging-in") {
|
||||
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
|
||||
console.error("emit browser-login cancel failed", err),
|
||||
);
|
||||
void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL);
|
||||
}
|
||||
WindowManager.CloseBrowserLogin().catch(() => {});
|
||||
setAction("disconnect");
|
||||
@@ -297,8 +397,14 @@ export const MainConnectionStatusSwitch = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col h-full w-full items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
className={cn(
|
||||
// Anchored from the top so the FQDN/IP lines below the toggle
|
||||
// can grow into a popover-aware layout without shifting the
|
||||
// toggle itself (justify-center would slide everything up
|
||||
// when the IP line is hidden during Disconnected).
|
||||
"flex flex-col h-full w-full items-center gap-4",
|
||||
"relative top-[11.7rem]",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
@@ -345,6 +451,9 @@ export const MainConnectionStatusSwitch = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
|
||||
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
|
||||
// a popover containing both v4 and v6, each independently click-to-copy.
|
||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasV6 = !!ipv6;
|
||||
@@ -380,6 +489,10 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
<button
|
||||
type={"button"}
|
||||
className={cn(
|
||||
// relative so the chevron can be absolutely
|
||||
// positioned alongside without widening the trigger
|
||||
// — keeps the IP text centred in its parent and
|
||||
// lets the popover centre cleanly on it.
|
||||
"group relative inline-flex items-center outline-none cursor-default",
|
||||
"transition-colors",
|
||||
)}
|
||||
@@ -427,6 +540,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
);
|
||||
};
|
||||
|
||||
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
|
||||
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
|
||||
// icon on the right that flips to a checkmark briefly after a successful copy.
|
||||
const IpRow = ({ value }: { value: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleClick = async () => {
|
||||
@@ -435,7 +551,9 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch {}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -8,13 +8,15 @@ 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, toggleExitNode } = useNetworks();
|
||||
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;
|
||||
@@ -25,23 +27,19 @@ export const MainExitNodeSwitcher = () => {
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next === NONE_VALUE) {
|
||||
if (active)
|
||||
toggleExitNode(active.id, true).catch((err: unknown) =>
|
||||
console.error("toggle exit node failed", err),
|
||||
);
|
||||
if (active) void toggleExitNode(active.id, true);
|
||||
return;
|
||||
}
|
||||
if (active?.id === next) return;
|
||||
toggleExitNode(next, false).catch((err: unknown) =>
|
||||
console.error("toggle exit node failed", err),
|
||||
);
|
||||
if (active && active.id === next) return;
|
||||
void toggleExitNode(next, false);
|
||||
};
|
||||
|
||||
const title = active ? active.id : t("exitNodes.card.title");
|
||||
const activeDescription = active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
const description = hasAny ? activeDescription : t("exitNodes.empty.title");
|
||||
const description = !hasAny
|
||||
? t("exitNodes.empty.title")
|
||||
: active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -36,18 +36,22 @@ export const MainHeader = () => {
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
WindowManager.OpenSettings("").catch(() => {});
|
||||
void WindowManager.OpenSettings("").catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Mirror the tray's Settings accelerator so the keystroke works while
|
||||
// the main window has focus too. The tray's SetAccelerator paints the
|
||||
// glyph on macOS/Linux but only fires the menu item — it can't reach the
|
||||
// webview's input loop, hence the parallel React-side listener.
|
||||
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
|
||||
|
||||
const openAbout = () => {
|
||||
setMenuOpen(false);
|
||||
WindowManager.OpenSettings("about").catch(() => {});
|
||||
void WindowManager.OpenSettings("about").catch(() => {});
|
||||
};
|
||||
|
||||
const openManageProfiles = () => {
|
||||
WindowManager.OpenSettings("profiles").catch(() => {});
|
||||
void WindowManager.OpenSettings("profiles").catch(() => {});
|
||||
};
|
||||
|
||||
const selectMode = (mode: ViewMode) => {
|
||||
@@ -126,6 +130,16 @@ export const MainHeader = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// The inner grid is locked to 356px (the default-mode content width:
|
||||
// 380px window − 12px px-3 each side). It stays left-anchored regardless
|
||||
// of window size, so the profile keeps the exact same absolute X
|
||||
// position when the user flips to advanced view. The settings button is
|
||||
// pulled out as an absolute, right-anchored element so it tracks the
|
||||
// window's right edge in both modes.
|
||||
// Header height matches the Settings window's top traffic-light strip
|
||||
// so the right panel ends up the same height in both windows. The h-10
|
||||
// of the inner buttons (profile trigger, more-vertical) defines the
|
||||
// natural height; the strip in SettingsLayout is sized to mirror it.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -133,7 +147,8 @@ export const MainHeader = () => {
|
||||
"flex items-center h-12 top-3",
|
||||
)}
|
||||
>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
{/* 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(
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
import {isWindows} from "@/lib/platform.ts";
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
@@ -34,14 +34,10 @@ const MainBody = () => {
|
||||
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0"}>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center shrink-0 ",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("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 />
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const filters: { value: NetworkFilter; label: string }[] = [
|
||||
{ value: "all", label: t("networks.filter.all") },
|
||||
@@ -34,7 +34,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
@@ -62,10 +62,21 @@ export const NetworkFilters = ({ 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>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
@@ -13,26 +12,37 @@ 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 { mockNetworkRoutes, mockOr } from "@/lib/mock";
|
||||
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||
|
||||
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
|
||||
// The daemon stringifies route.Network via netip.Prefix.String(). For
|
||||
// DNS-based routes the prefix is the zero value, which Go renders as
|
||||
// "invalid Prefix". Those rows render their domain + resolved IPs instead.
|
||||
const INVALID_PREFIX = "invalid Prefix";
|
||||
|
||||
const isDnsRoute = (n: Network): boolean =>
|
||||
n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
|
||||
|
||||
// Mirror management's NetworkResourceType (resource.go GetResourceType):
|
||||
// a CIDR is a host when its prefix length equals the address width
|
||||
// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with
|
||||
// domains attached are domain resources.
|
||||
type ResourceType = "host" | "subnet" | "domain";
|
||||
|
||||
const isHostCidr = (cidr: string): boolean => {
|
||||
const [addr, bitsStr] = cidr.split("/");
|
||||
if (!addr || !bitsStr) return false;
|
||||
const bits = Number(bitsStr);
|
||||
// IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'.
|
||||
const isV6 = addr.includes(":");
|
||||
return isV6 ? bits === 128 : bits === 32;
|
||||
};
|
||||
|
||||
const resourceTypeOf = (n: Network): ResourceType => {
|
||||
if (isDnsRoute(n)) return "domain";
|
||||
// n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs
|
||||
// come comma-joined, but those are filtered out upstream — guard
|
||||
// defensively by inspecting only the first segment.
|
||||
const primary = n.range.split(",")[0].trim();
|
||||
return isHostCidr(primary) ? "host" : "subnet";
|
||||
};
|
||||
@@ -43,6 +53,9 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
|
||||
return NetworkIcon;
|
||||
};
|
||||
|
||||
// Map every range string -> ids of CIDR routes that share it. Domain routes
|
||||
// are skipped (they overlap on domain, not prefix). Single-entry buckets
|
||||
// aren't overlaps.
|
||||
const buildOverlapMap = (
|
||||
routes: { id: string; range: string; domains: string[] }[],
|
||||
): Map<string, string[]> => {
|
||||
@@ -64,7 +77,8 @@ export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -92,19 +106,26 @@ export const Networks = () => {
|
||||
[networkRoutes, overlapById],
|
||||
);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it, and newly discovered routes append at
|
||||
// the end (sorted active-first / by-id among themselves). The ref carries
|
||||
// the previous order across renders so the reconciliation is synchronous
|
||||
// with networkRoutes updates (no useEffect lag → no visual hop).
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
networkRoutes,
|
||||
(r) => r.id,
|
||||
(a, b) => {
|
||||
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = networkRoutes
|
||||
.filter((r) => !known.has(r.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
})
|
||||
.map((r) => r.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [networkRoutes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -137,15 +158,13 @@ export const Networks = () => {
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
if (allSelected) {
|
||||
setNetworksSelected(
|
||||
void setNetworksSelected(
|
||||
filtered.map((r) => r.id),
|
||||
false,
|
||||
).catch((err: unknown) => console.error("disable all networks failed", err));
|
||||
);
|
||||
} else {
|
||||
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
|
||||
setNetworksSelected(ids, true).catch((err: unknown) =>
|
||||
console.error("enable all networks failed", err),
|
||||
);
|
||||
void setNetworksSelected(ids, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,26 +247,17 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
{data.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={cn(
|
||||
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={n.id}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={"absolute inset-0 cursor-pointer"}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
<div
|
||||
className={
|
||||
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id} className={"pointer-events-auto"}>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
className={
|
||||
@@ -258,7 +268,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
</div>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
<div className={"shrink-0 self-center relative"}>
|
||||
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
|
||||
<NetworkToggle
|
||||
checked={n.selected}
|
||||
onChange={() => onToggle(n.id, n.selected)}
|
||||
@@ -378,28 +388,25 @@ type ToggleProps = {
|
||||
mixed?: boolean;
|
||||
};
|
||||
|
||||
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
|
||||
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
role={"switch"}
|
||||
aria-checked={mixed ? "mixed" : checked}
|
||||
aria-label={label}
|
||||
onClick={onChange}
|
||||
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
<button
|
||||
type={"button"}
|
||||
role={"switch"}
|
||||
aria-checked={mixed ? "mixed" : checked}
|
||||
aria-label={label}
|
||||
onClick={onChange}
|
||||
className={cn(
|
||||
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
|
||||
"transition-colors cursor-pointer wails-no-draggable",
|
||||
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
|
||||
mixed && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
|
||||
"transition-colors cursor-pointer wails-no-draggable",
|
||||
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
|
||||
mixed && "opacity-60",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
mixed ? "translate-x-2.5" : checkedTranslate,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { mockOr, mockPeers } from "@/lib/mock";
|
||||
import { peerStatusLabelKey } from "./Peers";
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
@@ -59,9 +60,12 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const { selected, setSelected } = usePeerDetail();
|
||||
const { status, refresh } = useStatus();
|
||||
|
||||
// Keep `selected` in sync with the live peer list so the panel reflects
|
||||
// status / latency / byte updates without re-opening. If the peer
|
||||
// disappears, close the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const peers = status?.peers ?? [];
|
||||
const peers = mockOr(status?.peers ?? [], mockPeers);
|
||||
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
|
||||
if (!fresh) {
|
||||
setSelected(null);
|
||||
@@ -70,8 +74,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
if (fresh !== selected) setSelected(fresh);
|
||||
}, [status, selected, setSelected]);
|
||||
|
||||
// Daemon updates latency/bytes/handshake without pushing a fresh status
|
||||
// snapshot, so tick locally to keep relative timestamps live.
|
||||
// Re-render every second so the relative timestamps in PeerDetails
|
||||
// ("Xs ago", "Xm ago") tick. The daemon updates latency/bytes/handshake
|
||||
// silently without pushing a fresh status snapshot — see
|
||||
// status.go UpdateLatency / UpdateWireGuardPeerState — so without this
|
||||
// the displayed age would freeze for a stably-Connected peer.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
@@ -83,6 +90,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
// Refresh over the unix socket usually completes in <50ms, faster
|
||||
// than the spin animation can show. Hold the spinning state for at
|
||||
// least one full rotation so the click feels responsive.
|
||||
const MIN_SPIN_MS = 600;
|
||||
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
|
||||
try {
|
||||
@@ -92,13 +102,14 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
}
|
||||
}, [refresh, refreshing]);
|
||||
|
||||
// Esc closes the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelected(null);
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
|
||||
return (
|
||||
@@ -360,6 +371,10 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Single "View {n}" badge with a chevron that opens a click popover listing
|
||||
// each routed resource on its own line with a click-to-copy entry. Avoids
|
||||
// the repetitive "first item + N more" pattern given the row already has a
|
||||
// "Resources" label and Layers icon.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => (
|
||||
<ResourcesPopover networks={networks} />
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const filters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: t("peers.filter.all") },
|
||||
@@ -34,7 +34,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
@@ -14,6 +13,7 @@ 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";
|
||||
|
||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
@@ -29,6 +29,8 @@ const dotClass = (connStatus: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// The daemon reports "Idle" for not-connected peers; surface it as
|
||||
// "Disconnected" in the UI. Connected / Connecting pass through.
|
||||
export const peerStatusLabelKey = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
@@ -47,12 +49,15 @@ export const Peers = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Peers is only mounted in advanced view (see pages/Main.tsx), so a
|
||||
// mount-time focus is equivalent to "focus when the user toggles into
|
||||
// advanced view".
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const isConnected = status?.status === "Connected";
|
||||
const peers = status?.peers ?? [];
|
||||
const peers = mockOr(status?.peers ?? [], mockPeers);
|
||||
|
||||
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||
const online = peers.filter((p) => isOnline(p.connStatus)).length;
|
||||
@@ -63,21 +68,34 @@ export const Peers = () => {
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Stay in live-sort until every peer is stable. Right after Up the daemon
|
||||
// emits all peers as "Connecting"; committing then would lock that
|
||||
// alphabetical-only order forever.
|
||||
// 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 compare = (a: PeerStatus, b: PeerStatus) => {
|
||||
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);
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -85,7 +103,7 @@ export const Peers = () => {
|
||||
}
|
||||
|
||||
if (!stickyRef.current) {
|
||||
const sorted = [...peers].sort(compare);
|
||||
const sorted = sortOnlineFirst(peers);
|
||||
if (peers.every((p) => p.connStatus !== "Connecting")) {
|
||||
orderRef.current = sorted.map((p) => p.pubKey);
|
||||
stickyRef.current = true;
|
||||
@@ -93,9 +111,15 @@ export const Peers = () => {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
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)!);
|
||||
}, [peers]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -103,7 +127,10 @@ export const Peers = () => {
|
||||
return ordered.filter((p) => {
|
||||
if (statusFilter === "online" && !isOnline(p.connStatus)) return false;
|
||||
if (statusFilter === "offline" && isOnline(p.connStatus)) return false;
|
||||
return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q);
|
||||
if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [ordered, search, statusFilter]);
|
||||
|
||||
@@ -163,36 +190,24 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
return (
|
||||
<li
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={shortenDns(peer.fqdn)}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={"absolute inset-0 cursor-default"}
|
||||
/>
|
||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
dotClass(peer.connStatus),
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={
|
||||
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard
|
||||
message={peer.fqdn}
|
||||
className={"pointer-events-auto"}
|
||||
>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<TruncatedText
|
||||
text={shortenDns(peer.fqdn)}
|
||||
className={
|
||||
@@ -202,10 +217,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard
|
||||
message={peer.ip}
|
||||
className={"pointer-events-auto"}
|
||||
>
|
||||
<CopyToClipboard message={peer.ip}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{peer.ip}
|
||||
</span>
|
||||
@@ -215,7 +227,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
{isConnected && peer.latencyMs > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 self-center text-xs tabular-nums relative pointer-events-none",
|
||||
"shrink-0 self-center text-xs tabular-nums",
|
||||
latencyColor(peer.latencyMs),
|
||||
)}
|
||||
>
|
||||
@@ -225,7 +237,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
|
||||
"shrink-0 self-center text-nb-gray-300",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage").
|
||||
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
|
||||
// just like "test" does. The list is scanned in order, so more-specific
|
||||
// tokens (e.g. "staging" before "stage") should come first when they share
|
||||
// roots.
|
||||
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
|
||||
[/(default|personal)/i, UserCircle],
|
||||
[/(work|business|office|company|corp|corporate)/i, Briefcase],
|
||||
|
||||
@@ -18,11 +18,19 @@ import {
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// onCreate receives the sanitized profile name and the management URL the
|
||||
// user picked (the cloud default for Cloud mode, the normalized self-
|
||||
// hosted URL otherwise).
|
||||
onCreate: (name: string, managementUrl: string) => void;
|
||||
};
|
||||
|
||||
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
|
||||
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
|
||||
// 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()
|
||||
@@ -38,6 +46,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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);
|
||||
@@ -54,6 +65,8 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset the URL warnings whenever the user edits the URL or flips mode —
|
||||
// otherwise a stale warning lingers next to a just-corrected value.
|
||||
useEffect(() => {
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
@@ -87,6 +100,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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;
|
||||
@@ -101,14 +117,16 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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);
|
||||
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;
|
||||
|
||||
@@ -160,9 +178,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
<Input
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
placeholder={t(
|
||||
"settings.general.management.urlPlaceholder",
|
||||
)}
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={urlInputError}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
@@ -9,7 +10,7 @@ import type { Profile } from "@bindings/services/models.js";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -58,79 +59,79 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const displayName = activeProfile || t("profile.selector.loading");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ProfileTriggerButton name={displayName} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 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]:origin-top data-[side=top]:origin-bottom",
|
||||
"data-[side=left]:origin-right data-[side=right]:origin-left",
|
||||
"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()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</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>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
<>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ProfileTriggerButton name={displayName} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 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()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</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>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -185,7 +186,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
>
|
||||
<div className="flex flex-col min-w-0 flex-1 leading-tight">
|
||||
<span className="truncate">{profile.name}</span>
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
@@ -16,8 +17,7 @@ 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 { reconcileOrder } from "@/lib/sorting";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
@@ -38,22 +38,38 @@ export function ProfilesTab() {
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Order is held stable so switching only flips the badge, never reorders rows
|
||||
// (else the clicked row jumps to the top under the cursor).
|
||||
// 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 { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
profiles,
|
||||
(p) => p.name,
|
||||
(a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
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>) => {
|
||||
@@ -104,17 +120,27 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
try {
|
||||
await addProfile(name);
|
||||
// SetConfig is keyed by profile name, so it writes the not-yet-active
|
||||
// profile. Write before switching so any reconnect targets the right deployment.
|
||||
// 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({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,11 +193,7 @@ export function ProfilesTab() {
|
||||
</SettingsBottomBar>
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,16 +230,12 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
|
||||
<div className={"flex items-center gap-2 min-w-0"}>
|
||||
<span
|
||||
className={
|
||||
"truncate font-medium text-nb-gray-100 select-text cursor-text"
|
||||
}
|
||||
>
|
||||
<span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}>
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
|
||||
</div>
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -247,10 +265,7 @@ const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
}, [email]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}
|
||||
>
|
||||
<span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
@@ -279,10 +294,11 @@ const RowActions = ({
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
const nonDefaultDeleteLabel = isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
|
||||
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
|
||||
@@ -313,8 +329,10 @@ type ActionIconButtonProps = {
|
||||
icon: typeof CircleMinus;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
/** Occupies space but invisible and non-interactive (preserves row layout). */
|
||||
/** 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;
|
||||
};
|
||||
|
||||
@@ -341,8 +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",
|
||||
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
@@ -351,7 +368,9 @@ const ActionIconButton = ({
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip
|
||||
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
|
||||
content={
|
||||
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
|
||||
}
|
||||
side={"top"}
|
||||
>
|
||||
{button}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { AlertCircleIcon, ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
@@ -11,13 +12,30 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
// Below this, the situation is genuinely "soon" and the title/description
|
||||
// uses the urgent wording. Above it (e.g. opened with hours remaining), the
|
||||
// "later" variant drops the urgency cue so it doesn't read absurdly.
|
||||
const SOON_THRESHOLD_SECONDS = 60 * 60;
|
||||
|
||||
// Renders the countdown with only the units that matter: mm:ss under an
|
||||
// hour, hh:mm:ss under a day, dd:hh:mm:ss otherwise. Two-digit zero pad
|
||||
// throughout so columns don't jump as digits roll over.
|
||||
function formatRemaining(seconds: number): string {
|
||||
const s = Math.max(0, seconds | 0);
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const secs = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
return `${pad(minutes)}:${pad(secs)}`;
|
||||
}
|
||||
|
||||
export default function SessionExpirationDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
@@ -31,31 +49,27 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
const [remaining, setRemaining] = useState(initialSeconds);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(busy);
|
||||
busyRef.current = busy;
|
||||
const expired = remaining <= 0;
|
||||
const soon = remaining <= SOON_THRESHOLD_SECONDS;
|
||||
const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater");
|
||||
const activeDescription = soon
|
||||
? t("sessionExpiration.description")
|
||||
: t("sessionExpiration.descriptionLater");
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(initialSeconds);
|
||||
}, [initialSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = globalThis.setInterval(() => {
|
||||
if (remaining <= 0) return;
|
||||
const id = window.setInterval(() => {
|
||||
setRemaining((s) => (s <= 1 ? 0 : s - 1));
|
||||
}, 1000);
|
||||
return () => globalThis.clearInterval(id);
|
||||
}, [initialSeconds]);
|
||||
return () => window.clearInterval(id);
|
||||
}, [remaining]);
|
||||
|
||||
// Suppressed while `busy`: the tunnel stays up so Connected re-fires for
|
||||
// unrelated reasons (peer/route changes), and closing would abort our own WaitExtend.
|
||||
// Auto-close when the daemon flips back to Connected — covers extend
|
||||
// flows started from outside this window (tray notification action,
|
||||
// another UI surface) so the user isn't left staring at a stale dialog.
|
||||
useEffect(() => {
|
||||
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
|
||||
if (!busyRef.current && ev?.data?.status === "Connected") {
|
||||
if (ev?.data?.status === "Connected") {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
}
|
||||
});
|
||||
@@ -64,6 +78,11 @@ export default function SessionExpirationDialog() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
|
||||
// opens the browser for the user to sign in, blocks on the daemon until
|
||||
// the new deadline arrives. Tunnel stays up; success simply closes the
|
||||
// dialog, failure surfaces a native error dialog and leaves this one
|
||||
// open so the user can retry or logout.
|
||||
const stay = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -82,8 +101,13 @@ export default function SessionExpirationDialog() {
|
||||
userCode: start.userCode,
|
||||
});
|
||||
if (result.preempted) {
|
||||
// Another surface took over this deadline's flow; keep the dialog
|
||||
// open to retry. A successful extend elsewhere auto-closes this window.
|
||||
// Another UI surface (e.g. the tray "Extend now"
|
||||
// notification action) started a flow for the same
|
||||
// deadline and took over. Keep the dialog open so the
|
||||
// user can re-trigger if the other flow also fails;
|
||||
// a successful extend elsewhere refreshes the deadline
|
||||
// and this window auto-closes when it's no longer
|
||||
// relevant.
|
||||
return;
|
||||
}
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
@@ -128,10 +152,18 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading>
|
||||
{expired ? t("sessionExpiration.expired") : activeTitle}
|
||||
{expired
|
||||
? t("sessionExpiration.expired")
|
||||
: soon
|
||||
? t("sessionExpiration.title")
|
||||
: t("sessionExpiration.titleLater")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{expired ? t("sessionExpiration.expiredDescription") : activeDescription}
|
||||
{expired
|
||||
? t("sessionExpiration.expiredDescription")
|
||||
: soon
|
||||
? t("sessionExpiration.description")
|
||||
: t("sessionExpiration.descriptionLater")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +187,9 @@ export default function SessionExpirationDialog() {
|
||||
onClick={stay}
|
||||
disabled={busy}
|
||||
>
|
||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||
{expired
|
||||
? t("sessionExpiration.authenticate")
|
||||
: t("sessionExpiration.stay")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
|
||||
import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/>
|
||||
</svg>
|
||||
);
|
||||
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/>
|
||||
</svg>
|
||||
);
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => {
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export function SettingsAbout() {
|
||||
@@ -34,23 +20,16 @@ export function SettingsAbout() {
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
const COMMUNITY_LINKS: {
|
||||
label: string;
|
||||
url: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
iconClassName?: string;
|
||||
}[] = [
|
||||
const COMMUNITY_LINKS: { label: string; url: string; Icon: LucideIcon }[] = [
|
||||
{
|
||||
label: t("settings.about.community.github"),
|
||||
url: "https://github.com/netbirdio/netbird",
|
||||
Icon: GithubIcon,
|
||||
iconClassName: "h-3 w-3",
|
||||
Icon: Github,
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.slack"),
|
||||
url: "https://docs.netbird.io/slack-url",
|
||||
Icon: SlackIcon,
|
||||
iconClassName: "h-3 w-3",
|
||||
Icon: Slack,
|
||||
},
|
||||
{
|
||||
label: t("settings.about.community.forum"),
|
||||
@@ -84,8 +63,7 @@ export function SettingsAbout() {
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
<p
|
||||
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
@@ -99,7 +77,7 @@ export function SettingsAbout() {
|
||||
) : (
|
||||
t("settings.about.client", { version: daemonVersion })
|
||||
)}
|
||||
</button>
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
@@ -122,7 +100,7 @@ export function SettingsAbout() {
|
||||
<div
|
||||
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||
>
|
||||
{COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => (
|
||||
{COMMUNITY_LINKS.map(({ label, url, Icon }) => (
|
||||
<button
|
||||
key={url}
|
||||
type={"button"}
|
||||
@@ -131,7 +109,7 @@ export function SettingsAbout() {
|
||||
"inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
|
||||
}
|
||||
>
|
||||
<Icon className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
<Icon className={"h-3.5 w-3.5"} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -35,7 +35,7 @@ function triggerAccent() {
|
||||
root.render(<Accent onDone={cleanup} />);
|
||||
}
|
||||
|
||||
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
function Accent({ onDone }: { onDone: () => void }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -94,14 +94,14 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
};
|
||||
raf = requestAnimationFrame(draw);
|
||||
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
globalThis.setTimeout(onDone, 500);
|
||||
window.setTimeout(onDone, 500);
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
globalThis.clearTimeout(timeout);
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [onDone]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { System } from "@wailsio/runtime";
|
||||
import Button from "@/components/buttons/Button";
|
||||
@@ -8,16 +8,23 @@ import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
// macOS daemon/CLI only accept utun<N> (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars.
|
||||
// macOS: the Darwin utun control socket parses the digits after "utun" as the
|
||||
// unit number, so the daemon (and the CLI's parseInterfaceName in
|
||||
// client/cmd/up.go) only accepts utun<N>.
|
||||
// Linux/Windows: no daemon-side validation; the Linux kernel caps names at
|
||||
// IFNAMSIZ-1 = 15 chars and the safe charset across both is [A-Za-z0-9._-].
|
||||
const IS_MAC = System.IsMac();
|
||||
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";
|
||||
// Port 0 lets the daemon pick a random free port.
|
||||
// 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.
|
||||
// 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
|
||||
// IPv6 the daemon still needs 1280 on the path (RFC 8200), but that is not
|
||||
// the validator's job to enforce.
|
||||
const MTU_MIN = 576;
|
||||
const MTU_MAX = 8192;
|
||||
|
||||
@@ -33,15 +40,6 @@ export function SettingsAdvanced() {
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValues({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
|
||||
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
|
||||
@@ -57,7 +55,11 @@ export function SettingsAdvanced() {
|
||||
max: PORT_MAX,
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) {
|
||||
if (
|
||||
!Number.isInteger(values.mtu) ||
|
||||
values.mtu < MTU_MIN ||
|
||||
values.mtu > MTU_MAX
|
||||
) {
|
||||
out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX });
|
||||
}
|
||||
return out;
|
||||
@@ -87,7 +89,9 @@ export function SettingsAdvanced() {
|
||||
label={t("settings.advanced.interfaceName.label")}
|
||||
value={values.interfaceName}
|
||||
error={errors.interfaceName}
|
||||
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<div>
|
||||
@@ -114,7 +118,9 @@ export function SettingsAdvanced() {
|
||||
max={MTU_MAX}
|
||||
value={values.mtu}
|
||||
error={errors.mtu}
|
||||
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
@@ -122,13 +128,17 @@ export function SettingsAdvanced() {
|
||||
<SectionGroup title={t("settings.advanced.section.security")}>
|
||||
<div>
|
||||
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
|
||||
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
||||
<HelpText>
|
||||
{t("settings.advanced.psk.help")}
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={values.preSharedKey}
|
||||
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -15,13 +15,25 @@ export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { autostart, setAutostartEnabled } = useAutostartSetting();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
} = useManagementUrl();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMode = useRef(mode);
|
||||
useEffect(() => {
|
||||
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
|
||||
if (
|
||||
prevMode.current === ManagementMode.Cloud &&
|
||||
mode === ManagementMode.SelfHosted
|
||||
) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevMode.current = mode;
|
||||
@@ -59,7 +71,9 @@ export function SettingsGeneral() {
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>{t("settings.general.management.label")}</Label>
|
||||
<HelpText>{t("settings.general.management.help")}</HelpText>
|
||||
<HelpText>
|
||||
{t("settings.general.management.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
</div>
|
||||
|
||||
@@ -26,49 +26,49 @@ export const SettingsNavigation = () => {
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={t("settings.tabs.general")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={t("settings.tabs.network")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={t("settings.tabs.security")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
icon={UserCircleIcon}
|
||||
title={t("settings.tabs.profiles")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={t("settings.tabs.advanced")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={t("settings.tabs.troubleshooting")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={t("settings.tabs.general")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={t("settings.tabs.network")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={t("settings.tabs.security")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
icon={UserCircleIcon}
|
||||
title={t("settings.tabs.profiles")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={t("settings.tabs.advanced")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={t("settings.tabs.troubleshooting")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ import { isMacOS } from "@/lib/platform";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx";
|
||||
import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx";
|
||||
import {
|
||||
AutostartSettingsProvider,
|
||||
SettingsProvider,
|
||||
} from "@/contexts/SettingsContext.tsx";
|
||||
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
@@ -19,6 +22,21 @@ import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
|
||||
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
|
||||
|
||||
// The settings window mounts once at app startup (hidden) and stays at the
|
||||
// single URL `/#/settings` forever — no SetURL between opens, so the
|
||||
// `AppLayout` provider stack never re-mounts and we never see the
|
||||
// `SettingsSkeleton` flash mid-reload. Tab is local state, driven by:
|
||||
// - the `netbird:settings:open` Wails event from `WindowManager.OpenSettings`
|
||||
// (sets the target tab, then Go calls `Show`/`Focus`); and
|
||||
// - the same event with payload `"general"` from the close hook, so the
|
||||
// window is already on General the next time Show fires (common case).
|
||||
// In-window navigation state (e.g. the update-available header jump to About)
|
||||
// still wins for that one render.
|
||||
//
|
||||
// The `h-12` draggable strip at the top accounts for the macOS
|
||||
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
|
||||
// buttons float over invisible title bar) and mirrors the main window's
|
||||
// Header height so AppRightPanel ends up the same height in both windows.
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
@@ -37,63 +55,72 @@ export const SettingsPage = () => {
|
||||
return (
|
||||
<>
|
||||
{isMacOS() ? (
|
||||
<div className={"wails-draggable cursor-default select-none h-12 shrink-0"} />
|
||||
<div
|
||||
className={
|
||||
"wails-draggable cursor-default select-none h-12 shrink-0"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={"h-px shrink-0 bg-nb-gray-920/0"} />
|
||||
)}
|
||||
<VerticalTabs value={active} onValueChange={setActive}>
|
||||
<VerticalTabs
|
||||
value={active}
|
||||
onValueChange={setActive}
|
||||
>
|
||||
<SettingsNavigation />
|
||||
<AppRightPanel>
|
||||
<AutostartSettingsProvider>
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"profiles"}>
|
||||
<ProfilesTab />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content
|
||||
value={"troubleshooting"}
|
||||
>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"profiles"}>
|
||||
<ProfilesTab />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</AutostartSettingsProvider>
|
||||
</AppRightPanel>
|
||||
</VerticalTabs>
|
||||
|
||||
@@ -50,10 +50,7 @@ export function SettingsSSH() {
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup
|
||||
title={t("settings.ssh.section.capabilities")}
|
||||
disabled={!isSSHServerEnabled}
|
||||
>
|
||||
<SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
@@ -80,10 +77,7 @@ export function SettingsSSH() {
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup
|
||||
title={t("settings.ssh.section.authentication")}
|
||||
disabled={!isSSHServerEnabled}
|
||||
>
|
||||
<SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
@@ -98,7 +92,9 @@ export function SettingsSSH() {
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
|
||||
<HelpText margin={false}>
|
||||
{t("settings.ssh.jwtTtl.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
|
||||
@@ -18,6 +18,10 @@ export const SectionGroup = ({
|
||||
</section>
|
||||
);
|
||||
|
||||
// SettingsBottomBar renders the floating action bar at the bottom of a
|
||||
// settings tab (Save Changes / Add Profile / Create Bundle). It pairs the
|
||||
// absolutely positioned bar with an in-flow spacer of the same height so
|
||||
// scrollable content above doesn't end up hidden behind the bar.
|
||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={"h-[4rem] shrink-0"} aria-hidden />
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
@@ -39,7 +38,11 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
if (stage.kind === "done") {
|
||||
return (
|
||||
<DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} />
|
||||
<DoneResult
|
||||
result={stage.result}
|
||||
uploaded={stage.uploadAttempted}
|
||||
onClose={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (stage.kind !== "idle") {
|
||||
@@ -112,7 +115,7 @@ export function SettingsTroubleshooting() {
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
|
||||
function CenteredPanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -124,34 +127,22 @@ function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressSection({
|
||||
stage,
|
||||
onCancel,
|
||||
}: Readonly<{ stage: DebugStage; onCancel: () => void }>) {
|
||||
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cancelling = stage.kind === "cancelling";
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-2 max-w-sm"}>
|
||||
<DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading>
|
||||
<div className={"flex flex-col items-center gap-2 max-w-xs"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{stageLabel(stage, t)}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{t("settings.troubleshooting.progress.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{stage.kind === "capturing" && (
|
||||
<div
|
||||
className={
|
||||
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
|
||||
}
|
||||
aria-live={"polite"}
|
||||
>
|
||||
{formatRemaining(stage.remainingSec)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions className={"max-w-[220px]"}>
|
||||
<Button
|
||||
autoFocus
|
||||
@@ -172,25 +163,23 @@ function DoneResult({
|
||||
result,
|
||||
uploaded,
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
result: DebugBundleResult;
|
||||
uploaded: boolean;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showKey = uploaded && Boolean(result.uploadedKey);
|
||||
const uploadFailed = uploaded && !result.uploadedKey;
|
||||
const onRevealPath = () => {
|
||||
if (!result.path) return;
|
||||
DebugSvc.RevealFile(result.path).catch((err: unknown) =>
|
||||
console.error("reveal debug bundle file", err),
|
||||
);
|
||||
void DebugSvc.RevealFile(result.path).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-2 max-w-sm"}>
|
||||
<div className={"flex flex-col items-center gap-2 max-w-xs"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{showKey
|
||||
? t("settings.troubleshooting.done.uploadedTitle")
|
||||
@@ -203,7 +192,7 @@ function DoneResult({
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className={"w-full max-w-sm flex flex-col gap-3"}>
|
||||
<div className={"w-full max-w-xs flex flex-col gap-3"}>
|
||||
{showKey && <Input value={result.uploadedKey} readOnly copy />}
|
||||
|
||||
{result.path && !showKey && (
|
||||
@@ -263,7 +252,12 @@ function DoneResult({
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -271,17 +265,19 @@ function DoneResult({
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabel = (
|
||||
stage: DebugStage,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string => {
|
||||
const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return t("settings.troubleshooting.stage.preparingTrace");
|
||||
case "reconnecting":
|
||||
return t("settings.troubleshooting.stage.reconnecting");
|
||||
case "capturing":
|
||||
return t("settings.troubleshooting.stage.capturing");
|
||||
case "capturing": {
|
||||
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return t("settings.troubleshooting.stage.capturing", {
|
||||
elapsed: fmt(stage.totalSec - stage.remainingSec),
|
||||
total: fmt(stage.totalSec),
|
||||
});
|
||||
}
|
||||
case "restoring-level":
|
||||
return t("settings.troubleshooting.stage.restoring");
|
||||
case "bundling":
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialog } from "@/lib/dialogs";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
|
||||
import { WelcomeStepTray } from "./WelcomeStepTray";
|
||||
@@ -16,8 +17,18 @@ 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,
|
||||
@@ -28,6 +39,10 @@ function shouldShowManagementStep(
|
||||
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;
|
||||
@@ -39,12 +54,24 @@ 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(),
|
||||
@@ -70,6 +97,8 @@ export default function WelcomeDialog() {
|
||||
} 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: "",
|
||||
@@ -83,6 +112,10 @@ export default function WelcomeDialog() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -115,7 +148,10 @@ export default function WelcomeDialog() {
|
||||
async (url: string) => {
|
||||
if (!initial) return;
|
||||
try {
|
||||
// SetConfig is a partial update — undefined fields are preserved Go-side.
|
||||
// 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,
|
||||
|
||||
@@ -18,14 +18,16 @@ 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,
|
||||
}: Readonly<WelcomeStepManagementProps>) {
|
||||
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
|
||||
const { t } = useTranslation();
|
||||
const startsCloud = isCloudManagementUrl(initialUrl);
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
@@ -33,13 +35,21 @@ export function WelcomeStepManagement({
|
||||
);
|
||||
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);
|
||||
@@ -48,6 +58,9 @@ export function WelcomeStepManagement({
|
||||
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;
|
||||
@@ -56,11 +69,14 @@ export function WelcomeStepManagement({
|
||||
mode === ManagementMode.Cloud
|
||||
? CLOUD_MANAGEMENT_URL
|
||||
: normalizeManagementUrl(trimmedUrl);
|
||||
if (mode === ManagementMode.SelfHosted && !unreachable) {
|
||||
if (mode === ManagementMode.SelfHosted) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
if (!reachable) {
|
||||
// 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;
|
||||
}
|
||||
@@ -68,10 +84,14 @@ export function WelcomeStepManagement({
|
||||
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),
|
||||
|
||||
@@ -8,7 +8,12 @@ import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
|
||||
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
|
||||
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
|
||||
|
||||
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
|
||||
// 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;
|
||||
@@ -19,7 +24,7 @@ type WelcomeStepTrayProps = {
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# Translating the NetBird UI
|
||||
|
||||
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
|
||||
|
||||
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
|
||||
|
||||
> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
|
||||
---
|
||||
|
||||
## What NetBird is
|
||||
|
||||
A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard.
|
||||
|
||||
**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute.
|
||||
|
||||
**The vocabulary you'll meet:**
|
||||
|
||||
| Term | What it means here |
|
||||
|---|---|
|
||||
| **Peer** | A device on the network (laptop, server, phone) |
|
||||
| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") |
|
||||
| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway |
|
||||
| **Profile** | A saved connection identity you can switch between |
|
||||
| **Daemon** | The background service the UI talks to |
|
||||
| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) |
|
||||
| **Relay** | Forwards traffic when two peers can't connect directly |
|
||||
| **Rosenpass** | Post-quantum security layered over WireGuard® |
|
||||
| **Handshake** | The periodic WireGuard® key sync between peers |
|
||||
|
||||
---
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
i18n/locales/_index.json shipped-language list
|
||||
i18n/locales/en/common.json source of truth — message + description
|
||||
i18n/locales/<code>/common.json a target — message only
|
||||
```
|
||||
|
||||
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
|
||||
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
|
||||
| Give every key a non-empty `message` | Leave keys missing or empty |
|
||||
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
|
||||
|
||||
---
|
||||
|
||||
## Hard rules — get these exactly right
|
||||
|
||||
These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
|
||||
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
|
||||
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
|
||||
|
||||
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
|
||||
|
||||
**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form.
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers.
|
||||
|
||||
When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack.
|
||||
|
||||
**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`.
|
||||
|
||||
**Tier C — judgment.** One rule decides every term:
|
||||
|
||||
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
|
||||
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
|
||||
Two checks before you commit a term:
|
||||
|
||||
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
|
||||
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
|
||||
|
||||
---
|
||||
|
||||
## Style
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address |
|
||||
| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight |
|
||||
| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) |
|
||||
| Translate a term the **same way everywhere** | Vary wording for the same concept across screens |
|
||||
|
||||
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
|
||||
|
||||
A few habits that keep a bundle reading like one product rather than a word-for-word port:
|
||||
|
||||
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
|
||||
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
|
||||
- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected.
|
||||
- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously.
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
|
||||
**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
|
||||
|
||||
**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
|
||||
|
||||
---
|
||||
|
||||
## QA before you finish
|
||||
|
||||
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
|
||||
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
|
||||
- [ ] Buttons & tray short · locale punctuation and capitalization applied
|
||||
- [ ] New language added to `_index.json`
|
||||
- [ ] **Tested in the running app** ↓
|
||||
|
||||
---
|
||||
|
||||
## Test it in the app
|
||||
|
||||
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
|
||||
How to run the app and switch language: see `../CLAUDE.md` and `../frontend/CLAUDE.md`. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
@@ -33,12 +33,6 @@ const (
|
||||
// commonBundleFile is the per-language translation bundle. Single
|
||||
// namespace for now ("common") — split later if the key set grows
|
||||
// enough to warrant per-screen bundles.
|
||||
//
|
||||
// Shape is Chrome-extension JSON (each key maps to an object with a
|
||||
// "message" and an optional "description") so Crowdin reads the
|
||||
// description as translator context straight from the source file.
|
||||
// Only the source bundle (en) needs descriptions; target bundles carry
|
||||
// just "message". loadBundle flattens both back to key->message.
|
||||
commonBundleFile = "common.json"
|
||||
)
|
||||
|
||||
@@ -201,27 +195,15 @@ func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) {
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
// bundleEntry is the on-disk shape of one translation key: a Chrome-JSON
|
||||
// object carrying the translatable "message" plus an optional translator
|
||||
// "description" (consumed by Crowdin, ignored at runtime).
|
||||
type bundleEntry struct {
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) {
|
||||
p := path.Join(string(code), commonBundleFile)
|
||||
data, err := fs.ReadFile(localesFS, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries map[string]bundleEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
var bundle map[string]string
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", p, err)
|
||||
}
|
||||
bundle := make(map[string]string, len(entries))
|
||||
for k, e := range entries {
|
||||
bundle[k] = e.Message
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ func fakeLocales() fstest.MapFS {
|
||||
]
|
||||
}`)},
|
||||
"en/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": {"message": "Connect", "description": "Tray menu item"},
|
||||
"tray.menu.installVersion": {"message": "Install version {version}"},
|
||||
"notify.update.body": {"message": "NetBird {version} is available."}
|
||||
"tray.menu.connect": "Connect",
|
||||
"tray.menu.installVersion": "Install version {version}",
|
||||
"notify.update.body": "NetBird {version} is available."
|
||||
}`)},
|
||||
"hu/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": {"message": "Csatlakozás"},
|
||||
"tray.menu.installVersion": {"message": "{version} telepítése"}
|
||||
"tray.menu.connect": "Csatlakozás",
|
||||
"tray.menu.installVersion": "{version} telepítése"
|
||||
}`)},
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestBundle_MissingDefaultBundleFails(t *testing.T) {
|
||||
// English locale.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
}
|
||||
_, err := NewBundle(fs)
|
||||
require.Error(t, err)
|
||||
@@ -134,7 +134,7 @@ func TestBundle_MissingBundleSkipsLanguage(t *testing.T) {
|
||||
{"code":"en","displayName":"English","englishName":"English"},
|
||||
{"code":"de","displayName":"Deutsch","englishName":"German"}
|
||||
]}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
}
|
||||
b, err := NewBundle(fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
|
||||
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"},
|
||||
{"code": "ru", "displayName": "Русский", "englishName": "Russian"},
|
||||
{"code": "es", "displayName": "Español", "englishName": "Spanish"},
|
||||
{"code": "fr", "displayName": "Français", "englishName": "French"},
|
||||
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
|
||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user