25 KiB
NetBird Wails UI — Frontend Working Notes
The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (services/windowmanager.go). For Go-side conventions and the daemon gRPC layer see ../CLAUDE.md.
Keep these notes current. Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase.
Stack & tooling
React 18 + TS 5.7 (strict, noImplicitAny: false) + Vite 6 + Tailwind 3 (darkMode: "class") + Radix primitives + i18next + @wailsio/runtime. React Router v7 HashRouter (Wails serves a static bundle). pnpm only — package.json is authoritative for deps and scripts. Class merging: cn(...) in src/lib/cn.ts. framer-motion is used only by the connect toggle. task dev from client/ui/ is the canonical dev entry point — it runs Vite on WAILS_VITE_PORT || 9245.
Path aliases & bindings
@/* → src/*, @bindings/* → bindings/github.com/netbirdio/netbird/client/ui/* (set in both tsconfig.json and vite.config.ts). Canonical imports: from "@bindings/services" (functions) and from "@bindings/services/models.js" (types).
bindings/ is gitignored and fully generated. A fresh clone has no bindings/ on disk, so pnpm typecheck fails until you run pnpm bindings (or wails3 generate bindings -clean=true -ts from client/ui/) once. wails3 dev regenerates on its own.
Routing (app.tsx)
HashRouter. Dialog routes are grouped under a parent <Route path="dialog"> (URL grouping only, no shared layout); the two in-window routes sit under <AppLayout>. The Go side mirrors the prefix — WindowManager opens windows at /#/dialog/<name>.
| Path | Component (module) | Layout | Window |
|---|---|---|---|
/ |
MainPage (modules/main/) |
AppLayout |
Main window |
/settings |
SettingsPage (modules/settings/) |
AppLayout |
Settings auxiliary window |
/dialog/browser-login |
LoginWaitingForBrowserDialog (modules/login/) |
none | SSO browser-wait, always-on-top |
/dialog/install-progress |
UpdateInProgressDialog (modules/auto-update/) |
none | Install progress, always-on-top |
/dialog/session-expiration |
SessionExpirationDialog (modules/session/) |
none | Session expiry warning, always-on-top |
/dialog/welcome |
WelcomeDialog (modules/welcome/) |
none | First-launch onboarding |
/dialog/error |
ErrorDialog (modules/error/) |
none | App's single error surface, always-on-top |
* |
<Navigate to="/"> |
AppLayout |
Catch-all |
Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in services/windowmanager.go — see ../CLAUDE.md. Frontend-relevant notes per window:
- Settings — opened via
WindowManager.OpenSettings(tab). The window stays at/#/settingsfor its whole lifetime (noSetURLbetween opens, soAppLayout's providers never remount). Active tab is React local state inSettingsPage, set from thenetbird:settings:openevent Go emits beforeShow. Reset-to-General on close is driven in React by adocument.visibilitychangelistener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which racesHideand flashes the previous tab for one frame). - install-progress — owns the install-result polling + 5s daemon-down-grace, calls
Update.Quit()on success. Opened byClientVersionContext.triggerUpdate(user-driven enforced branch) and on theinstallingflip fromnetbird:update:state(force-install branch). - session-expiration —
?seconds=drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emittrigger-login; Logout callsConnection.Logout. - welcome — opened from Go's
ApplicationStartedhook only whenprefStore.Get().OnboardingCompletedis false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, seeshouldShowManagementStep). Continue callsPreferences.SetOnboardingCompleted(true), thenWindowManager.OpenMain(), thenWindowManager.CloseWelcome(). - error —
errorDialog({Title, Message})inlib/errors.tsopens this (not a native OS box).titleis the window chrome title (set Go-side, not in the body);messageis read fromuseSearchParamsand rendered next to a dangerSquareIcon, with a Close button (Escape also closes →WindowManager.CloseError()).
Layouts
AppLayout is the only router-level layout. It mounts the shared provider stack and renders <Outlet/>:
DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider
DialogProvideris outermost (and outside the daemon gate) souseConfirm()works regardless of daemon state.StatusProviderowns the singleDaemonFeed.Get+netbird:statussubscription and only renders its children when the daemon is reachable — otherwise it short-circuits to<DaemonUnavailableOverlay/>. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return.- Order matters:
SettingsContext(mounted inSettingsPage) depends onProfileContext;ClientVersionContextreadsStatusContextevents.
AppRightPanel (in layouts/) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it).
Page-specific chrome and providers live in the page, not the layout:
MainPage(main window only) mountsViewModeProvider(wraps the whole page — bothMainHeaderandMainBodyread view mode; it callsWindow.SetSize, so it must not be visible to the Settings window),NetworksProvider, andPeerDetailProvider.NavSectionProvideris mounted only inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer ofuseNavSection.SettingsPageowns thewails-draggablestrip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs.
Directory layout (src/)
app.tsx— root render + route table. The canonical registry of every route. Also wires init-time bootstrap (initLogForwarding,welcome,initI18n,initPlatform) before first render.layouts/—AppLayout.tsx(the only router-level layout) andAppRightPanel.tsx(shared content-panel shell).modules/<feature>/— each feature owns its folder: a*Page.tsxentry where applicable, plus its local components.main/—MainPage.tsx,MainHeader.tsx,MainConnectionStatusSwitch.tsx(connect toggle + thestartLoginSSO orchestrator),MainExitNodeSwitcher.tsx.main/advanced/— advanced-mode-only surfaces:Navigation.tsx(Peers/Networks tab switch) pluspeers/(Peers.tsx,PeerDetailPanel.tsx,PeerFilters.tsx) andnetworks/(Networks.tsx,NetworkFilters.tsx). There is no exit-nodes sub-module — exit-node state lives inNetworksContextand the UI isMainExitNodeSwitcher(shown in default mode too).
settings/—SettingsPage.tsx,SettingsNavigation.tsx,SettingsSection.tsx,SettingsSkeleton.tsx, and the tab files flat (SettingsGeneral,SettingsNetwork,SettingsSecurity,SettingsSSH,SettingsAdvanced,SettingsTroubleshooting,SettingsAbout,SettingsAccent). The Profiles tab ismodules/profiles/ProfilesTab.tsx.profiles/—ProfileDropdown.tsx(header),ProfileCreationModal.tsx,ProfilesTab.tsx(settings table),ProfileAvatar.tsx. Context incontexts/ProfileContext.tsx. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusingManagementServerSwitch+useManagementUrl);ProfilesTab.handleCreateadds the profile,Settings.SetConfigs themanagementUrlonto it (keyed by profile name, before switching), then switches. Row actions confirm viauseConfirm().welcome/—WelcomeDialog.tsx(orchestrator) +WelcomeStepTray.tsx,WelcomeStepManagement.tsx. The management step renders only when active profile is"default", the profile email is empty, and the management URL is cloud-default-or-empty (shouldShowManagementStep). Self-hosted URL reachability is a soft warning (useManagementUrl.checkManagementUrlReachable) — the user can re-click Continue to proceed past a failed check.login/—LoginWaitingForBrowserDialog.tsx(SSO browser-wait window).session/—SessionExpirationDialog.tsx.auto-update/—UpdateInProgressDialog.tsx,UpdateBadge.tsx,UpdateVersionCard.tsx. Context incontexts/ClientVersionContext.tsx.error/—ErrorDialog.tsx.
contexts/— every React context as a flat file:StatusContext,ProfileContext,DebugBundleContext,ClientVersionContext,SettingsContext,MdmContext,NetworksContext,PeerDetailContext,ViewModeContext,NavSectionContext,DialogContext. Mental model: "where is the X context?contexts/XContext.tsx."components/— presentational primitives, no daemon RPCs, no router:buttons/—Button,IconButton.inputs/—Input,SearchInput.dialog/—Dialog,DialogActions,DialogDescription,DialogHeading,ConfirmDialog(window-based dialog layout primitive),ConfirmModal(in-app Radix confirmation, usually driven viauseConfirm()).switches/—SwitchItem,SwitchItemGroup,ToggleSwitch,FancyToggleSwitch.typography/—Label,HelpText.empty-state/—EmptyState,NoResults,NotConnectedState,DaemonUnavailableOverlay.- Flat at root:
Badge,CopyToClipboard,DropdownMenu,SquareIcon,Tooltip,TruncatedText,VerticalTabs,LanguagePicker,ManagementServerSwitch.
hooks/—useAutoSizeWindow.ts(auto-size +Window.Showfor auxiliary dialogs),useKeyboardShortcut.ts,useManagementUrl.ts(management-URL helpers:CLOUD_MANAGEMENT_URL,isValidManagementUrl,normalizeManagementUrl,isNetbirdCloud,checkManagementUrlReachable).lib/— pure utilities (no JSX, no React state):cn.ts,errors.ts(formatErrorMessage+ theerrorDialog({Title, Message})window wrapper),formatters.ts(byte/latency/relative-time +shortenDns),sorting.ts(reconcileOrder— order-preserving list reconciliation shared by the peers/networks/profiles lists),i18n.ts,logs.ts(forwards console + uncaught errors to the Go log pipeline),platform.ts(isMacOS/isWindows),welcome.ts.assets/— fonts, logos, flags.
Wails event bus
Subscribe with Events.On(name, handler); the handler receives { data: <typed payload> }. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each Events.On adds an emit-time fan-out.
| Event name | Payload | Emitted by | Consumed by |
|---|---|---|---|
netbird:status |
Status |
services/peers.go |
StatusContext (the only subscriber) |
netbird:profile:changed |
ProfileRef |
services/profileswitcher.go SwitchActive |
ProfileContext — refreshes so a tray-initiated switch paints in the UI |
netbird:update:state |
UpdateState |
services/peers.go fanOutUpdateEvents + the updater's progress_window:show translator |
ClientVersionContext — single source of truth for updateAvailable / version / enforced / installing |
netbird:settings:open |
string (tab id) |
services/windowmanager.go OpenSettings (before Show) |
SettingsPage — setActive(e.data). Reset-on-close is the visibilitychange listener, not this event. |
netbird:preferences:changed |
{ language } |
Go after SetLanguage / SetViewMode |
lib/i18n.ts — calls i18next.changeLanguage so a flip from any window paints everywhere |
browser-login:cancel |
(none) | LoginWaitingForBrowserDialog Cancel button or Go on window close |
MainConnectionStatusSwitch's startLogin() to abort the in-flight WaitSSOLogin |
trigger-login |
(none) | services.EventTriggerLogin (reserved; no Go emitter today) |
MainConnectionStatusSwitch subscribes and runs startLogin() |
netbird:event, netbird:update:available, and netbird:update:progress are emitted Go-side for the tray but not subscribed on the TS side — the UI derives the same info from useStatus().status.events.
Contexts and state
State that crosses screens/windows lives in context, each provider mounted exactly once.
useStatus(StatusContext) —{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }. Owns the singleDaemonFeed.Get+netbird:statussubscription and the daemon gate (see Layouts).refresh()after Connect/Disconnect to dodge a few hundred ms of event-stream lag.ProfileContext—username,activeProfile,profiles, plusrefresh/switchProfile/addProfile/removeProfile/logoutProfile.switchProfiledelegates toProfileSwitcher.SwitchActive(the Go-side single source of truth — drives the optimistic-Connecting paint andPeerssuppression). The other methods are thin wrappers overProfiles.*/Connection.Logout+ arefresh().SettingsContext—setField/saveField/saveFields/saveNowoverSettings.GetConfig|SetConfigwith 400ms debounce. Renders<SettingsSkeleton/>whileconfig === null. PSK mask quirk:GetConfigreturns existing PSKs as"**********"; sending the mask back round-trips it into storage andwgtypes.ParseKeyfails on the next connect —savedrops the field when it equals the mask.MdmContext—useMdm()returnsconfig.managedFieldsasRecord<string, boolean>, keyed by the daemon'smdm.Key*names exactly as written in the policy source (managementURL,allowServerSSH,preSharedKey,wireguardPort,rosenpassEnabled/Permissive,disableClientRoutes/disableServerRoutes,disableAutoConnect,blockInbound). No GUI-side renaming — what the Group Policy admin writes is what the lookup key is. Mounted inAppLayout(underProfileProvider); fetchesSettings.GetConfigonce, re-fetches on the daemon'snetbird:eventmetadata.type=config_changedpush so policy flips paint live. No second copy of the locked values — MDM is a global override, so the active profile's resolveduseSettings().config.<field>already carries the MDM-enforced value. Consumers: Settings tabs hide individual toggles/sections (both rosenpass keys managed ⇒ whole encryption section hidden);SettingsNavigation+SettingsPagehide the SSH tab whenmanaged.allowServerSSHis set and bounceactive="ssh"back to General;ProfileCreationModalskips the Cloud/self-hosted picker whenmanaged.managementURLis set and submits the resolved URL verbatim;WelcomeDialogreadsconfig.managedFields.managementURLdirectly (sits outsideAppLayout) to skip the management step on a fresh install.DebugBundleContext— stagesidle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done. Cancellable viaAbortControllerat any stage; cancel restores the original log level best-effort. Upload URL is the hardcodedNETBIRD_UPLOAD_URL.ClientVersionContext— seeds fromUpdate.GetState(), subscribes tonetbird:update:state; exposes{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }. Three branches:available && !enforced— download-only;UpdateVersionCard→ opens GitHub releases.available && enforced && !installing— user-driven;triggerUpdateopens the install-progress window then callsUpdate.Trigger().available && enforced && installing— daemon already installing; the flip auto-opens the install-progress window.
NetworksContext— routed networks + exit nodes derived fromstatus.networks; optimistic overrides for instant toggle feedback.PeerDetailContext— which peer detail panel is open in advanced view.NavSectionContext— the advanced-mode Peers/Networks tab selection.
View mode + no client-side persistence
ViewModeProvider (contexts/ViewModeContext.tsx, mounted in MainPage) owns viewMode: "default" | "advanced", consumed via useViewMode(). setViewMode updates state, calls Window.SetSize(width, <live frame height>), and persists via Preferences.SetViewMode. Widths live in VIEW_WIDTH: Default 380, Advanced 900. The height is intentionally not asserted — we read the current frame height via Window.Size() and pass it back, because Wails' macOS windowSetSize is setFrame: (frame, incl. ~28px title bar) while initial windowNew uses initWithContentRect: (content). Passing a constant would chop ~28px off the content area on the first switch. main.go opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from Preferences.Get() on mount without triggering a resize.
No localStorage / sessionStorage / cookies anywhere — persistence is the Go side's job: settings → SetConfig, language → Preferences.SetLanguage, view mode → Preferences.SetViewMode.
Localisation (i18n)
Bootstrap in src/lib/i18n.ts, awaited before render in app.tsx. It reads the current language from Preferences.Get(), glob-imports every bundle from the shared tree at client/ui/i18n/locales/ (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with fallbackLng: "en" and interpolation: { prefix: "{", suffix: "}" }, and subscribes to netbird:preferences:changed so a flip from any window calls i18next.changeLanguage here.
First-run browser-language detection. When no preferences file exists, Preferences.Get() returns language: "" (the Go "unset" signal). initI18n walks navigator.language + navigator.languages, lowercases each, and picks the first base code (de from de-DE) with a shipped bundle — then Preferences.SetLanguage(detected) fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to en. From the second launch the persisted value wins.
Usage. Default to the hook:
import { useTranslation } from "react-i18next";
const { t } = useTranslation();
t("settings.tabs.general");
t("update.card.versionAvailable", { version: updateVersion }); // placeholders
Outside React (module-scope event handlers, error titles) import the instance directly: import i18next from "@/lib/i18n".
Bundle files. Keys live in client/ui/i18n/locales/<code>/common.json in Chrome-extension JSON shape: each key maps to { "message": "...", "description": "..." }. description is translator context for Crowdin (read from the source file, ignored at runtime) — only en/common.json carries descriptions; target bundles carry just message. lib/i18n.ts strips each entry to its message when building the i18next resources, so t() lookups are unchanged. Placeholders use single braces: "Install version {version}". Add a key to en/common.json first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI).
Translating bundles. client/ui/i18n/TRANSLATING.md is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/\n/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change.
Adding a language. Drop client/ui/i18n/locales/<code>/common.json (follow TRANSLATING.md) and append the row to _index.json. No flag asset is needed — LanguagePicker.tsx deliberately ships no flags ("flags represent countries, not languages"). lib/i18n.ts discovers bundles via import.meta.glob('../../../i18n/locales/*/common.json', { eager: true }) (the tree lives outside frontend/, so vite.config.ts whitelists the parent dir under server.fs.allow) — no code change needed to wire a new locale.
What gets translated. Every user-facing string. Don't add hard-coded English — add the key, then t(). Internal log strings and the Update failed fallback fed into classifyError() are not translated.
Login flow (startLogin in MainConnectionStatusSwitch.tsx)
The SSO flow is a module-level startLogin() with a loginInFlight guard so a double-click can't fire two concurrent flows. Sequence:
Connection.Login({})with empty fields — Go fills in active profile + OS user.- If SSO is needed (
needsSsoLogin):WindowManager.OpenBrowserLogin(uri)opens the sign-in popup (hidden until React mounts anduseAutoSizeWindowcallsWindow.Show).- The dialog fires
Connection.OpenURL(uri)from its mount effect (done from the dialog, notstartLogin, so the browser doesn't race the still-hidden popup). Promise.race(WaitSSOLogin, browser-login:cancel).- On cancel: cancel the in-flight
WaitSSOLogingRPC so the daemon drops the abandoned device code.
Connection.Up({})to bring the new session up.
onSettled (releasing the caller's React-level guard) fires the instant the flow ends — before the error dialog — never gated on the dialog. Errors that aren't cancellations surface via errorDialog. This is the only SSO entry point; there's no /login route — wire any new SSO trigger through here.
Dialogs convention
Errors → errorDialog({Title, Message}) from src/lib/errors.ts (which also exports formatErrorMessage), never Dialogs.* from @wailsio/runtime. Despite the name it opens the custom always-on-top /#/dialog/error window via WindowManager.OpenError (modules/error/ErrorDialog.tsx), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. errorDialog() resolves as soon as the window opens — it does not block until dismissed.
For confirmations, use useConfirm() from contexts/DialogContext.tsx — const ok = await confirm({ title, description, confirmLabel, danger? }) resolves to a boolean. It renders a single shared ConfirmModal mounted at the provider level. Used by the Profiles tab and the management-server cloud switch.
Skip dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full rationale in ../CLAUDE.md.
Tailwind tokens
Defined in tailwind.config.ts. nb-gray is the neutral palette (background nb-gray-950); netbird is brand orange (#f68330). New code uses nb-gray + netbird + semantic dot colors (green-500, red-500, yellow-500). bg-conic-netbird and the pulse-reverse / spin-slow / ping-slow keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under src/assets/fonts/.
Wails-specific quirks
- Window dragging. Class
wails-draggableon regions that should drag the OS window (headers, the Settings title strip, dialog wrappers).wails-no-draggableon interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click. - Webview asset access. Reference assets through Vite:
import url from "@/assets/.../foo.svg". Absolute filesystem paths don't work in dev or prod. Window.SetSize(w, h). Called fromViewModeContext'ssetViewMode. Height is read fresh fromWindow.Size()and re-passed — see the View mode section for why a constant would shrink the content area.- Main-window width. Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (
MainPage→isWindows() ? 364 : 380; see wails/wails#3260). Browser.OpenURL(url). Used bySettingsAbout(legal links) and the BrowserLogin "Try again".SettingsAbouthas awindow.openfallback for when Wails refuses (non-http schemes are rejected).
Useful references
WAILS-API.md(sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns$CancellablePromise<T>(awaitand ignore.cancel()in practice). Regenerate viapnpm bindingsafter any Go-side change.- Wails v3 dialog signatures:
node_modules/@wailsio/runtime/types/dialogs.d.ts. - Wails v3 docs (may 403 from some clients): https://v3.wails.io/
../CLAUDE.md— Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals.