remove unused stuff, refactor frontend folder structure

This commit is contained in:
Eduard Gert
2026-05-28 16:26:13 +02:00
parent e09bc8894d
commit 51b243bdfa
92 changed files with 953 additions and 1629 deletions

View File

@@ -34,7 +34,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
@@ -59,7 +59,7 @@ Daemon connection status strings (`services/peers.go`) mirror `internal.Status*`
## Profile switching
`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profile/ProfileContext.tsx`'s `switchProfile`, which `modules/settings/SettingsProfiles.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions.
`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profiles/ProfileContext.tsx`'s `switchProfile`, which `modules/profiles/ProfilesTab.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions.
Reconnect policy (driven by `prevStatus` from `Peers.Get`):
@@ -88,10 +88,10 @@ Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-sid
The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`:
- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
- **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
- **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up.
- **InstallProgress** (`/#/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). The DEV-only "Development" tab has a "Show updating dialog" button that opens this window directly for preview.
- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow.Currently no triggers wired — daemon-status integration is a follow-up.
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
All five auxiliary windows 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 auxiliaries.
@@ -134,7 +134,7 @@ The tray uses Wails' built-in `notifications` service. One `notifications.Notifi
### Profile switching invariants
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/settings/SettingsProfiles.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
**Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`.

View File

@@ -22,21 +22,53 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
| Path | Component | Layout | Where it opens |
|---|---|---|---|
| `/` | `Main` | `AppLayout` | Main window default route |
| `/browser-login` | `WaitingForBrowserDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
| `/install-progress` | `InstallProgressDialog` (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). Dev "Show updating dialog" button in `SettingsDevelopment` opens it directly. |
| `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). The `Profiles` tab (`modules/settings/SettingsProfiles.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")``Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. |
| `/` | `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-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")``Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/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). The view-mode (Default 380×640 vs Advanced 900×640) lives as `useState` inside `Header.tsx`, which calls `Window.SetSize` directly on change — no shared shell context for it.
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
`SettingsLayout` uses the same provider stack minus the `Header`. It also reserves a 38px `wails-draggable` strip at the top so the macOS traffic-light buttons (the window uses `MacTitleBarHiddenInset`) don't overlap content.
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
Page-specific chrome lives next to the page, not in the layout:
- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only:
- `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.
## Directory layout (src/)
`modules/<feature>/` owns the polished UI — both the AppLayout-shell-driven views and the standalone auxiliary windows (e.g. `modules/authentication/WaitingForBrowserDialog.tsx`, `modules/auto-update/InstallProgressDialog.tsx`). `screens/` is a residual legacy bucket — don't add new code there.
- `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/``SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
- `modules/auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
- `components/` — presentational primitives, no domain coupling. Grouped by family:
- `components/buttons/``Button`, `IconButton`.
- `components/inputs/``Input`, `SearchInput`.
- `components/dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
- `components/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`.
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
## Wails event bus
@@ -44,15 +76,14 @@ Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed
| Event name (string) | Payload | Emitted by | Consumed by |
|---|---|---|---|
| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `modules/daemon-status/StatusContext` (`useStatus`) |
| `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` | `modules/profile/ProfileContext` refreshes so a tray-initiated switch paints in the React UI. |
| `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`. |
| `netbird:dev:overrides` | `{updateAvailable, enforced, version}` | `modules/settings/SettingsDevelopment.tsx` toggles | `modules/auto-update/ClientVersionContext` listens and overrides daemon-reported update state when the dev toggle is on. In-memory only; resets when Settings window closes. |
| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `layouts/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()` when fired. No Go-side emitter today. |
| `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. |
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.
@@ -60,19 +91,18 @@ If you wire a new daemon-event subscriber on the TS side, prefer subscribing onc
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
- **`useStatus`** (`modules/daemon-status/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.
- **`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.
- **`ProfileContext`** (`modules/profile/`) — `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()`.
- **`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()`.
- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `<SkeletonSettings/>` 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 `"**********"`.
- **`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 `"**********"`.
- **`DebugBundleProvider` + `useDebugBundle`** (`modules/debug-bundle/`) — 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`.
- **`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`.
Dev preview: `SettingsDevelopment` toggles emit `netbird:dev:overrides`, which this provider listens for and overrides `available / enforced / version`. No more module-level `FORCE_*` constants.
### Default/Advanced view + no client-side persistence
@@ -121,7 +151,7 @@ Compare against the variable, never against an English literal.
**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/modules/settings/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.
**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/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
@@ -132,7 +162,7 @@ The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlig
1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user.
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`).
- `WaitingForBrowserDialog` 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.
- `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.
@@ -162,8 +192,8 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
## Things in flight (don't be surprised by)
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `MainRightSide` and is what the user sees today; the real-data one isn't wired into the route table.
- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
- **`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/SessionExpiredDialog.tsx`** and **`modules/session/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. No triggers wired today — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
## Wails Go API reference

View File

@@ -16,7 +16,6 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",

View File

@@ -14,9 +14,6 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -536,19 +533,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -2018,23 +2002,6 @@ snapshots:
'@types/react': 18.3.29
'@types/react-dom': 18.3.7(@types/react@18.3.29)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.29
'@types/react-dom': 18.3.7(@types/react@18.3.29)
'@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)

View File

@@ -2,17 +2,16 @@ import React from "react";
import ReactDOM from "react-dom/client";
import "./globals.css";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import SessionExpiredDialog from "@/modules/authentication/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/authentication/SessionAboutToExpireDialog.tsx";
import InstallProgressDialog from "@/modules/auto-update/InstallProgressDialog.tsx";
import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { SettingsLayout } from "@/layouts/SettingsLayout.tsx";
import { Main } from "@/layouts/Main.tsx";
import { Settings } from "@/modules/settings/Settings.tsx";
import { MainPage } from "@/modules/main/MainPage.tsx";
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
import { SkeletonTheme } from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { welcome } from "@/lib/welcome";
import WaitingForBrowserDialog from "@/modules/authentication/WaitingForBrowserDialog.tsx";
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
import { initI18n } from "@/lib/i18n";
welcome();
@@ -30,15 +29,15 @@ initI18n()
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="/browser-login" element={<WaitingForBrowserDialog />} />
<Route path="/install-progress" element={<InstallProgressDialog />} />
<Route path="/session-expired" element={<SessionExpiredDialog />} />
<Route path="/session-about-to-expire" element={<SessionAboutToExpireDialog />} />
<Route element={<SettingsLayout />}>
<Route path="settings" element={<Settings />} />
<Route path="dialog">
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
<Route path="install-progress" element={<UpdateInProgressDialog />} />
<Route path="session-expired" element={<SessionExpiredDialog />} />
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<Main />} />
<Route index element={<MainPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}

View File

@@ -1,40 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { generateColorFromString } from "@/lib/color";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
name?: string;
size?: number;
};
export const Avatar = forwardRef<HTMLButtonElement, Props>(function Avatar(
{ name = "", size = 28, className, type = "button", ...props },
ref,
) {
const initial = (name.trim().charAt(0) || "?").toUpperCase();
const color = generateColorFromString(name);
return (
<button
ref={ref}
type={type}
className={cn(
"inline-grid place-items-center rounded-full bg-nb-gray-850 p-0 text-center",
"text-[0.9rem] font-semibold cursor-default outline-none",
"transition-colors duration-150 hover:bg-nb-gray-850",
"data-[state=open]:bg-nb-gray-850",
className,
)}
style={{
width: size,
height: size,
color,
lineHeight: 0,
letterSpacing: 0,
}}
{...props}
>
{initial}
</button>
);
});

View File

@@ -1,60 +0,0 @@
import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/cn";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
children?: ReactNode;
className?: string;
};
export const BottomSheet = ({ open, onOpenChange, children, className }: Props) => {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
return createPortal(
<AnimatePresence>
{open && (
<div className={"fixed inset-0 z-50"}>
<motion.div
className={"absolute inset-0 bg-black/40 backdrop-blur-sm"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
onClick={() => onOpenChange(false)}
/>
<motion.div
role={"dialog"}
aria-modal={"true"}
className={cn(
"absolute left-0 right-0 bottom-0",
"bg-nb-gray-925 border-t border-nb-gray-850 rounded-t-2xl",
"shadow-2xl outline-none",
"max-h-[85vh] overflow-hidden",
className,
)}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 360, damping: 34 }}
>
<div className={"flex justify-center pt-2"}>
<div className={"h-1 w-10 rounded-full bg-nb-gray-700"} />
</div>
<div className={"px-5 pt-4 pb-6"}>{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
};

View File

@@ -1,14 +0,0 @@
import { HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
className,
)}
{...rest}
/>
);
}

View File

@@ -1,86 +0,0 @@
import {
ButtonHTMLAttributes,
ComponentType,
forwardRef,
ReactNode,
} from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
icon?: ComponentType<LucideProps>;
iconNode?: ReactNode;
title: string;
description?: string;
active?: boolean;
iconSize?: number;
};
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
function CardNavItem(
{
icon: Icon,
iconNode,
title,
description,
active = false,
iconSize = 15,
className,
type = "button",
...props
},
ref,
) {
return (
<button
ref={ref}
type={type}
className={cn(
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
className,
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
{iconNode ?? (Icon && (
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
))}
</div>
<div className={"min-w-0"}>
<h2
className={cn(
"font-medium text-[0.81rem] truncate",
active ? "text-nb-gray-100" : "text-nb-gray-200",
)}
>
{title}
</h2>
{description && (
<p
className={cn(
"text-xs font-medium truncate",
active ? "text-nb-gray-300" : "text-nb-gray-400",
)}
>
{description}
</p>
)}
</div>
</button>
);
},
);

View File

@@ -1,76 +0,0 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { CheckIcon } from "lucide-react";
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
type RootProps = {
value: string;
onChange: (value: string) => void;
children: ReactNode;
className?: string;
};
const Root = ({ value, onChange, children, className }: RootProps) => {
return (
<RadioGroup.Root
value={value}
onValueChange={onChange}
className={cn("grid grid-cols-2 gap-3", className)}
>
{children}
</RadioGroup.Root>
);
};
type OptionProps = {
value: string;
title: string;
description?: string;
preview?: ReactNode;
className?: string;
};
const Option = ({ value, title, description, preview, className }: OptionProps) => {
return (
<RadioGroup.Item
value={value}
className={cn(
"group relative flex flex-col items-stretch text-left rounded-lg",
"border border-nb-gray-850 bg-nb-gray-925 p-3 cursor-default outline-none",
"transition-colors duration-150",
"hover:border-nb-gray-800",
"data-[state=checked]:border-netbird data-[state=checked]:ring-1 data-[state=checked]:ring-netbird",
className,
)}
>
<span
className={cn(
"absolute top-2.5 right-2.5 flex h-4 w-4 items-center justify-center rounded-[4px]",
"border border-nb-gray-700 bg-nb-gray-900",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator className={"flex items-center justify-center"}>
<CheckIcon size={11} className={"text-white"} strokeWidth={3} />
</RadioGroup.Indicator>
</span>
<div
className={cn(
"h-48 -mx-3 -mt-3 mb-3 overflow-hidden",
"bg-gradient-to-b from-nb-gray-800/15 to-nb-gray",
"rounded-t-lg flex items-center justify-center",
)}
>
{preview}
</div>
<h3 className={"text-sm font-semibold text-nb-gray-100"}>{title}</h3>
{description && (
<p className={"text-[0.72rem] leading-snug text-nb-gray-400 mt-0.5"}>
{description}
</p>
)}
</RadioGroup.Item>
);
};
export const CardSelect = Object.assign(Root, { Option });

View File

@@ -1,34 +0,0 @@
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import * as React from "react";
import { cn } from "@/lib/cn";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardPortal = HoverCardPrimitive.Portal;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "start", sideOffset = 6, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-80 rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-3 text-nb-gray-200 shadow-lg outline-none",
"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",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger };

View File

@@ -7,8 +7,8 @@ import { Dialogs } from "@wailsio/runtime";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";

View File

@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import netbirdLogo from "@/assets/logos/netbird.svg";
import { SwitchItem } from "@/components/SwitchItem";
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
import { SwitchItem } from "@/components/switches/SwitchItem";
import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;

View File

@@ -1,48 +0,0 @@
import type { ReactNode } from "react";
import { Check, Loader2, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "loading" | "success" | "error";
type Props = {
variant: Variant;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
};
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
loading: {
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
className: "bg-nb-gray-100",
},
success: {
icon: <Check className={"text-white"} size={18} />,
className: "bg-green-500",
},
error: {
icon: <XCircle className={"text-white"} size={18} />,
className: "bg-red-500",
},
};
export function StatusPanel({ variant, title, description, children, actions }: Props) {
const { icon, className } = VARIANTS[variant];
return (
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
{icon}
</div>
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
</div>
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { cn } from "../lib/cn";
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
description?: string;
}
export function Switch({ checked, onChange, disabled, label, description }: Props) {
return (
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
{(label || description) && (
<span className="flex flex-col">
{label && <span className="text-sm font-medium">{label}</span>}
{description && (
<span className="text-xs text-nb-gray-500">{description}</span>
)}
</span>
)}
</label>
);
}

View File

@@ -1,40 +0,0 @@
import { ReactNode, useState } from "react";
import { cn } from "../lib/cn";
interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface Props {
tabs: Tab[];
initial?: string;
}
export function Tabs({ tabs, initial }: Props) {
const [active, setActive] = useState(initial ?? tabs[0]?.value);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setActive(t.value)}
className={cn(
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active === t.value
? "border-netbird text-netbird"
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
)}
>
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto">
{tabs.find((t) => t.value === active)?.content}
</div>
</div>
);
}

View File

@@ -3,15 +3,15 @@ import classNames from "classnames";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
export type ButtonVariants = VariantProps<typeof buttonVariants>;
type ButtonVariants = VariantProps<typeof buttonVariants>;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
}
export const buttonVariants = cva(
const buttonVariants = cva(
[
"relative",
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm select-none",

View File

@@ -5,11 +5,8 @@ import { X } from "lucide-react";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
export const Trigger = DialogPrimitive.Trigger;
export const Close = DialogPrimitive.Close;
export const Portal = DialogPrimitive.Portal;
export const Overlay = forwardRef<
const Overlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {

View File

@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import { AlertCircleIcon, BookText } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Button } from "@/components/Button";
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { ExternalLinkIcon, LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "./SquareIcon";
import { SquareIcon } from "@/components/SquareIcon";
type Props = {
icon: ComponentType<LucideProps>;

View File

@@ -2,7 +2,7 @@ import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Label } from "@/components/Label";
import { Label } from "@/components/typography/Label";
type InputVariants = VariantProps<typeof inputVariants>;

View File

@@ -1,7 +1,7 @@
import React from "react";
import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { ToggleSwitch } from "@/components/ToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { ToggleSwitch } from "@/components/switches/ToggleSwitch";
import { cn } from "@/lib/cn";
interface Props {

View File

@@ -2,7 +2,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group";
import { motion } from "framer-motion";
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
type Props = {
value: string;

View File

@@ -9,6 +9,8 @@ import {
type ReactNode,
} from "react";
import { Dialogs, Events } from "@wailsio/runtime";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
@@ -34,18 +36,6 @@ type ClientVersionContextValue = {
const EVENT_UPDATE_STATE = "netbird:update:state";
// Dev tab in Settings emits this with { updateAvailable, enforced, version }.
// Lives only in-memory in the main window for the session — losing it when
// Settings closes is acceptable per the dev-toggle scope (no daemon write,
// no persistence). See SettingsDevelopment.tsx.
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
type DevOverrides = {
updateAvailable: boolean;
enforced: boolean;
version: string;
};
const emptyState: UpdateState = {
available: false,
version: "",
@@ -66,7 +56,6 @@ export const useClientVersion = () => {
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<UpdateState>(emptyState);
const [updating, setUpdating] = useState(false);
const [devOverride, setDevOverride] = useState<DevOverrides | null>(null);
useEffect(() => {
let cancelled = false;
@@ -91,46 +80,23 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
};
}, []);
useEffect(() => {
const off = Events.On(EVENT_DEV_OVERRIDES, (ev: { data: DevOverrides }) => {
if (ev?.data) setDevOverride(ev.data);
});
return () => {
off?.();
};
}, []);
// Dev override only kicks in when it explicitly forces updateAvailable on.
// Otherwise daemon truth wins.
const effective = useMemo<UpdateState>(() => {
if (devOverride && devOverride.updateAvailable) {
return {
available: true,
version: devOverride.version || "0.65.0",
enforced: devOverride.enforced,
installing: state.installing,
};
}
return state;
}, [state, devOverride]);
// 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 (effective.installing && !prevInstallingRef.current) {
WindowManager.OpenInstallProgress(effective.version || "").catch(console.error);
if (state.installing && !prevInstallingRef.current) {
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
}
prevInstallingRef.current = effective.installing;
}, [effective.installing, effective.version]);
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(effective.version || "").catch(console.error);
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
UpdateSvc.Trigger()
.catch(async (e) => {
// The daemon may already be down (force-install branch raced
@@ -146,18 +112,18 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
});
})
.finally(() => setUpdating(false));
}, [effective.version]);
}, [state.version]);
const value = useMemo<ClientVersionContextValue>(
() => ({
updateAvailable: effective.available,
updateVersion: effective.version || null,
enforced: effective.enforced,
installing: effective.installing,
updateAvailable: state.available,
updateVersion: state.version || null,
enforced: state.enforced,
installing: state.installing,
triggerUpdate,
updating,
}),
[effective, triggerUpdate, updating],
[state, triggerUpdate, updating],
);
return (

View File

@@ -1,4 +1,10 @@
import { useRef, useState } from "react";
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { Dialogs } from "@wailsio/runtime";
import {
Connection as ConnectionSvc,
@@ -7,7 +13,7 @@ import {
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors.ts";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { useProfile } from "@/contexts/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
const TRACE_LOG_FILE_COUNT = 5;
@@ -44,7 +50,7 @@ const sleep = (ms: number, signal: AbortSignal) =>
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
export const useDebugBundle = () => {
const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true);
@@ -191,3 +197,26 @@ export const useDebugBundle = () => {
openBundleDir,
};
};
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
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",
);
}
return ctx;
};

View File

@@ -10,7 +10,7 @@ import {
} from "react";
import { Networks as NetworksSvc } from "@bindings/services";
import type { Network } from "@bindings/services/models.js";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { useStatus } from "@/contexts/StatusContext";
// 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

View File

@@ -11,8 +11,8 @@ import { Dialogs } from "@wailsio/runtime";
import { Settings as SettingsSvc } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
@@ -150,7 +150,7 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
return (
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SkeletonSettings />
<SettingsSkeleton />
) : (
<SettingsContext.Provider
value={{

View File

@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
import { Events } from "@wailsio/runtime";
import { Peers } from "@bindings/services";
import type { Status } from "@bindings/services/models.js";
import { DaemonUnavailableOverlay } from "@/modules/daemon-status/DaemonUnavailableOverlay.tsx";
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
const EVENT_STATUS = "netbird:status";

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Dialogs } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
export enum ManagementMode {
Cloud = "cloud",

View File

@@ -1,12 +1,16 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
import { NavSectionProvider } from "@/lib/navSection";
import { ViewModeProvider } from "@/lib/viewMode";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
import { StatusProvider } from "@/contexts/StatusContext.tsx";
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.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"}>
@@ -14,12 +18,7 @@ export const AppLayout = () => {
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<ViewModeProvider>
<NavSectionProvider>
<Header />
<Outlet />
</NavSectionProvider>
</ViewModeProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>

View File

@@ -16,7 +16,7 @@ const PANEL_TRANSITION = {
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
};
export const MainRightSide = ({ children, overlay, overlayOpen = false }: Props) => {
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
return (
<div
className={cn(

View File

@@ -1,78 +0,0 @@
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
import { cn } from "@/lib/cn";
import { useNavSection } from "@/lib/navSection";
import { useViewMode } from "@/lib/viewMode";
import { NotConnectedState } from "@/components/NotConnectedState";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { Peers } from "@/modules/peers/Peers";
import { Networks } from "@/modules/networks/Networks";
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/modules/networks/NetworksContext";
import {
PeerDetailProvider,
usePeerDetail,
} from "@/modules/peers/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/peers/PeerDetailPanel";
export const Main = () => {
return (
<NetworksProvider>
<PeerDetailProvider>
<MainBody />
</PeerDetailProvider>
</NetworksProvider>
);
};
const MainBody = () => {
const { viewMode } = useViewMode();
const isAdvanced = viewMode === "advanced";
const { section } = useNavSection();
const { selected } = usePeerDetail();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div
className={
"flex flex-col items-center shrink-0 w-[348px]"
}
>
<ConnectionStatusSwitch />
</div>
{isAdvanced && (
<MainRightSide
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
>
<div
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
!isConnected && "pointer-events-none select-none",
)}
aria-hidden={!isConnected}
>
<Navigation />
<div className={"flex-1 min-h-0 flex flex-col"}>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</div>
</div>
{!isConnected && (
<div
className={
"absolute inset-0 z-20 flex pointer-events-auto bg-nb-gray-940"
}
>
<NotConnectedState />
</div>
)}
</MainRightSide>
)}
</div>
);
};

View File

@@ -1,36 +0,0 @@
import { Outlet } from "react-router-dom";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
// SettingsLayout wraps the Settings screen for use inside its own dedicated
// window. Same provider stack as AppLayout but without the main Header — the
// settings window has its own native title bar and doesn't show the profile
// selector / panel toggle / settings icon.
//
// The h-10 placeholder strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
// title bar is invisible but the traffic-light buttons still float in the
// top-left corner. The height also mirrors the main window's Header so the
// MainRightSide panel ends up the same height in both windows.
export const SettingsLayout = () => {
return (
<div className={"relative flex h-full flex-col select-none"}>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<div
className={
"wails-draggable cursor-default select-none h-12 shrink-0"
}
/>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
</div>
);
};

View File

@@ -1,17 +0,0 @@
import chroma from "chroma-js";
export const generateColorFromString = (str?: string) => {
if (!str) return "#f68330";
if (str.includes("System")) return "#808080";
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
let hash = 0;
str.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let colour = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
colour += value.toString(16).padStart(2, "0");
}
return chroma(colour).saturate(2).luminance(0.4).hex();
};

View File

@@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Loader2, XCircle } from "lucide-react";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
@@ -25,7 +25,7 @@ type Phase =
| { kind: "canceled" }
| { kind: "failed"; message: string };
export default function InstallProgressDialog() {
export default function UpdateInProgressDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const version = params.get("version") ?? "";

View File

@@ -2,8 +2,8 @@ import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { DownloadIcon, NotepadText } from "lucide-react";
import { Button } from "@/components/Button";
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
import { Button } from "@/components/buttons/Button";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";

View File

@@ -1,16 +0,0 @@
import { createContext, type ReactNode } from "react";
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
export const DebugBundleContext =
createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
};

View File

@@ -1,12 +0,0 @@
import { useContext } from "react";
import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx";
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error(
"useDebugBundleContext must be used inside DebugBundleProvider",
);
}
return ctx;
};

View File

@@ -1,77 +0,0 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useTranslation } from "react-i18next";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
const NONE_VALUE = "__none__";
type Props = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
export const ExitNodesList = ({ data, onToggle }: Props) => {
const { t } = useTranslation();
const active = data.find((n) => n.selected) ?? null;
const value = active?.id ?? NONE_VALUE;
const handleChange = (next: string) => {
if (next === value) return;
if (next === NONE_VALUE) {
if (active) onToggle(active.id, true);
return;
}
onToggle(next, false);
};
return (
<RadioGroup.Root
value={value}
onValueChange={handleChange}
className={"flex flex-col"}
>
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
{data.map((n) => (
<Row key={n.id} value={n.id} label={n.id} />
))}
</RadioGroup.Root>
);
};
type RowProps = {
value: string;
label: string;
first?: boolean;
};
const Row = ({ value, label, first }: RowProps) => (
<RadioGroup.Item
value={value}
className={cn(
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
first && "mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer outline-none text-left",
)}
>
<span
className={
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{label}
</span>
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border",
"border-nb-gray-700 bg-nb-gray-900",
"flex items-center justify-center",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator
className={"h-2 w-2 rounded-full bg-white"}
/>
</span>
</RadioGroup.Item>
);

View File

@@ -4,19 +4,19 @@ import { useSearchParams } from "react-router-dom";
import { Dialogs, Events } from "@wailsio/runtime";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
export default function WaitingForBrowserDialog() {
export default function LoginWaitingForBrowserDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";

View File

@@ -3,9 +3,9 @@ import { useTranslation } from "react-i18next";
import { Dialogs, Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
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 { formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
@@ -114,7 +114,7 @@ async function startLogin(): Promise<void> {
}
}
export const ConnectionStatusSwitch = () => {
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
const { activeProfile, username } = useProfile();

View File

@@ -18,16 +18,16 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { IconButton } from "@/components/IconButton";
import { ProfileDropdown } from "@/components/ProfileDropdown";
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
import { IconButton } from "@/components/buttons/IconButton";
import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/lib/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/lib/viewMode";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
export const Header = () => {
export const MainHeader = () => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const { viewMode, setViewMode } = useViewMode();

View File

@@ -0,0 +1,89 @@
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
import { MainHeader } from "@/modules/main/MainHeader.tsx";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
import { cn } from "@/lib/cn";
import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/contexts/NetworksContext";
import {
PeerDetailProvider,
usePeerDetail,
} from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
export const MainPage = () => {
return (
<ViewModeProvider>
<MainHeader />
<NetworksProvider>
<PeerDetailProvider>
<MainBody />
</PeerDetailProvider>
</NetworksProvider>
</ViewModeProvider>
);
};
const MainBody = () => {
const { viewMode } = useViewMode();
const isAdvanced = viewMode === "advanced";
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div
className={"flex flex-col items-center shrink-0 w-[348px]"}
>
<MainConnectionStatusSwitch />
</div>
{isAdvanced && (
<NavSectionProvider>
<AdvancedAppRightPanel />
</NavSectionProvider>
)}
</div>
);
};
const AdvancedAppRightPanel = () => {
const { section } = useNavSection();
const { selected } = usePeerDetail();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
return (
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
>
<div
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
!isConnected && "pointer-events-none select-none",
)}
aria-hidden={!isConnected}
>
<Navigation />
<div className={"flex-1 min-h-0 flex flex-col"}>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</div>
</div>
{!isConnected && (
<div
className={
"absolute inset-0 z-20 flex pointer-events-auto bg-nb-gray-940"
}
>
<NotConnectedState />
</div>
)}
</AppRightPanel>
);
};

View File

@@ -2,8 +2,8 @@ import { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/lib/navSection";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
type TabEntry = {
value: NavSection;
@@ -80,4 +80,4 @@ const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
/>
);
export type { NavSection } from "@/lib/navSection";
export type { NavSection } from "@/contexts/NavSectionContext";

View File

@@ -1,14 +1,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as RadioGroup from "@radix-ui/react-radio-group";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { WaypointsIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { useNetworks } from "@/modules/networks/NetworksContext";
import { ExitNodesList } from "./ExitNodesList";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
const NONE_VALUE = "__none__";
export const ExitNodes = () => {
const { t } = useTranslation();
@@ -104,3 +107,74 @@ export const ExitNodes = () => {
</div>
);
};
type ExitNodesListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
const { t } = useTranslation();
const active = data.find((n) => n.selected) ?? null;
const value = active?.id ?? NONE_VALUE;
const handleChange = (next: string) => {
if (next === value) return;
if (next === NONE_VALUE) {
if (active) onToggle(active.id, true);
return;
}
onToggle(next, false);
};
return (
<RadioGroup.Root
value={value}
onValueChange={handleChange}
className={"flex flex-col"}
>
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
{data.map((n) => (
<Row key={n.id} value={n.id} label={n.id} />
))}
</RadioGroup.Root>
);
};
type RowProps = {
value: string;
label: string;
first?: boolean;
};
const Row = ({ value, label, first }: RowProps) => (
<RadioGroup.Item
value={value}
className={cn(
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
first && "mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer outline-none text-left",
)}
>
<span
className={
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{label}
</span>
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border",
"border-nb-gray-700 bg-nb-gray-900",
"flex items-center justify-center",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator
className={"h-2 w-2 rounded-full bg-white"}
/>
</span>
</RadioGroup.Item>
);

View File

@@ -1,10 +1,17 @@
import type { ComponentType } from "react";
import * as Popover from "@radix-ui/react-popover";
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
// The daemon stringifies route.Network via netip.Prefix.String(). For
// DNS-based routes the prefix is the zero value, which Go renders as
@@ -44,26 +51,202 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
return NetworkIcon;
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
// 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[]> => {
const byRange = new Map<string, string[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r.id);
byRange.set(r.range, arr);
}
const out = new Map<string, string[]>();
for (const [range, ids] of byRange) {
if (ids.length > 1) out.set(range, ids);
}
return out;
};
export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
const overlapById = useMemo(() => {
const map = new Map<string, string[]>();
for (const ids of overlapGroups.values()) {
for (const id of ids) map.set(id, ids);
}
return map;
}, [overlapGroups]);
const counts = useMemo<Record<NetworkFilter, number>>(
() => ({
all: networkRoutes.length,
active: networkRoutes.filter((r) => r.selected).length,
overlapping: overlapById.size,
}),
[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 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);
})
.map((r) => r.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [networkRoutes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [ordered, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) {
return (
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={NetworkIcon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
learnMoreTopic={t("nav.resources.title")}
/>
</div>
);
}
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
void setNetworksSelected(
filtered.map((r) => r.id),
false,
);
} else {
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
void setNetworksSelected(ids, true);
}
};
return (
<div
className={cn(
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<NetworksList data={filtered} onToggle={toggleNetwork} />
)}
</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>
{filtered.length > 0 && (
<div
className={cn(
"flex items-center gap-3 px-6 py-3.5",
"border-t border-nb-gray-910",
)}
>
<span className={"flex-1 text-xs font-medium text-nb-gray-300 tabular-nums"}>
{t("networks.bulk.selectionCount", {
selected: selectedInView,
total: filtered.length,
})}
</span>
<button
type={"button"}
onClick={onBulkClick}
className={cn(
"inline-flex items-center h-8 px-3 rounded-md",
"text-xs font-medium text-nb-gray-100",
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
"transition-colors outline-none wails-no-draggable cursor-pointer",
)}
>
{bulkLabel}
</button>
</div>
)}
>
<Icon size={14} />
</div>
);
};
type Props = {
type NetworksListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
export const NetworksList = ({ data, onToggle }: Props) => {
const NetworksList = ({ data, onToggle }: NetworksListProps) => {
const { t } = useTranslation();
return (
@@ -113,6 +296,20 @@ export const NetworksList = ({ data, onToggle }: Props) => {
);
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
<div
className={cn(
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
)}
>
<Icon size={14} />
</div>
);
};
const Subtitle = ({ network }: { network: Network }) => {
if (isDnsRoute(network)) {
const domain = network.domains[0];
@@ -135,12 +332,7 @@ const Subtitle = ({ network }: { network: Network }) => {
return null;
};
type DomainSubtitleProps = {
domain: string;
ips: string[];
};
const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
const first = ips[0];
const extra = ips.length - 1;
@@ -235,7 +427,7 @@ type ToggleProps = {
mixed?: boolean;
};
export const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<button
type={"button"}
role={"switch"}

View File

@@ -1,7 +1,10 @@
import { ComponentType, ReactNode } from "react";
import { ComponentType, ReactNode, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowUpDownIcon,
ArrowUpIcon,
CableIcon,
@@ -20,15 +23,126 @@ import {
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { formatBytes, formatRelative, latencyColor } from "./format";
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
type Props = {
peer: PeerStatus;
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
ease: [0.32, 0.72, 0, 1],
};
const DASH = "-";
export const PeerDetails = ({ peer }: Props) => {
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
type Props = {
transition?: Transition;
};
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status } = 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 fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
return;
}
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// Esc closes the panel.
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
return (
<AnimatePresence>
{selected && (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={transition}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
>
<div
className={cn(
"shrink-0 flex items-center gap-3",
"px-3 h-12 border-b border-nb-gray-910",
)}
>
<button
type={"button"}
onClick={() => setSelected(null)}
aria-label={t("common.close")}
className={cn(
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"transition-colors outline-none cursor-default",
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} />
</button>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus),
)}
title={selected.connStatus}
/>
<span className={"min-w-0 text-sm font-medium text-nb-gray-100 truncate"}>
{selected.fqdn || selected.ip}
</span>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} />
</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>
</motion.div>
)}
</AnimatePresence>
);
};
const PeerDetails = ({ peer }: { peer: PeerStatus }) => {
const { t } = useTranslation();
const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never");
const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH;

View File

@@ -1,17 +1,31 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { LaptopIcon } from "lucide-react";
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerFilters, StatusFilter } from "./PeerFilters";
import { PeersList } from "./PeersList";
const isOnline = (connStatus: string) => connStatus === "Connected";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
@@ -19,7 +33,7 @@ export const Peers = () => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
// Peers is only mounted in advanced view (see layouts/Main.tsx), so a
// 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(() => {
@@ -110,3 +124,71 @@ export const Peers = () => {
</div>
);
};
const PeersList = ({ data }: { data: PeerStatus[] }) => {
const { setSelected } = usePeerDetail();
return (
<ul className={"flex flex-col"}>
{data.map((peer) => {
const isConnected = peer.connStatus === "Connected";
return (
<li
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
dotClass(peer.connStatus),
)}
title={peer.connStatus}
/>
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={peer.fqdn}>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{peer.fqdn}
</span>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard message={peer.ip}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
className={cn(
"shrink-0 self-center text-nb-gray-300",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
</li>
);
})}
</ul>
);
};

View File

@@ -1,202 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { NetworkIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { NetworksList } from "./NetworksList";
import { useNetworks } from "./NetworksContext";
// 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[]> => {
const byRange = new Map<string, string[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r.id);
byRange.set(r.range, arr);
}
const out = new Map<string, string[]>();
for (const [range, ids] of byRange) {
if (ids.length > 1) out.set(range, ids);
}
return out;
};
export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
const overlapById = useMemo(() => {
const map = new Map<string, string[]>();
for (const ids of overlapGroups.values()) {
for (const id of ids) map.set(id, ids);
}
return map;
}, [overlapGroups]);
const counts = useMemo<Record<NetworkFilter, number>>(
() => ({
all: networkRoutes.length,
active: networkRoutes.filter((r) => r.selected).length,
overlapping: overlapById.size,
}),
[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 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);
})
.map((r) => r.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [networkRoutes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [ordered, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) {
return (
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={NetworkIcon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
learnMoreTopic={t("nav.resources.title")}
/>
</div>
);
}
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
void setNetworksSelected(
filtered.map((r) => r.id),
false,
);
} else {
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
void setNetworksSelected(ids, true);
}
};
return (
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<NetworksList data={filtered} onToggle={toggleNetwork} />
)}
</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>
{filtered.length > 0 && (
<div
className={cn(
"flex items-center gap-3 px-6 py-3.5",
"border-t border-nb-gray-910",
)}
>
<span className={"flex-1 text-xs font-medium text-nb-gray-300 tabular-nums"}>
{t("networks.bulk.selectionCount", {
selected: selectedInView,
total: filtered.length,
})}
</span>
<button
type={"button"}
onClick={onBulkClick}
className={cn(
"inline-flex items-center h-8 px-3 rounded-md",
"text-xs font-medium text-nb-gray-100",
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
"transition-colors outline-none wails-no-draggable cursor-pointer",
)}
>
{bulkLabel}
</button>
</div>
)}
</div>
);
};

View File

@@ -1,122 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ArrowLeftIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import { PeerDetails } from "./PeerDetails";
import { usePeerDetail } from "./PeerDetailContext";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
ease: [0.32, 0.72, 0, 1],
};
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
type Props = {
transition?: Transition;
};
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status } = 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 fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
return;
}
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// Esc closes the panel.
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
return (
<AnimatePresence>
{selected && (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={transition}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
>
<div
className={cn(
"shrink-0 flex items-center gap-3",
"px-3 h-12 border-b border-nb-gray-910",
)}
>
<button
type={"button"}
onClick={() => setSelected(null)}
aria-label={t("common.close")}
className={cn(
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"transition-colors outline-none cursor-default",
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} />
</button>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus),
)}
title={selected.connStatus}
/>
<span className={"min-w-0 text-sm font-medium text-nb-gray-100 truncate"}>
{selected.fqdn || selected.ip}
</span>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} />
</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>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -1,85 +0,0 @@
import { ChevronRightIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { latencyColor } from "./format";
import { usePeerDetail } from "./PeerDetailContext";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
const { setSelected } = usePeerDetail();
return (
<ul className={"flex flex-col"}>
{data.map((peer) => {
const isConnected = peer.connStatus === "Connected";
return (
<li
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
dotClass(peer.connStatus),
)}
title={peer.connStatus}
/>
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={peer.fqdn}>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{peer.fqdn}
</span>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard message={peer.ip}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
className={cn(
"shrink-0 self-center text-nb-gray-300",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
</li>
);
})}
</ul>
);
};

View File

@@ -1,48 +0,0 @@
# Peers — info missing in PeersList.tsx
`PeersList.tsx` currently shows only: `connStatus` (dot), `fqdn`, `ip`.
`screens/Peers.tsx` additionally surfaces the following fields from `PeerStatus`:
## Row chrome (collapsed)
- `peer.relayed` — Network (relayed, yellow) vs Zap (P2P, green) icon, gated on `connStatus === "Connected"`.
- `peer.rosenpassEnabled` — ShieldCheck icon when true.
- `peer.latencyMs``"{n} ms"` on the right when Connected and > 0.
## Top-level controls
- Filter input — matches against `fqdn`, `ip`, and each entry in `networks`.
- Peer count — `status.peers.length` next to the title.
- Expand/collapse per row (chevron).
## Expanded details panel
- `peer.pubKey` — Public key (mono).
- `peer.lastHandshakeUnix` — Last handshake (relative time).
- `peer.connStatusUpdateUnix` — Status since (relative time).
- `peer.bytesRx` / `peer.bytesTx` — formatted B/KB/MB/GB.
- `peer.localIceCandidateType` + `peer.localIceCandidateEndpoint` — Local candidate.
- `peer.remoteIceCandidateType` + `peer.remoteIceCandidateEndpoint` — Remote candidate.
- `peer.relayAddress` — shown only when `peer.relayed`.
- `peer.networks` — joined list, shown when non-empty.
## `PeerStatus` interface (from `@bindings/services/models.js`)
```ts
interface PeerStatus {
ip: string;
pubKey: string;
connStatus: string; // "Connected" | "Connecting" | "Idle" | ...
connStatusUpdateUnix: number;
relayed: boolean;
localIceCandidateType: string;
remoteIceCandidateType: string;
localIceCandidateEndpoint: string;
remoteIceCandidateEndpoint: string;
fqdn: string;
bytesRx: number;
bytesTx: number;
latencyMs: number;
relayAddress: string;
lastHandshakeUnix: number;
rosenpassEnabled: boolean;
networks: string[];
}
```

View File

@@ -1,9 +1,9 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusCircle } from "lucide-react";
import * as Dialog from "@/components/Dialog";
import { Input } from "@/components/Input";
import { Button } from "@/components/Button";
import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input";
import { Button } from "@/components/buttons/Button";
type Props = {
open: boolean;
@@ -11,7 +11,7 @@ type Props = {
onCreate: (name: string) => void;
};
export const NewProfileModal = ({ open, onOpenChange, onCreate }: Props) => {
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);

View File

@@ -5,11 +5,11 @@ import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react";
import { pickProfileIcon } from "@/components/ProfileAvatar";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import type { Profile } from "@bindings/services/models.js";
import { NewProfileModal } from "@/components/NewProfileModal";
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/modules/profile/ProfileContext";
import { useProfile } from "@/contexts/ProfileContext";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
@@ -165,7 +165,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<NewProfileModal
<ProfileCreationModal
open={newProfileOpen}
onOpenChange={setNewProfileOpen}
onCreate={handleCreateProfile}

View File

@@ -4,20 +4,20 @@ import { Dialogs } from "@wailsio/runtime";
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/Button";
import HelpText from "@/components/HelpText";
import { NewProfileModal } from "@/components/NewProfileModal";
import { pickProfileIcon } from "@/components/ProfileAvatar";
import { Button } from "@/components/buttons/Button";
import HelpText from "@/components/typography/HelpText";
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import i18next from "@/lib/i18n";
import { useProfile } from "@/modules/profile/ProfileContext";
import { useProfile } from "@/contexts/ProfileContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE = "default";
export function SettingsProfiles() {
export function ProfilesTab() {
const { t } = useTranslation();
const {
profiles,
@@ -145,7 +145,7 @@ export function SettingsProfiles() {
</SettingsBottomBar>
</SectionGroup>
<NewProfileModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
</>
);
}

View File

@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Dialogs } from "@wailsio/runtime";
import { ClockIcon } from "lucide-react";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import {
Connection,
@@ -15,7 +15,7 @@ import {
Session,
WindowManager,
} from "@bindings/services";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors.ts";
const DEFAULT_SECONDS = 360;

View File

@@ -2,14 +2,14 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const EVENT_TRIGGER_LOGIN = "trigger-login";
const WINDOW_WIDTH = 360;

View File

@@ -1,98 +0,0 @@
import { useEffect, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { SettingsProfiles } from "@/modules/settings/SettingsProfiles.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx";
// The settings window opens at General by default. Navigation state (e.g. the
// update-available header trigger jumps to About) or a `?tab=` query param
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
// the profile dropdown) override the default. No persistence across opens —
// a user who wants to revisit a deep tab gets there in two clicks.
export const Settings = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const queryTab = searchParams.get("tab");
const navState = location.state as { tab?: string } | null;
const [active, setActive] = useState(
() => navState?.tab ?? queryTab ?? "general",
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
}, [navState?.tab, location.key]);
return (
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
<SettingsNavigationTriggers />
<MainRightSide>
<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"}>
<SettingsProfiles />
</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>
{import.meta.env.DEV && (
<VerticalTabs.Content value={"development"}>
<SettingsDevelopment />
</VerticalTabs.Content>
)}
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</MainRightSide>
</VerticalTabs>
);
};

View File

@@ -4,7 +4,7 @@ import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "luci
import type { LucideIcon } from "lucide-react";
import netbirdFull from "@/assets/logos/netbird-full.svg";
import pkg from "../../../package.json";
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";

View File

@@ -1,12 +1,12 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { System } from "@wailsio/runtime";
import Button from "@/components/Button";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import Button from "@/components/buttons/Button";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
// macOS: the Darwin utun control socket parses the digits after "utun" as the
// unit number, so the daemon (and the CLI's parseInterfaceName in

View File

@@ -1,84 +0,0 @@
import { useEffect, useState } from "react";
import { Events } from "@wailsio/runtime";
import { Button } from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { WindowManager } from "@bindings/services";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
// Cross-window dev override: ClientVersionContext in the main window
// listens for this and replaces daemon-reported update state with the
// toggle values. Resets when the Settings window closes (no persistence
// by design).
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
const PREVIEW_VERSION = "0.65.0";
export function SettingsDevelopment() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [enforced, setEnforced] = useState(false);
useEffect(() => {
void Events.Emit(EVENT_DEV_OVERRIDES, {
updateAvailable,
enforced,
version: PREVIEW_VERSION,
});
}, [updateAvailable, enforced]);
return (
<>
<SectionGroup title={"Auto-update"}>
<FancyToggleSwitch
value={updateAvailable}
onChange={setUpdateAvailable}
label={"Is update available"}
helpText={
"Force the UI to think a new version is available. Reflects in the About card and the header badge."
}
/>
<FancyToggleSwitch
value={enforced}
onChange={setEnforced}
label={"Auto update enabled"}
helpText={
"Force the UI to think management has auto-update enabled. Switches the About card to “Install now”."
}
/>
<div className={"flex flex-col gap-2 items-start pt-2"}>
<Button
variant={"secondary"}
onClick={() =>
WindowManager.OpenInstallProgress(PREVIEW_VERSION).catch(
console.error,
)
}
>
Show updating dialog
</Button>
</div>
</SectionGroup>
<SectionGroup title={"Session windows"}>
<div className={"flex flex-col gap-2 items-start"}>
<Button
variant={"secondary"}
onClick={() =>
WindowManager.OpenSessionExpired().catch(console.error)
}
>
Open Session expired
</Button>
<Button
variant={"secondary"}
onClick={() =>
WindowManager.OpenSessionAboutToExpire(336).catch(
console.error,
)
}
>
Open About to expire (5:36)
</Button>
</div>
</SectionGroup>
</>
);
}

View File

@@ -1,15 +1,15 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { Button } from "@/components/buttons/Button";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
import { LanguagePicker } from "@/modules/settings/LanguagePicker.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
export function SettingsGeneral() {
const { t } = useTranslation();

View File

@@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next";
import { Tooltip } from "@/components/Tooltip.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext.tsx";
import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import {
BoltIcon,
HammerIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
@@ -15,7 +14,7 @@ import {
UserCircleIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
export const SettingsNavigation = () => {
const { t } = useTranslation();
const { updateAvailable } = useClientVersion();
@@ -69,13 +68,6 @@ export const SettingsNavigationTriggers = () => {
title={t("settings.tabs.about")}
adornment={aboutAdornment}
/>
{import.meta.env.DEV && (
<VerticalTabs.Trigger
value={"development"}
icon={HammerIcon}
title={"Development"}
/>
)}
</VerticalTabs.List>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
export function SettingsNetwork() {
const { t } = useTranslation();

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx";
import { 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";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
// The settings window opens at General by default. Navigation state (e.g. the
// update-available header trigger jumps to About) or a `?tab=` query param
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
// the profile dropdown) override the default. No persistence across opens —
// a user who wants to revisit a deep tab gets there in two clicks.
//
// 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 [searchParams] = useSearchParams();
const queryTab = searchParams.get("tab");
const navState = location.state as { tab?: string } | null;
const [active, setActive] = useState(
() => navState?.tab ?? queryTab ?? "general",
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
}, [navState?.tab, location.key]);
return (
<>
<div
className={
"wails-draggable cursor-default select-none h-12 shrink-0"
}
/>
<VerticalTabs
value={active}
onValueChange={setActive}
className={"p-4"}
>
<SettingsNavigation />
<AppRightPanel>
<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.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</AppRightPanel>
</VerticalTabs>
</>
);
};

View File

@@ -1,11 +1,11 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useState } from "react";
export function SettingsSSH() {

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
export function SettingsSecurity() {
const { t } = useTranslation();

View File

@@ -1,6 +1,6 @@
import Skeleton from "react-loading-skeleton";
export const SkeletonSettings = () => {
export const SettingsSkeleton = () => {
return (
<div className={"gap-6 flex flex-col"}>
<div>

View File

@@ -3,18 +3,18 @@ import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "@/components/Button";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import HelpText from "@/components/HelpText.tsx";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon";
import { cn } from "@/lib/cn";
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
import type { DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
export function SettingsTroubleshooting() {

View File

@@ -106,9 +106,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.browserLogin == nil {
startURL := "/#/browser-login"
startURL := "/#/dialog/browser-login"
if uri != "" {
startURL = "/#/browser-login?uri=" + url.QueryEscape(uri)
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.hideOtherWindowsLocked("browser-login")
// Prefer the screen the main window is on so the sign-in popup
@@ -169,7 +169,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
return
}
if uri != "" {
s.browserLogin.SetURL("/#/browser-login?uri=" + url.QueryEscape(uri))
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
}
s.browserLogin.Show()
s.browserLogin.Focus()
@@ -261,7 +261,7 @@ func (s *WindowManager) OpenSessionExpired() {
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: application.NewRGB(24, 26, 29),
URL: "/#/session-expired",
URL: "/#/dialog/session-expired",
Mac: application.MacWindow{
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropTranslucent,
@@ -302,7 +302,7 @@ func (s *WindowManager) CloseSessionExpired() {
func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
if s.sessionAboutToExpire == nil {
s.sessionAboutToExpire = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "session-about-to-expire",
@@ -364,9 +364,9 @@ func (s *WindowManager) CloseSessionAboutToExpire() {
func (s *WindowManager) OpenInstallProgress(version string) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/install-progress"
startURL := "/#/dialog/install-progress"
if version != "" {
startURL = "/#/install-progress?version=" + url.QueryEscape(version)
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
}
if s.installProgress == nil {
s.hideOtherWindowsLocked("install-progress")