mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 21:59:56 +00:00
556 lines
34 KiB
Markdown
556 lines
34 KiB
Markdown
# NetBird Wails UI — Frontend Working Notes
|
||
|
||
This is the React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus two auxiliary windows (`/#/settings` and `/#/browser-login`) opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||
|
||
> **Work in progress.** Big chunks of the UI are still mocked, prototyped, or duplicated across screens that pre-date the current AppLayout. Anything marked "prototype" / "mocked" / "legacy" below should be assumed half-wired. The polished surface today is: the main connect toggle, the Settings window, the debug-bundle flow, the auto-update overlay, and the profile selector. Everything else is in flight.
|
||
|
||
## Stack
|
||
|
||
- **React 18** with `react-dom/client` + `<React.StrictMode>` (app.tsx).
|
||
- **TypeScript 5.7**, `"strict": true`, `noUnusedLocals: true`, `noImplicitAny: false`, `jsx: react-jsx`.
|
||
- **Vite 6** + `@vitejs/plugin-react`. Wails ships its own Vite plugin (`@wailsio/runtime/plugins/vite`) that's wired in for binding regen / runtime injection.
|
||
- **React Router v7** (`HashRouter` — Wails serves a static bundle so hash-based routing avoids server-side fallback).
|
||
- **Tailwind CSS 3** with `darkMode: "class"`. Class-merging via `cn(...inputs)` (`src/lib/cn.ts` — `twMerge(clsx(inputs))`).
|
||
- **Radix UI primitives** for Dialog / DropdownMenu / Popover / RadioGroup / ScrollArea / Switch / Tabs / Tooltip / VisuallyHidden / Label.
|
||
- **framer-motion** for the central connect-toggle animation only.
|
||
- **lucide-react** for icons. **chroma-js** for the deterministic-color helper. **cmdk** is installed but not currently used.
|
||
- **`@wailsio/runtime`** for `Dialogs`, `Events`, `Browser`, `Window` APIs.
|
||
- **Package manager: pnpm** (`pnpm-lock.yaml`). No `package-lock.json` / `yarn.lock`.
|
||
|
||
Scripts (`package.json`):
|
||
|
||
```
|
||
pnpm dev # vite dev server (port 9245, host 127.0.0.1)
|
||
pnpm build:dev # tsc + vite build, mode=development, --minify false
|
||
pnpm build # tsc + vite build, mode=production
|
||
pnpm preview # vite preview
|
||
pnpm typecheck # tsc --noEmit
|
||
pnpm format # prettier write on src/**
|
||
pnpm format:check
|
||
```
|
||
|
||
`task dev` from `client/ui/` starts the Wails dev harness, which in turn runs `vite` (port `WAILS_VITE_PORT || 9245`).
|
||
|
||
## Path aliases
|
||
|
||
`tsconfig.json` and `vite.config.ts` agree on two aliases:
|
||
|
||
| Alias | Resolves to |
|
||
|---|---|
|
||
| `@/*` | `src/*` |
|
||
| `@bindings/*` | `bindings/github.com/netbirdio/netbird/client/ui/*` |
|
||
|
||
So `import { Connection } from "@bindings/services"` and `import type { Status } from "@bindings/services/models.js"` are the canonical imports. **Don't** hand-write deep `../../bindings/github.com/...` paths — a few legacy screens (`screens/Profiles.tsx`, `pages/Update.tsx`) still do; treat that as a smell.
|
||
|
||
`bindings/` is **gitignored** — every file is generated and never hand-edited. Regenerate from `client/ui/` via `wails3 generate bindings -clean=true -ts`, or use the `pnpm bindings` shortcut from this directory. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` will fail until you run it once; `wails3 dev` regenerates on its own.
|
||
|
||
## Routing (app.tsx)
|
||
|
||
`HashRouter` with the following routes:
|
||
|
||
| Path | Component | Layout | Where it opens |
|
||
|---|---|---|---|
|
||
| `/` | `Main` | `AppLayout` | Main window default route |
|
||
| `/quick` | `QuickActions` | none | Standalone — **prototype**, not currently invoked by the Go side |
|
||
| `/browser-login` | `BrowserLogin` | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
|
||
| `/update` | `Update` (pages) | none | Main window during enforced-update install |
|
||
| `/session-expired` | `SessionExpired` | none | Standalone — **prototype**, no buttons wired |
|
||
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) |
|
||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||
|
||
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `ProfileProvider → DebugBundleProvider → ClientVersionProvider`. The order matters — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints the `<UpdatingOverlay/>` so it has to be outermost in terms of z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`).
|
||
|
||
`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.
|
||
|
||
## Directory layout (src/)
|
||
|
||
```
|
||
app.tsx # entry, routes, <SkeletonTheme>, welcome() console banner
|
||
globals.css # Tailwind layers + custom CSS variables
|
||
vite-env.d.ts
|
||
|
||
assets/ # flags/, fonts/, logos/ (svg)
|
||
components/ # presentational primitives — see "Components" below
|
||
hooks/ # useStatus.ts (currently the only hook here)
|
||
layouts/ # AppLayout, SettingsLayout, Header, Main, MainRightSide,
|
||
# Navigation, ConnectionStatus, ConnectionStatusSwitch
|
||
lib/ # cn (tailwind merge), color (hash → hex), welcome (console art),
|
||
# MainModuleContext (unused legacy)
|
||
modules/ # feature folders that own their own contexts/state
|
||
auto-update/ # ClientVersionContext + overlays/banners/badges
|
||
debug-bundle/ # useDebugBundle hook + Provider wrapper
|
||
peers/ # Peers UI (currently mockPeers; not wired to daemon data)
|
||
profile/ # ProfileContext
|
||
settings/ # Settings root + per-tab files + SettingsContext
|
||
skeletons/ # SkeletonSettings
|
||
pages/ # full-screen single-purpose pages routed via app.tsx
|
||
BrowserLogin.tsx # auxiliary window content
|
||
SessionExpired.tsx # prototype, no wiring
|
||
Update.tsx # enforced-update install screen (real one)
|
||
Debug.tsx # legacy debug bundle UI, superseded by SettingsTroubleshooting
|
||
screens/ # in-window screens (mostly legacy; pre-AppLayout era)
|
||
Peers.tsx # legacy peer-detail UI (uses real Peers.Get data)
|
||
Networks.tsx # legacy networks UI
|
||
Profiles.tsx # uses ProfileSwitcher.SwitchActive (current preferred path)
|
||
Settings.tsx # legacy — superseded by modules/settings/Settings.tsx
|
||
Update.tsx # legacy update page (different from pages/Update.tsx)
|
||
QuickActions.tsx # legacy quick-action panel
|
||
Debug.tsx # legacy
|
||
```
|
||
|
||
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. Today: `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell).
|
||
|
||
## Generated bindings
|
||
|
||
Re-exported from `@bindings/services/index.ts`:
|
||
|
||
```ts
|
||
import {
|
||
Connection, Debug, Forwarding, Networks, Peers,
|
||
ProfileSwitcher, Profiles, Settings, Update, WindowManager,
|
||
} from "@bindings/services";
|
||
|
||
import type {
|
||
ActiveProfile, Config, ConfigParams, DebugBundleParams, DebugBundleResult,
|
||
Features, ForwardingRule, LocalPeer, LogLevel, LoginParams, LoginResult,
|
||
LogoutParams, Network, PeerLink, PeerStatus, PortInfo, PortRange, Profile,
|
||
ProfileRef, SelectNetworksParams, SetConfigParams, Status, SystemEvent,
|
||
UpParams, UpdateAvailable, UpdateProgress, UpdateResult, WaitSSOParams,
|
||
} from "@bindings/services/models.js";
|
||
```
|
||
|
||
Every service method returns a `$CancellablePromise<T>` (Wails3 wrapper) — call `.cancel()` to abort the underlying gRPC call. In practice we `await` them and never call `.cancel()`; the few stream-driven cases use `AbortController` (see `useDebugBundle`).
|
||
|
||
## Wails event bus
|
||
|
||
Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed payload> }`. The event name strings live next to their usage (no central registry on the TS side).
|
||
|
||
| Event name (string) | Payload | Emitted by | Consumed by |
|
||
|---|---|---|---|
|
||
| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `hooks/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: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 | Same — drives the tray; Go side opens the `/update` route. |
|
||
| `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`); not currently used by the frontend |
|
||
|
||
If you wire a new daemon-event subscriber on the TS side, prefer subscribing once at the context level rather than per-screen — the Wails event bus is process-wide and each `Events.On` adds an emit-time fan-out.
|
||
|
||
## Contexts and state
|
||
|
||
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
|
||
|
||
### `useStatus` (hooks/useStatus.ts)
|
||
|
||
Returns `{ status, error, refresh }`. Fetches `Peers.Get()` once, then re-renders on every `netbird:status` push. `refresh()` is for forcing a re-read after a user action (Connect / Disconnect) so the UI doesn't lag the event stream by a few hundred ms.
|
||
|
||
### `ProfileContext` (modules/profile/ProfileContext.tsx)
|
||
|
||
Single source of truth for `username`, `activeProfile`, `profiles`. Exposes `refresh`, `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`.
|
||
|
||
**Caveat:** `ProfileContext.switchProfile` implements the reconnect policy in TS (Switch + conditional Down/Up gated on previous Connected/Connecting). The Go-side `ProfileSwitcher.SwitchActive` does the same thing **plus** drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. Prefer `ProfileSwitcher.SwitchActive` for new call sites — `screens/Profiles.tsx` already does. The duplicate logic in `ProfileContext` is on the cleanup list.
|
||
|
||
### `SettingsContext` (modules/settings/SettingsContext.tsx)
|
||
|
||
Loads `SettingsSvc.GetConfig` for the active profile, then debounces every `setField` write (`SAVE_DEBOUNCE_MS = 400`). API:
|
||
|
||
- `setField(k, v)` — optimistic update + debounced save. Use for toggles.
|
||
- `saveField(k, v)` — flush pending + save immediately. Use for explicit Save buttons.
|
||
- `saveFields(partial)` — same as `saveField` but for multiple keys at once (used by the Advanced tab's batched save).
|
||
- `saveNow()` — flush pending without changing values.
|
||
|
||
While `config` is `null` the provider renders `<SkeletonSettings/>` instead of children — the actual tabs never need to handle a null config.
|
||
|
||
**PSK mask quirk:** The daemon returns existing pre-shared keys as `"**********"` in `GetConfig`. Sending the mask back round-trips it into the saved config and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"` so an unrelated toggle save doesn't corrupt the stored PSK.
|
||
|
||
### Wide/narrow panel state
|
||
|
||
There is no appearance context or localStorage. The `expanded` flag lives in `AppLayout` as plain `useState(false)` and is the only shell-layout knob. `Header.tsx` reads it via props (sets the panel-toggle icon and calls `Window.SetSize(925|380, 615)` on change); `Main.tsx` reads it via Outlet context (`MainOutletContext`) to decide whether to mount the right-side panel. Every app launch starts small — no cross-machine drift.
|
||
|
||
Nav-item visibility (Peers / Resources / Exit Node) and the header buttons (profile selector, settings) are hardcoded to always-render in `Navigation.tsx` and `Header.tsx` respectively; the previous toggles are gone along with the Appearance settings tab.
|
||
|
||
### `DebugBundleProvider` + `useDebugBundle` (modules/debug-bundle/)
|
||
|
||
Stateful hook driving the debug-bundle flow. Wrapped in a context so the troubleshooting tab inside the Settings window keeps the same stage if the user navigates away and back. Stages:
|
||
|
||
```
|
||
idle → preparing-trace → reconnecting → capturing (per-second countdown) →
|
||
restoring-level → bundling → uploading → done
|
||
```
|
||
|
||
Cancellable via `AbortController` from any stage. On cancel the original log level is restored best-effort. `NETBIRD_UPLOAD_URL = https://upload.debug.netbird.io/upload-url` is hardcoded.
|
||
|
||
### `ClientVersionContext` (modules/auto-update/ClientVersionContext.tsx)
|
||
|
||
Reads `Status.events`, finds the most recent event whose metadata carries `new_version_available`, and exposes `{ updateAvailable, updateVersion, triggerUpdate, updating, updateError, dismissUpdateError }`. Mounts `<UpdateAvailableBanner/>` and the `<UpdatingOverlay/>` so any screen inherits the overlay without opting in.
|
||
|
||
**Dev preview flags at the top of the file** (flip and save to preview UI states without involving the daemon):
|
||
|
||
```ts
|
||
const FORCE_UPDATE_AVAILABLE = true; // currently TRUE — banner is forced on
|
||
const FORCE_UPDATING = false;
|
||
const FORCE_VERSION = "0.65.0";
|
||
const HIDE_UPDATE_AVAILABLE = false; // hard-hide everything regardless of state
|
||
const FORCE_ERROR: ForceError = null; // "timeout" | "cancel" | "fail" | null
|
||
const FORCE_ERROR_MSG = "installer exited with code 1";
|
||
```
|
||
|
||
`FORCE_UPDATE_AVAILABLE = true` means the banner shows in production builds too right now. Flip it back to `false` before a real release. `UpdateAvailableBanner` additionally returns null in `import.meta.env.DEV` to avoid noise during `pnpm dev`.
|
||
|
||
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
|
||
|
||
The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
|
||
|
||
1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user.
|
||
2. If the daemon needs SSO (`needsSsoLogin`):
|
||
- `Connection.OpenURL(uri)` opens the verification page in the system browser (honors `$BROWSER`).
|
||
- `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window.
|
||
- `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.
|
||
|
||
Errors that aren't cancellations surface via `Dialogs.Error`.
|
||
|
||
This is the only SSO entry point used by the polished Main UI. Legacy screens (`screens/Status.tsx`, `screens/Profiles.tsx`) link to a `/login` route that **does not exist** in `app.tsx` today — those navigations will fall through the `*` catch-all to `/`. Those screens are not part of the live route table, so it doesn't bite users, but don't add a new `useNavigate("/login")` without first wiring an actual route.
|
||
|
||
## Components
|
||
|
||
`src/components/` holds presentational primitives (no daemon RPCs, no router):
|
||
|
||
- **Form / interactive:** `Button`, `IconButton`, `Input` (label + help text + reveal toggle + suffix + readonly + copy slot), `Switch`, `ToggleSwitch`, `FancyToggleSwitch` (label + helpText + value), `Label`, `HelpText`, `SearchInput`, `Tabs`, `VerticalTabs`, `CardSelect`, `CardNavItem`, `Card`.
|
||
- **Layout / overlays:** `Dialog` (Radix wrapper with `Root/Trigger/Content/Title/Description/Footer`), `BottomSheet`, `Tooltip`, `StatusPanel`.
|
||
- **Domain-specific:** `NetBirdConnectToggle` (the big animated brand circle — framer-motion + tailwind keyframes), `ProfileSelector`, `NewProfileDialog`, `Avatar`.
|
||
|
||
Settings rows mostly use `FancyToggleSwitch` inside `<SectionGroup title=…>`. Section group dimming is handled with `disabled` (greyed + `pointer-events-none`).
|
||
|
||
In-app modals (NewProfileDialog, the delete-profile confirm in `screens/Profiles.tsx`) use the Radix `Dialog` primitive inside the main webview. The two auxiliary OS windows (Settings, BrowserLogin) are created by Go via `WindowManager`, not by frontend code.
|
||
|
||
## Dialogs convention (recap)
|
||
|
||
Errors surface via `Dialogs.Error` from `@wailsio/runtime` with an action-named title:
|
||
|
||
```ts
|
||
await Dialogs.Error({
|
||
Title: "Save Settings Failed", // not "Error"
|
||
Message: e instanceof Error ? e.message : String(e),
|
||
});
|
||
```
|
||
|
||
Confirmations use `Dialogs.Warning` with explicit `Buttons` and compare against the **Label string**, not an index:
|
||
|
||
```ts
|
||
const r = await Dialogs.Warning({
|
||
Title: "Delete Profile",
|
||
Message: `Delete "${name}"?`,
|
||
Buttons: [{ Label: "Cancel", IsCancel: true }, { Label: "Delete", IsDefault: true }],
|
||
});
|
||
if (r !== "Delete") return;
|
||
```
|
||
|
||
When **not** to use native dialogs: inline form validation, transient link errors on the dashboard, "partial success" notes inside an otherwise-OK flow. See `../CLAUDE.md` for the full rules. The settings management-URL switch is a good example: `useManagementUrl` shows inline URL-format errors but throws up a `Dialogs.Warning` confirmation when the user is about to flip from self-hosted to NetBird Cloud (because that forces a reconnect/re-login).
|
||
|
||
## Tailwind tokens
|
||
|
||
Custom colors in `tailwind.config.ts`:
|
||
|
||
- `nb-gray` — main neutral palette, 50–960. `nb-gray-950` (#181a1d) is the app background; tabs/cards step through 900/910/920/925/935/940 as you go deeper. `DEFAULT` is `nb-gray-950`.
|
||
- `netbird` — brand orange. `DEFAULT` is `#f68330` (500-ish). Used for primary buttons, focused tab borders, the connect-toggle border ring.
|
||
- `gray`, `red`, `yellow`, `green`, `blue`, `indigo`, `purple`, `pink` — Flowbite-style 50–900 palettes used in the legacy screens (`screens/*`). Avoid in new code — stick to `nb-gray` + `netbird` + the semantic dot colors (`green-500`, `red-500`, `yellow-500`).
|
||
|
||
Background image `bg-conic-netbird` and keyframes `pulse-reverse` / `spin-slow` / `ping-slow` (plus the animations `animate-pulse-slow`, `animate-pulse-slower`, `animate-spin-slow`, `animate-ping-slow`) are used by `NetBirdConnectToggle`.
|
||
|
||
Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono) — both shipped under `src/assets/fonts/`.
|
||
|
||
## Wails-specific quirks
|
||
|
||
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, the UpdatingOverlay). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||
- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod.
|
||
- **`Window.SetSize(w, h)`.** Called from `Header.tsx` to switch between 380-wide and 925-wide layouts. There's a one-time initial sync on mount so localStorage's `expanded` flag wins over the Go-side default of 925.
|
||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails).
|
||
|
||
## Things in flight (don't be surprised by)
|
||
|
||
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `MainRightSide` and is what the user sees today; the real-data one isn't wired into the route table.
|
||
- **`screens/Profiles.tsx`** still imports bindings via the deep relative path. It's the example of the preferred `ProfileSwitcher.SwitchActive` flow but otherwise pre-AppLayout.
|
||
- **`pages/Debug.tsx`** is the legacy debug-bundle screen. The polished flow is in `modules/settings/SettingsTroubleshooting.tsx` (via `useDebugBundle`). `pages/Debug.tsx` isn't currently routed.
|
||
- **`pages/Update.tsx`** and **`screens/Update.tsx`** are two different update pages. The route table points at `pages/Update.tsx` (the production one with the 15-minute timeout, daemon-down-grace, and error-mapping). The `screens/Update.tsx` is an older simpler variant.
|
||
- **`pages/SessionExpired.tsx`** is fully rendered but the Sign-in / Later buttons have no onClick handlers yet.
|
||
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
|
||
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
|
||
- **`lib/MainModuleContext.tsx`** is exported but unused. Candidate for deletion.
|
||
|
||
## Wails Go API reference
|
||
|
||
Quick reference for every binding method and model shape exposed to the frontend. Generated from `services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
|
||
|
||
Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
|
||
|
||
### Imports
|
||
|
||
```ts
|
||
// Services
|
||
import {
|
||
Connection, Peers, ProfileSwitcher, Profiles,
|
||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||
} from "@bindings/services";
|
||
|
||
// Models (types-only)
|
||
import type {
|
||
Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
|
||
Profile, ProfileRef, ActiveProfile,
|
||
Config, ConfigParams, SetConfigParams, Features,
|
||
Network, SelectNetworksParams,
|
||
ForwardingRule, PortInfo, PortRange,
|
||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||
} from "@bindings/services/models.js";
|
||
```
|
||
|
||
### Push events
|
||
|
||
Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: <payload> }`.
|
||
|
||
| Event | Payload | Fires on |
|
||
|---|---|---|
|
||
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
|
||
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
|
||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
|
||
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
|
||
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
|
||
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow; not currently wired on the TS side. |
|
||
|
||
The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
|
||
|
||
### `Connection`
|
||
|
||
```ts
|
||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email
|
||
Connection.Up(p: UpParams): Promise<void> // async on the daemon
|
||
Connection.Down(): Promise<void>
|
||
Connection.Logout(p: LogoutParams): Promise<void>
|
||
Connection.OpenURL(url: string): Promise<void> // honors $BROWSER
|
||
```
|
||
|
||
`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
|
||
|
||
Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
|
||
|
||
### `Peers`
|
||
|
||
```ts
|
||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||
Peers.Watch(): Promise<void> // already invoked from main.go
|
||
Peers.BeginProfileSwitch(): Promise<void>
|
||
Peers.CancelProfileSwitch(): Promise<void>
|
||
```
|
||
|
||
`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
|
||
|
||
### `ProfileSwitcher`
|
||
|
||
```ts
|
||
ProfileSwitcher.SwitchActive(p: ProfileRef): Promise<void>
|
||
```
|
||
|
||
The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
|
||
|
||
Reconnect policy (driven by `prevStatus` captured at entry):
|
||
|
||
| Previous status | Action | Optimistic UI | Suppressed events until new flow |
|
||
|---|---|---|---|
|
||
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||
| Idle | Switch only | (no change) | — |
|
||
|
||
### `Profiles`
|
||
|
||
```ts
|
||
Profiles.Username(): Promise<string> // current OS username
|
||
Profiles.List(username: string): Promise<Profile[]>
|
||
Profiles.GetActive(): Promise<ActiveProfile>
|
||
Profiles.Switch(p: ProfileRef): Promise<void> // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
|
||
Profiles.Add(p: ProfileRef): Promise<void>
|
||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||
```
|
||
|
||
`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/<name>.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
|
||
|
||
### `Settings`
|
||
|
||
```ts
|
||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||
Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||
```
|
||
|
||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
||
|
||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||
|
||
`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
|
||
|
||
### `Networks`
|
||
|
||
```ts
|
||
Networks.List(): Promise<Network[]>
|
||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||
```
|
||
|
||
`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
|
||
|
||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||
|
||
### `Forwarding`
|
||
|
||
```ts
|
||
Forwarding.List(): Promise<ForwardingRule[]>
|
||
```
|
||
|
||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||
|
||
### `Debug`
|
||
|
||
```ts
|
||
Debug.GetLogLevel(): Promise<LogLevel>
|
||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||
Debug.RevealFile(path: string): Promise<void> // OS file-manager focus
|
||
```
|
||
|
||
**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
|
||
|
||
`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
|
||
|
||
### `Update`
|
||
|
||
```ts
|
||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the outcome (long-running)
|
||
Update.Quit(): Promise<void> // 100ms later, app.Quit()
|
||
```
|
||
|
||
Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
|
||
|
||
### `WindowManager`
|
||
|
||
```ts
|
||
WindowManager.OpenSettings(): Promise<void>
|
||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||
```
|
||
|
||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||
|
||
### Daemon `Status.status` values
|
||
|
||
Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
|
||
|
||
| Value | Meaning |
|
||
|---|---|
|
||
| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
|
||
| `"Connecting"` | Up in progress |
|
||
| `"Connected"` | Tunnel up |
|
||
| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
|
||
| `"LoginFailed"` | Previous Login attempt errored |
|
||
| `"SessionExpired"` | SSO token expired; needs re-Login |
|
||
| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
|
||
|
||
The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
|
||
|
||
### Model field reference
|
||
|
||
`Status`:
|
||
```ts
|
||
{ status, daemonVersion: string;
|
||
management: PeerLink; signal: PeerLink;
|
||
local: LocalPeer;
|
||
peers: PeerStatus[];
|
||
events: SystemEvent[]; }
|
||
```
|
||
|
||
`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
|
||
|
||
`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
|
||
|
||
`PeerStatus`:
|
||
```ts
|
||
{ ip, pubKey, fqdn, connStatus: string;
|
||
connStatusUpdateUnix: number;
|
||
relayed: boolean;
|
||
localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
|
||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
|
||
bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
|
||
relayAddress: string; // set when relayed=true
|
||
rosenpassEnabled: boolean;
|
||
networks: string[]; }
|
||
```
|
||
|
||
`SystemEvent`:
|
||
```ts
|
||
{ id: string;
|
||
severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
|
||
category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
|
||
message: string; // technical / log line
|
||
userMessage: string; // human-friendly — render this
|
||
timestamp: number; // unix seconds
|
||
metadata: Record<string, string>; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
|
||
```
|
||
|
||
`Profile`: `{ name: string; isActive: boolean; email: string }`.
|
||
|
||
`Config` (read-only mirror, all required):
|
||
```ts
|
||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||
disableAutoConnect, serverSshAllowed,
|
||
rosenpassEnabled, rosenpassPermissive,
|
||
disableNotifications, lazyConnectionEnabled, blockInbound,
|
||
networkMonitor, disableClientRoutes, disableServerRoutes,
|
||
disableDns, disableIpv6, blockLanAccess,
|
||
enableSshRoot, enableSshSftp,
|
||
enableSshLocalPortForwarding, enableSshRemotePortForwarding,
|
||
disableSshAuth: boolean; }
|
||
```
|
||
|
||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
||
|
||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||
|
||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||
|
||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||
|
||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||
|
||
`PortRange`: `{ start, end: number }` (inclusive).
|
||
|
||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||
|
||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||
|
||
`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
|
||
|
||
`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
|
||
|
||
`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
|
||
|
||
`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
|
||
|
||
`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
|
||
|
||
`UpdateResult`: `{ success: boolean; errorMsg: string }`.
|
||
|
||
`UpdateAvailable`: `{ version: string; enforced: boolean }`.
|
||
|
||
`UpdateProgress`: `{ action: string; version: string }`.
|
||
|
||
## Useful references
|
||
|
||
- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||
- Wails v3 docs (may 403 from some clients): https://v3.wails.io/
|
||
- `../CLAUDE.md` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals.
|