This commit is contained in:
Eduard Gert
2026-05-07 09:57:14 +02:00
parent 553be144b4
commit debb558aa3
22 changed files with 774 additions and 178 deletions

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@wailsio/runtime": "latest",
"chroma-js": "^3.2.0",

View File

@@ -20,6 +20,9 @@ dependencies:
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
@@ -1102,6 +1105,32 @@ packages:
react: 18.3.1
dev: false
/@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
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
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1):
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -1171,6 +1200,19 @@ packages:
react: 18.3.1
dev: false
/@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1):
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.3.28
react: 18.3.1
dev: false
/@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1):
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:

View File

@@ -1,69 +1,129 @@
1. General
# Settings — Tabs & Controls
The "old tray" toggles + notifications. This is what 90% of users come to Settings for.
Each row has a title and short description. Booleans default to **toggle switch**; pick another control only when noted.
- Connect on startup — disableAutoConnect (inverted)
- Allow SSH — serverSshAllowed (master switch; the SSH tab is the detail)
- Quantum-resistance — rosenpassEnabled
- Nested when on: Permissive mode — rosenpassPermissive
- Lazy connections — lazyConnectionEnabled
- Block inbound — blockInbound
- Show notifications — disableNotifications (inverted)
Tab order: **General · Network · SSH · Troubleshooting · About**.
▎ Note: blockInbound is technically a firewall behavior, but Stage 1 explicitly groups it with the tray-replacement toggles. Keep it here.
---
2. Connection
## 1. General
Identity + how the wire is established. The "what server am I talking to and how" tab.
App behavior + how the client connects.
- Management URL — managementUrl
- Pre-shared key — preSharedKey (password input, toggle reveal)
- Advanced (collapsed by default)
- Admin URL — adminUrl
- Interface name — interfaceName
- WireGuard port — wireguardPort
- MTU — mtu
### Startup
3. Network
- **Connect on startup** — `disableAutoConnect` (inverted) · *toggle switch*
- Automatically connect to NetBird when the app launches.
- **Show notifications** — `disableNotifications` (inverted) · *toggle switch*
- Show desktop notifications for connection events and updates.
Routing / DNS / LAN behavior — i.e. what the daemon does to the host network.
### Connection
- Network monitor — networkMonitor
- Disable DNS — disableDns
- Disable client routes — disableClientRoutes
- Disable server routes — disableServerRoutes
- Block LAN access — blockLanAccess
- **Management URL** — `managementUrl` · *text input*
- The NetBird management server this client connects to.
- **Admin URL** — `adminUrl` · *text input*
- Web dashboard URL used by "Open Admin Panel".
- **Pre-shared key** — `preSharedKey` · *password input with reveal toggle*
- Optional WireGuard pre-shared key for an extra layer of symmetric encryption.
4. SSH
### Interface
Detailed SSH server config. Greyed out with an inline notice ("Enable Allow SSH in General to configure") when serverSshAllowed is off.
- **Interface name** — `interfaceName` · *text input*
- Name of the WireGuard network interface created on this host.
- **WireGuard port** — `wireguardPort` · *number input*
- Local UDP port the WireGuard interface listens on.
- **MTU** — `mtu` · *number input*
- Maximum transmission unit for the WireGuard interface.
- SSH root login — enableSshRoot
- SFTP — enableSshSftp
- Local port forwarding — enableSshLocalPortForwarding
- Remote port forwarding — enableSshRemotePortForwarding
- Advanced (collapsed)
- Disable SSH auth — disableSshAuth
- JWT cache TTL — sshJwtCacheTtl
---
5. Diagnostics
## 2. Network
Everything you reach for when something is wrong. Mixes config (log level) with actions (bundle creation) deliberately — they're used together.
Routing, DNS, firewall, and encryption — everything the daemon does on the wire and to the host network.
- Log level — Debug / Info / Warn / Error (dropdown)
- Log file path — read-only, with Copy + Reveal in Finder/Explorer buttons (configFile / logFile from daemon)
- Config file path — same pattern
- Debug bundle (own section)
- Anonymize toggle
- Include system info toggle
- Upload on create toggle → reveals URL field when on
- Create Bundle button → progress indicator → resulting path or upload URL displayed below
### Routing & DNS
6. About
- **Lazy connections** — `lazyConnectionEnabled` · *toggle switch*
- Only establish peer tunnels on first traffic instead of eagerly at startup.
- **Network monitor** — `networkMonitor` · *toggle switch*
- Reconnect automatically when the host network changes (Wi-Fi switch, VPN, sleep/wake).
- **Enable DNS** — `disableDns` (inverted) · *toggle switch*
- Apply NetBird-managed DNS settings to the host resolver.
- **Enable client routes** — `disableClientRoutes` (inverted) · *toggle switch*
- Accept routes advertised by other peers so this client can reach their networks.
- **Enable server routes** — `disableServerRoutes` (inverted) · *toggle switch*
- Advertise this host's local routes to other peers.
Version + update flow + identity reference.
### Firewall
- **Block inbound traffic** — `blockInbound` · *toggle switch*
- Drop all unsolicited inbound traffic on the NetBird interface.
- **Block LAN access** — `blockLanAccess` · *toggle switch*
- Prevent peers from reaching this host's local network.
### Encryption
- **Quantum-resistant encryption** — `rosenpassEnabled` · *toggle switch*
- Add a post-quantum key exchange (Rosenpass) on top of WireGuard.
- **Permissive mode** — `rosenpassPermissive` · *toggle switch* (nested, only when above is on)
- Allow connections to peers without quantum-resistance support.
---
## 3. SSH
NetBird SSH server config. Master switch at the top; sub-toggles greyed out with an inline notice ("Enable Allow SSH to configure") when the master is off.
### Server
- **Allow SSH** — `serverSshAllowed` · *toggle switch* (master)
- Run the NetBird SSH server on this host so other peers can connect to it.
- **Allow root login** — `enableSshRoot` · *toggle switch*
- Permit incoming SSH sessions to authenticate as `root`.
- **Enable SFTP** — `enableSshSftp` · *toggle switch*
- Allow file transfers over the NetBird SSH server.
- **Local port forwarding** — `enableSshLocalPortForwarding` · *toggle switch*
- Allow clients to forward local ports through this host.
- **Remote port forwarding** — `enableSshRemotePortForwarding` · *toggle switch*
- Allow clients to expose remote ports back through this host.
### Authentication
- **Disable SSH auth** — `disableSshAuth` · *toggle switch*
- Skip JWT authentication for incoming SSH sessions. **Insecure — diagnostics only.**
- **JWT cache TTL** — `sshJwtCacheTtl` · *number input (seconds)*
- How long verified JWTs are cached before re-validation.
---
## 4. Troubleshooting
Everything you reach for when something is wrong. Config + actions deliberately mixed — they're used together.
### Logging
- **Log level** — *dropdown: Debug / Info / Warn / Error*
- Verbosity of the daemon log. Raise to Debug when reproducing an issue.
- **Log file path** — *read-only text + Copy + Reveal in Finder/Explorer*
- **Config file path** — *read-only text + Copy + Reveal in Finder/Explorer*
### Debug bundle
- **Anonymize** — *toggle switch*
- Strip IPs, hostnames, and peer names from the bundle before saving.
- **Include system info** — *toggle switch*
- Add OS, kernel, and network interface details to the bundle.
- **Upload on create** — *toggle switch*
- When on, reveals an upload URL field and uploads the bundle after creation.
- **Create Bundle** — *button* → progress indicator → resulting path or upload URL.
---
## 5. About
Version, update flow, and identity reference.
- App version, daemon version
- Check for Updates button → drives the auto-update flow (15-min timeout, success/error states)
- Local peer info quick-reference (FQDN, IP) — same data the connection-state view shows
- **Check for Updates** — *button* (drives auto-update flow; 15-min timeout, success/error states)
- Local peer info quick-reference (FQDN, IP) — same data shown in the connection-state view
- Links: docs, GitHub repo, license

View File

@@ -5,35 +5,30 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import QuickActions from "@/screens/QuickActions.tsx";
import LoginUrl from "@/screens/LoginUrl.tsx";
import Update from "@/screens/Update.tsx";
import Layout from "@/layout.tsx";
import Peers from "@/screens/Peers.tsx";
import Networks from "@/screens/Networks.tsx";
import Profiles from "@/screens/Profiles.tsx";
import Settings from "@/screens/Settings.tsx";
import Debug from "@/screens/Debug.tsx";
import {Main} from "@/screens/Main.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { Main } from "@/layouts/Main.tsx";
import { Settings } from "@/modules/settings/Settings.tsx";
import { SkeletonTheme } from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<Layout />}>
<Route index element={<Main />} />
<Route path="peers" element={<Peers />} />
<Route path="networks" element={<Networks />} />
<Route path="profiles" element={<Profiles />} />
<Route path="settings" element={<Settings />} />
<Route path="debug" element={<Debug />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<AppLayout />}>
<Route index element={<Main />} />
<Route path="settings" element={<Settings />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}
/>
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
);

View File

@@ -0,0 +1,79 @@
import React from "react";
import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { ToggleSwitch } from "@/components/ToggleSwitch";
import { cn } from "@/lib/cn";
interface Props {
value: boolean;
onChange: (value: boolean) => void;
helpText?: React.ReactNode;
label?: React.ReactNode;
children?: React.ReactNode;
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
value,
onChange,
helpText,
label,
children,
disabled = false,
dataCy,
className,
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
}
};
return (
<div
onClick={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
className={cn(
"cursor-pointer transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
disabled && "opacity-50 pointer-events-none",
className,
)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch
checked={value}
onCheckedChange={onChange}
dataCy={dataCy}
/>
</div>
</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}
</div>
);
}

View File

@@ -9,6 +9,7 @@ type Props = HTMLMotionProps<"button"> & {
description?: string;
active?: boolean;
iconSize?: number;
iconBackground?: boolean;
};
export const NavItem = forwardRef<HTMLButtonElement, Props>(
@@ -19,6 +20,7 @@ export const NavItem = forwardRef<HTMLButtonElement, Props>(
description,
active = false,
iconSize = 15,
iconBackground = true,
className,
type = "button",
...props
@@ -40,21 +42,33 @@ export const NavItem = forwardRef<HTMLButtonElement, Props>(
)}
{...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",
)}
>
{iconBackground ? (
<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",
)}
>
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active
? "text-nb-gray-200"
: "text-nb-gray-400",
)}
/>
</div>
) : (
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
"shrink-0 ml-2 transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
</div>
)}
<div className={"min-w-0"}>
<h2
className={cn(

View File

@@ -1,7 +0,0 @@
export default function PlaceholderHeader() {
return (
<div
className="h-[36px] shrink-0 cursor-default wails-draggable"
/>
);
}

View File

@@ -124,9 +124,10 @@ export const ProfileSelector = ({ email = "" }: Props) => {
}
>
<div
className={
"h-7 w-7 flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold"
}
className={cn(
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
email ? "h-7 w-7" : "h-6 w-6",
)}
style={{ color: initialColor }}
>
{initial}

View File

@@ -0,0 +1,71 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/cn";
type SwitchVariants = VariantProps<typeof switchVariants>;
const switchVariants = cva("", {
variants: {
size: {
default: "h-[24px] w-[44px]",
small: "h-[18px] w-[36px]",
},
variant: {
default: [
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
],
"red-green": [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
],
red: [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
],
},
"thumb-size": {
default: "h-5 w-5 data-[state=checked]:translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
},
},
});
const ToggleSwitch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
SwitchVariants & { dataCy?: string }
>(
(
{ className, size = "default", variant = "default", dataCy, ...props },
ref,
) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
className,
switchVariants({ size, variant }),
)}
{...props}
data-cy={dataCy}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
switchVariants({ "thumb-size": size }),
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
)}
/>
</SwitchPrimitives.Root>
),
);
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
export { ToggleSwitch };

View File

@@ -1,15 +0,0 @@
import { Outlet } from "react-router-dom";
import PlaceholderHeader from "@/components/PlaceholderHeader";
export default function Layout() {
return (
<div className="flex h-full flex-col">
<PlaceholderHeader />
<div className="flex min-h-0 flex-1">
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
export const AppLayout = () => {
return (
<div className={"flex h-full flex-col"}>
<Header />
<Outlet />
</div>
);
};

View File

@@ -5,7 +5,7 @@ import {
export const ConnectionStatus = () => {
return (
<div className={"flex flex-col items-center"}>
<div className={"flex flex-col h-full items-center justify-center"}>
<NetBirdConnectToggle state={ConnectionState.Connected} />
<h1
className={

View File

@@ -1,20 +1,26 @@
import { useLocation, useNavigate } from "react-router-dom";
import { SettingsIcon } from "lucide-react";
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
import { IconButton } from "@/components/IconButton.tsx";
import { SettingsIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = {
settingsActive?: boolean;
onSettingsClick?: () => void;
};
export const Header = () => {
const navigate = useNavigate();
const location = useLocation();
const settingsActive = location.pathname.startsWith("/settings");
export const Header = ({ settingsActive = false, onSettingsClick }: Props) => {
return (
<div className={"w-full justify-between flex mb-12"}>
<ProfileSelector email={"eduard@netbird.io"} />
<div
className={
"pt-4 shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15"
}
>
<div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} />
</div>
<IconButton
icon={SettingsIcon}
onClick={onSettingsClick}
onClick={() => navigate(settingsActive ? "/" : "/settings")}
className={cn(
settingsActive &&
"bg-nb-gray-930 text-nb-gray-200 hover:text-nb-gray-200",

View File

@@ -0,0 +1,22 @@
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
import { Peers } from "@/modules/peers/Peers.tsx";
export const Main = () => {
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div
className={
"flex flex-col max-w-xs w-full shrink-0 items-center"
}
>
<ConnectionStatus />
<Navigation peersActive />
</div>
<MainRightSide>
<Peers />
</MainRightSide>
</div>
);
};

View File

@@ -1,32 +0,0 @@
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
import { Header } from "@/layouts/Header.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
export type MainModule = "peers" | "settings";
type Props = {
active: MainModule;
onChange: (module: MainModule) => void;
};
export const MainLeftSide = ({ active, onChange }: Props) => {
return (
<div
className={"flex flex-col max-w-xs w-full shrink-0 items-center"}
>
<Header
settingsActive={active === "settings"}
onSettingsClick={() =>
onChange(active === "settings" ? "peers" : "settings")
}
/>
<ConnectionStatus />
<Navigation
peersActive={active === "peers"}
onPeersClick={() => {
if (active !== "peers") onChange("peers");
}}
/>
</div>
);
};

View File

@@ -12,7 +12,7 @@ type Props = {
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1 mt-8"}>
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
<NavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}

View File

@@ -0,0 +1,27 @@
import { createContext, ReactNode, useContext, useState } from "react";
export type MainModule = "peers" | "settings";
type Ctx = {
active: MainModule;
setActive: (m: MainModule) => void;
};
const MainModuleContext = createContext<Ctx | null>(null);
export const MainModuleProvider = ({ children }: { children: ReactNode }) => {
const [active, setActive] = useState<MainModule>("peers");
return (
<MainModuleContext.Provider value={{ active, setActive }}>
{children}
</MainModuleContext.Provider>
);
};
export const useMainModule = () => {
const ctx = useContext(MainModuleContext);
if (!ctx) {
throw new Error("useMainModule must be used within MainModuleProvider");
}
return ctx;
};

View File

@@ -1,7 +1,19 @@
import { useState } from "react";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import {
SettingsNavigation,
SettingsSection,
} from "@/modules/settings/SettingsNavigation.tsx";
export const Settings = () => {
const [active, setActive] = useState<SettingsSection>("general");
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4 px-4"}>
<h2 className={"text-sm font-medium text-nb-gray-200"}>Settings</h2>
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div className={"flex flex-col w-52 shrink-0 items-center"}>
<SettingsNavigation active={active} onChange={setActive} />
</div>
<MainRightSide>{null}</MainRightSide>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { NavItem } from "@/components/NavItem.tsx";
import {
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
SlidersHorizontalIcon,
TerminalIcon,
} from "lucide-react";
export type SettingsSection =
| "general"
| "network"
| "ssh"
| "troubleshooting"
| "about";
type Props = {
active: SettingsSection;
onChange: (section: SettingsSection) => void;
};
const ITEMS: {
id: SettingsSection;
icon: typeof SlidersHorizontalIcon;
title: string;
}[] = [
{ id: "general", icon: SlidersHorizontalIcon, title: "General" },
{ id: "network", icon: NetworkIcon, title: "Network" },
{ id: "ssh", icon: TerminalIcon, title: "SSH" },
{ id: "troubleshooting", icon: LifeBuoyIcon, title: "Troubleshooting" },
{ id: "about", icon: InfoIcon, title: "About" },
];
export const SettingsNavigation = ({ active, onChange }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1"}>
{ITEMS.map(({ id, icon, title }) => (
<NavItem
key={id}
icon={icon}
title={title}
iconSize={14}
iconBackground={false}
className={"py-2.5"}
active={active === id}
onClick={() => {
if (active !== id) onChange(id);
}}
/>
))}
</nav>
);
};

View File

@@ -1,23 +0,0 @@
import { useState } from "react";
import { MainLeftSide, MainModule } from "@/layouts/MainLeftSide.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Peers } from "@/modules/peers/Peers.tsx";
import { Settings } from "@/modules/settings/Settings.tsx";
type Props = {
};
export const Main = ({}: Props) => {
const [active, setActive] = useState<MainModule>("peers");
return (
<div className={"wails-draggable flex h-full p-4 gap-4 min-h-0"}>
<MainLeftSide active={active} onChange={setActive} />
<MainRightSide>
{active === "peers" ? <Peers /> : <Settings />}
</MainRightSide>
</div>
);
};

View File

@@ -0,0 +1,278 @@
# Wails Go API surface for the React frontend
All bindings live under `frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/`. Import them as:
```ts
import { Connection, Peers, Networks, Settings, Profiles, Debug, Update, Forwarding } from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import * as $models from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services/models";
```
Every method returns `$CancellablePromise<T>` (Wails3 wrapper around a Promise — call `.cancel()` to abort the underlying gRPC stream / call).
## Push events
Subscribe with the Wails event API: `import { Events } from "@wailsio/runtime"`.
| Event name | Payload type | Fires on |
|---|---|---|
| `netbird:status` | `Status` | Daemon connection-state change (Connected / Connecting / Disconnected / Idle), peer-list change, address change, management/signal flip. **Replaces polling**. |
| `netbird:event` | `SystemEvent` | One push per daemon-emitted event (DNS/network/auth/connectivity/system). Drives toasts and the event log. |
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version. Show the update menu/banner. |
| `netbird:update:progress` | `UpdateProgress` | `action:"show"` → open the update progress page; `action:"hide"` → close. |
Calling `Peers.Watch()` once at boot starts both backend stream loops; both self-restart with backoff on errors.
## Connection lifecycle — `Connection`
```ts
Connection.Up(p: UpParams): Promise<void>
Connection.Down(): Promise<void>
Connection.Login(p: LoginParams): Promise<LoginResult>
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email/userInfo
Connection.Logout(p: LogoutParams): Promise<void>
```
- **Up flow**: call `Login` first; if `LoginResult.needsSsoLogin === true` open `verificationUriComplete` in the browser, then call `WaitSSOLogin` with `{ userCode: LoginResult.userCode, hostname: ... }`. Once that resolves call `Up`.
- **Down flow**: just `Down()`. The daemon transitions to `Idle`.
```ts
class LoginParams { profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }
class LoginResult { needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }
class WaitSSOParams { userCode, hostname: string }
class UpParams { profileName, username: string }
class LogoutParams { profileName, username: string }
```
## Status / peer list — `Peers`
```ts
Peers.Get(): Promise<Status> // one-shot snapshot
Peers.Watch(): Promise<void> // call once at boot to enable push events
```
```ts
class Status {
status: string // "Idle" | "Connecting" | "Connected" | "SessionExpired" (see below)
daemonVersion: string
management: PeerLink
signal: PeerLink
local: LocalPeer
peers: PeerStatus[]
events: SystemEvent[]
}
class PeerLink {
url: string
connected: boolean
}
class LocalPeer {
ip, pubKey, fqdn: string
networks: string[]
}
class PeerStatus {
ip, pubKey, fqdn: string
connStatus: string // "Connected" | "Connecting" | "Idle"
connStatusUpdateUnix: number // unix seconds
relayed: boolean
localIceCandidateType, remoteIceCandidateType: string
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string
bytesRx, bytesTx: number
latencyMs: number
relayAddress: string // populated when relayed
lastHandshakeUnix: number
rosenpassEnabled: boolean
networks: string[]
}
class SystemEvent {
id: string
severity: string // "info" | "warning" | "error" | "critical"
category: string // "network" | "dns" | "authentication" | "connectivity" | "system"
message: string // technical / log message
userMessage: string // human-friendly message — render this
timestamp: number // unix seconds
metadata: Record<string, string>
}
```
### Connection-state values
The `Status.status` field uses these literal strings (from the daemon):
| Value | Meaning |
|---|---|
| `"Idle"` | Disconnected — Up not invoked, or Down completed |
| `"Connecting"` | Up in progress |
| `"Connected"` | Tunnel up |
| `"SessionExpired"` | SSO token expired — needs Login again |
(The Fyne UI also reads a synthetic `"Error"` label for some failed states; check `events` for details.)
### ICE candidate type values
`localIceCandidateType` / `remoteIceCandidateType` are pion/ICE strings: `"host"`, `"srflx"`, `"prflx"`, `"relay"`, or `""` while connecting.
## Networks — `Networks`
```ts
Networks.List(): Promise<Network[]>
Networks.Select(p: SelectNetworksParams): Promise<void>
Networks.Deselect(p: SelectNetworksParams): Promise<void>
```
```ts
class Network {
id, range: string // range is a CIDR
selected: boolean
domains: string[] // empty unless this is a domain network
resolvedIps: Record<string, string[]> // domain -> IPs
}
class SelectNetworksParams {
networkIds: string[]
append: boolean // false = replace selection, true = merge with existing
all: boolean // true = ignore networkIds and target every network (Select-All / Deselect-All)
}
```
The Fyne UI's All / Overlapping / Exit-node tabs are filters over the same `List()` result:
- **Exit-node**: `range === "0.0.0.0/0" || range === "::/0"`
- **Overlapping**: client-side detection of CIDR overlap among `range` values
- **All**: everything
## Forwarding / exposed services — `Forwarding`
```ts
Forwarding.List(): Promise<ForwardingRule[]>
```
```ts
class ForwardingRule {
protocol: string // "tcp" | "udp"
destinationPort: PortInfo
translatedAddress, translatedHostname: string
translatedPort: PortInfo
}
class PortInfo { // exactly one field is populated
port?: number
range?: PortRange
}
class PortRange { start, end: number }
```
## Profiles — `Profiles`
```ts
Profiles.List(username: string): Promise<Profile[]>
Profiles.GetActive(): Promise<ActiveProfile>
Profiles.Switch(p: ProfileRef): Promise<void>
Profiles.Add(p: ProfileRef): Promise<void>
Profiles.Remove(p: ProfileRef): Promise<void>
Profiles.Username(): Promise<string> // current OS username
```
```ts
class Profile { name: string; isActive: boolean }
class ProfileRef { profileName, username: string }
class ActiveProfile { profileName, username: string }
```
## Settings / config — `Settings`
```ts
Settings.GetConfig(p: ConfigParams): Promise<Config>
Settings.SetConfig(p: SetConfigParams): Promise<void>
Settings.GetFeatures(): Promise<Features>
```
```ts
class ConfigParams { profileName, username: string } // identifies which profile's config
class Config {
managementUrl, adminUrl, configFile, logFile, preSharedKey: string
interfaceName: string; wireguardPort, mtu: number
disableAutoConnect, serverSshAllowed: boolean
rosenpassEnabled, rosenpassPermissive: boolean
disableNotifications, lazyConnectionEnabled, blockInbound: boolean
networkMonitor, disableClientRoutes, disableServerRoutes: boolean
disableDns, blockLanAccess: boolean
enableSshRoot, enableSshSftp: boolean
enableSshLocalPortForwarding, enableSshRemotePortForwarding: boolean
disableSshAuth: boolean
sshJwtCacheTtl: number
}
class SetConfigParams {
// identity (always required)
profileName, username: string
// any field below is optional — only the ones you set are pushed to the daemon
managementUrl?, adminUrl?, ...
// ... same shape as Config
}
class Features {
// feature flags from the daemon — hide UI sections when these are true
disableProfiles, disableUpdateSettings, disableNetworks: boolean
}
```
`SetConfig` is partial — supply only the fields you want to change, plus `profileName` + `username`. Booleans use Go pointer-presence under the hood; on the TS side undefined / missing means "leave as-is".
## Debug bundle / log level — `Debug`
```ts
Debug.GetLogLevel(): Promise<LogLevel>
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
```
```ts
class LogLevel { level: string } // "trace" | "debug" | "info" | "warning" | "error" | "panic"
class DebugBundleParams {
anonymize: boolean
systemInfo: boolean
uploadUrl: string // empty string = no upload
logFileCount: number // 0 = default
}
class DebugBundleResult {
path: string // local path of the generated bundle
uploadedKey: string // populated when uploadUrl was set
uploadFailureReason: string // populated on upload error
}
```
## Update flow — `Update`
```ts
Update.Trigger(): Promise<UpdateResult> // start the install
Update.GetInstallerResult(): Promise<UpdateResult> // poll the install outcome (long-running)
```
```ts
class UpdateResult { success: boolean; errorMsg: string }
class UpdateAvailable { // payload of "netbird:update:available"
version: string
enforced: boolean // true = management server requires it
}
class UpdateProgress { // payload of "netbird:update:progress"
action: string // "show" | "hide"
version: string
}
```
Typical flow:
1. Listen for `"netbird:update:available"` → show the "Update X.Y.Z" affordance.
2. User clicks → call `Update.Trigger()`.
3. The page that shows the install progress polls `GetInstallerResult()` (15-min timeout). On `success: true` the daemon will exit; the app should `app.Quit()` (or restart). On `success: false` show `errorMsg`.
## Toast notifications
The tray sends OS notifications via `application/services/notifications` automatically for `netbird:event` events that have `userMessage`. The frontend doesn't need to do anything for that; the data is also delivered via `netbird:event` if you want to render an in-window log.

View File

@@ -113,6 +113,7 @@ func main() {
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
},
})