remove old code, add german lang

This commit is contained in:
Eduard Gert
2026-05-15 12:56:09 +02:00
parent 17cae1a75c
commit 5411fa4350
25 changed files with 175 additions and 827 deletions

View File

@@ -20,9 +20,9 @@ Each service is registered via `app.RegisterService(application.NewService(svc))
### Frontend (`frontend/src/`)
- `app.tsx` — top-level routes. Hash router with `/quick`, `/browser-login`, `/update`, `/session-expired`, `/settings` (own layout), and a root `AppLayout` that hosts `Main` and a `*` catch-all.
- `layouts/AppLayout.tsx` — composition shell. Wraps `Header + Outlet` in `AppearanceProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`.
- `layouts/AppLayout.tsx` — composition shell. Wraps `Header + Outlet` in `ProfileProvider → DebugBundleProvider → ClientVersionProvider`. The wide-panel `expanded` state lives here as plain `useState` (no persistence) and is passed to `Header` via props and `Main` via Outlet context.
- `layouts/SettingsLayout.tsx` — used when the settings window opens (route `/settings`).
- `modules/*/Context.tsx` — context providers (`appearance`, `auto-update`, `debug-bundle`, `profile`).
- `modules/*/Context.tsx` — context providers (`auto-update`, `debug-bundle`, `profile`).
- `pages/` — full-screen, single-purpose pages opened in popups or via top-level routes (`BrowserLogin`, `SessionExpired`, `Update`, `Debug`).
- `screens/` — content shown inside `AppLayout` (`Status`, `Peers`, `Networks`, `Profiles`, `Settings`, `Update`, `QuickActions`, `Debug`).

View File

@@ -58,7 +58,7 @@ Bindings are regenerated from Go via `wails3 generate bindings -clean=true -ts`
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `AppearanceProvider → 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` 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.
@@ -77,12 +77,11 @@ layouts/ # AppLayout, SettingsLayout, Header, Main, MainRightSide,
lib/ # cn (tailwind merge), color (hash → hex), welcome (console art),
# MainModuleContext (unused legacy)
modules/ # feature folders that own their own contexts/state
appearance/ # AppearanceContext (localStorage)
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 + accent egg
settings/ # Settings root + per-tab files + SettingsContext
skeletons/ # SkeletonSettings
pages/ # full-screen single-purpose pages routed via app.tsx
BrowserLogin.tsx # auxiliary window content
@@ -90,7 +89,6 @@ pages/ # full-screen single-purpose pages routed via app.tsx
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)
Status.tsx # legacy detailed status page (not in current route table)
Peers.tsx # legacy peer-detail UI (uses real Peers.Get data)
Networks.tsx # legacy networks UI
Profiles.tsx # uses ProfileSwitcher.SwitchActive (current preferred path)
@@ -165,9 +163,11 @@ While `config` is `null` the provider renders `<SkeletonSettings/>` instead of c
**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.
### `AppearanceContext` (modules/appearance/AppearanceContext.tsx)
### Wide/narrow panel state
Pure-frontend UI preferences persisted to `localStorage` under `netbird:appearance`. Fields: `connectionLayout` (`"default" | "switch"`), `expanded` (bool — drives the wide / narrow window mode), `showPeersNav`, `showResourcesNav`, `showExitNodeNav`, `showProfileSelector`, `showSettingsButton`. `Header.tsx` writes `expanded` and resizes the OS window to match (`Window.SetSize(925|380, 615)`).
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/)
@@ -278,8 +278,6 @@ Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono) — both shipped u
- **`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.
- **`ConnectionStatus.tsx`** (the non-Switch variant of the main toggle) is local-state-only — it does not call `Connection.Up/Down` and shows hardcoded `peer-hostname.netbird.cloud` / `192.168.0.1`. It's a visual prototype the user can flip to via `connectionLayout` in `AppearanceContext`; **don't rely on it for real connect/disconnect behavior**. The real one is `ConnectionStatusSwitch.tsx`.
- **`SettingsAccent`** is a 10-clicks-on-the-version-label easter egg that renders a falling-`TEAMNETBIRD` canvas overlay for 9 seconds. Kept on purpose.
## Wails Go API reference

View File

@@ -2,10 +2,10 @@
// This file is automatically generated. DO NOT EDIT
/**
* I18n holds the embedded translation bundles and serves them to both the
* tray (Translate, used on every menu rebuild) and the frontend (Bundle,
* optional Wails-bound endpoint for runtime fetches). The bundles are
* loaded once at construction and never mutated.
* I18n is the Wails-bound facade over i18n.Bundle. It exists only to give
* the binding generator a service type with the context.Context-first
* signatures it expects; the translation logic, locale loading and the
* LanguageCode type all live in client/ui/i18n.
* @module
*/
@@ -15,50 +15,30 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
import * as i18n$0 from "../i18n/models.js";
/**
* Bundle returns the full key->text map for one language. Bound to React via
* Wails as an optional endpoint — the frontend can either import the JSON
* files directly through Vite or fetch them through this RPC. The returned
* map is a copy.
* Bundle returns the full key->text map for one language, letting the
* React side drive its own translation library (i18next, etc.) off the
* same source bundles the tray uses.
*/
export function Bundle(code: string): $CancellablePromise<{ [_ in string]?: string }> {
export function Bundle(code: i18n$0.LanguageCode): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(1780869897, code).then(($result: any) => {
return $$createType0($result);
});
}
/**
* HasLanguage reports whether a bundle is loaded for the given code.
* Preferences.SetLanguage uses this to validate input.
* Languages exposes the list of shipped locales to the frontend so the
* settings page can populate its language picker.
*/
export function HasLanguage(code: string): $CancellablePromise<boolean> {
return $Call.ByID(3348861033, code);
}
/**
* Languages returns the list of available locales. Bound to React via Wails
* so the settings page can populate its language selector.
*/
export function Languages(): $CancellablePromise<$models.Language[]> {
export function Languages(): $CancellablePromise<i18n$0.Language[]> {
return $Call.ByID(768152924).then(($result: any) => {
return $$createType2($result);
});
}
/**
* Translate resolves key for the given language with a placeholder pass.
* Args must come in {placeholderName, value} pairs (e.g. "version", "1.2.3"
* substitutes "{version}"). Unknown keys fall back to the default language;
* if even that fails, the key itself is returned (debug-friendly — a missed
* key is visible in the UI rather than blank).
*/
export function Translate(lang: string, key: string, ...args: string[]): $CancellablePromise<string> {
return $Call.ByID(2739709937, lang, key, args);
}
// Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $models.Language.createFrom;
const $$createType1 = i18n$0.Language.createFrom;
const $$createType2 = $Create.Array($$createType1);

View File

@@ -36,7 +36,6 @@ export {
DebugBundleResult,
Features,
ForwardingRule,
Language,
LocalPeer,
LogLevel,
LoginParams,
@@ -53,7 +52,6 @@ export {
SetConfigParams,
Status,
SystemEvent,
UIPreferences,
UpParams,
UpdateAvailable,
UpdateProgress,

View File

@@ -344,41 +344,6 @@ export class ForwardingRule {
}
}
/**
* Language describes one shipped UI locale. Code is the BCP-47-ish key the
* frontend and the preferences file use; DisplayName is shown in the language
* picker in its own script (so a Hungarian user sees "Magyar" even when the
* current UI language is English).
*/
export class Language {
"code": string;
"displayName": string;
"englishName": string;
/** Creates a new Language instance. */
constructor($$source: Partial<Language> = {}) {
if (!("code" in $$source)) {
this["code"] = "";
}
if (!("displayName" in $$source)) {
this["displayName"] = "";
}
if (!("englishName" in $$source)) {
this["englishName"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Language instance from a string or object.
*/
static createFrom($$source: any = {}): Language {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Language($$parsedSource as Partial<Language>);
}
}
/**
* LocalPeer mirrors LocalPeerState — what this client looks like on the mesh.
*/
@@ -1073,32 +1038,6 @@ export class SystemEvent {
}
}
/**
* UIPreferences is the user-scope UI state mirrored to disk and to the
* frontend. Pointer-free because the whole document is rewritten on every
* change — there are no per-field partial updates.
*/
export class UIPreferences {
"language": string;
/** Creates a new UIPreferences instance. */
constructor($$source: Partial<UIPreferences> = {}) {
if (!("language" in $$source)) {
this["language"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new UIPreferences instance from a string or object.
*/
static createFrom($$source: any = {}): UIPreferences {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new UIPreferences($$parsedSource as Partial<UIPreferences>);
}
}
/**
* UpParams selects the profile the daemon should bring up.
*/

View File

@@ -2,9 +2,10 @@
// This file is automatically generated. DO NOT EDIT
/**
* Preferences is the user-scope UI preferences service. Read at app start,
* updated by the React settings page (Wails-bound SetLanguage), and observed
* by the tray which re-renders its menu in the new language.
* Preferences is the Wails-bound facade over preferences.Store. The store
* itself owns persistence and the subscription channel; this type just
* re-exposes Get and SetLanguage with the context.Context-first signature
* the Wails binding generator wants.
* @module
*/
@@ -14,34 +15,26 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
import * as i18n$0 from "../i18n/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as preferences$0 from "../preferences/models.js";
/**
* Get returns a copy of the current preferences. Bound to React via Wails.
* Get returns the current user-scope preferences.
*/
export function Get(): $CancellablePromise<$models.UIPreferences> {
export function Get(): $CancellablePromise<preferences$0.UIPreferences> {
return $Call.ByID(3500743391).then(($result: any) => {
return $$createType0($result);
});
}
/**
* SetLanguage validates and persists a new language preference, then
* broadcasts the change to the frontend and to internal subscribers (tray).
* Bound to React via Wails.
* SetLanguage validates and persists a new UI language.
*/
export function SetLanguage(lang: string): $CancellablePromise<void> {
export function SetLanguage(lang: i18n$0.LanguageCode): $CancellablePromise<void> {
return $Call.ByID(3710099805, lang);
}
/**
* Subscribe returns a channel that receives every persisted change. The
* unsubscribe function closes the channel and removes it from the list;
* callers must not close the channel themselves.
*/
export function Subscribe(): $CancellablePromise<[any, any]> {
return $Call.ByID(2696446779);
}
// Private type creation functions
const $$createType0 = $models.UIPreferences.createFrom;
const $$createType0 = preferences$0.UIPreferences.createFrom;

View File

@@ -6,6 +6,9 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as preferences$0 from "../../../../netbirdio/netbird/client/ui/preferences/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js";
@@ -22,7 +25,7 @@ function configure() {
// Private type creation functions
const $$createType0 = services$0.SystemEvent.createFrom;
const $$createType1 = services$0.UIPreferences.createFrom;
const $$createType1 = preferences$0.UIPreferences.createFrom;
const $$createType2 = services$0.Status.createFrom;
const $$createType3 = services$0.UpdateAvailable.createFrom;
const $$createType4 = services$0.UpdateProgress.createFrom;

View File

@@ -5,6 +5,9 @@
// @ts-ignore: Unused imports
import type { Events } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as preferences$0 from "../../../../netbirdio/netbird/client/ui/preferences/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js";
@@ -13,7 +16,7 @@ declare module "@wailsio/runtime" {
namespace Events {
interface CustomEvents {
"netbird:event": services$0.SystemEvent;
"netbird:preferences:changed": services$0.UIPreferences;
"netbird:preferences:changed": preferences$0.UIPreferences;
"netbird:status": services$0.Status;
"netbird:update:available": services$0.UpdateAvailable;
"netbird:update:progress": services$0.UpdateProgress;

View File

@@ -1,136 +0,0 @@
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/cn";
import netbirdLogo from "@/assets/logos/netbird.svg";
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
type StateProps = {
state: ConnectionState;
};
type NetBirdConnectToggleProps = {
state: ConnectionState;
size?: number;
onClick?: () => void;
disabled?: boolean;
};
export const NetBirdConnectToggle = ({ state, size = 140, onClick, disabled }: NetBirdConnectToggleProps) => {
const [visualState, setVisualState] = useState(state);
useEffect(() => {
setVisualState(state);
}, [state]);
const handleClick = () => {
if (disabled) return;
if (visualState === ConnectionState.Connected) {
setVisualState(ConnectionState.Disconnecting);
} else if (visualState === ConnectionState.Disconnected) {
setVisualState(ConnectionState.Connecting);
}
onClick?.();
};
const padding = size * 0.075;
const borderGap = 2;
const borderInset = padding - borderGap;
const innerSize = size * 0.7;
const logoSize = size * 0.26;
const pingInset = size * 0.075;
return (
<div>
<motion.button
className={cn(
"rounded-full relative overflow-visible outline-none border-none bg-transparent",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
)}
style={{ padding }}
onClick={handleClick}
disabled={disabled}
whileTap={disabled ? undefined : { scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<OuterRing state={visualState} />
<BorderInnerRing state={visualState} inset={borderInset} />
<InnerRing size={innerSize}>
<NetBirdLogo state={visualState} logoSize={logoSize} />
<PingRing state={visualState} inset={pingInset} />
</InnerRing>
</motion.button>
</div>
);
};
const OuterRing = ({ state }: StateProps) => {
const isActive = state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
return (
<div
className={cn(
"absolute inset-0 rounded-full transition-all",
isActive ? "bg-netbird-500/20" : "bg-neutral-700",
state === ConnectionState.Disconnecting && "animate-pulse-slow",
)}
/>
);
};
const BorderInnerRing = ({ state, inset }: StateProps & { inset: number }) => (
<div
className={cn(
"absolute rounded-full transition-all duration-1000",
state === ConnectionState.Connected && "bg-netbird-600",
state === ConnectionState.Disconnecting && "bg-conic-netbird animate-spin-slow",
state !== ConnectionState.Connected && state !== ConnectionState.Disconnecting && "bg-neutral-500",
)}
style={{ inset }}
/>
);
const InnerRing = ({ children, size }: { children: React.ReactNode; size: number }) => (
<div
className="rounded-full bg-nb-gray flex items-center justify-center relative z-10 mx-auto"
style={{ width: size, height: size }}
>
{children}
</div>
);
const NetBirdLogo = ({ state, logoSize }: StateProps & { logoSize: number }) => {
const isConnecting = state === ConnectionState.Connecting;
return (
<div
className={cn(isConnecting && "animate-pulse-slow")}
style={isConnecting ? { animationDelay: "0.1s" } : undefined}
>
<img
src={netbirdLogo}
alt="NetBird"
width={logoSize}
className={cn(
"filter transition-all duration-1000",
state === ConnectionState.Disconnected ? "grayscale" : "grayscale-0",
)}
/>
</div>
);
};
const PingRing = ({ state, inset }: StateProps & { inset: number }) => (
<span
className={cn(
"block absolute border-2 border-netbird rounded-full",
state === ConnectionState.Connecting ? "animate-ping-slow" : "hidden",
)}
style={{ inset }}
/>
);

View File

@@ -16,8 +16,16 @@ body,
overflow: hidden;
}
/*
* Body bg is fully opaque on purpose. The main window uses
* MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
* lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
* body alpha meant two machines with different wallpapers saw different
* effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
*/
body {
@apply bg-nb-gray/90 font-sans text-nb-gray-200 antialiased;
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
.wails-draggable {

View File

@@ -1,6 +1,7 @@
{
"languages": [
{"code": "en", "displayName": "English", "englishName": "English"},
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
]
}

View File

@@ -0,0 +1,34 @@
{
"tray.tooltip": "NetBird",
"tray.status.disconnected": "Getrennt",
"tray.status.daemonUnavailable": "Nicht aktiv",
"tray.status.error": "Fehler",
"tray.menu.open": "NetBird öffnen",
"tray.menu.connect": "Verbinden",
"tray.menu.disconnect": "Trennen",
"tray.menu.exitNode": "Exit-Node",
"tray.menu.networks": "Ressourcen",
"tray.menu.profiles": "Profile",
"tray.menu.settings": "Einstellungen",
"tray.menu.debugBundle": "Debug-Paket erstellen",
"tray.menu.about": "Über",
"tray.menu.github": "GitHub",
"tray.menu.documentation": "Dokumentation",
"tray.menu.downloadLatest": "Neueste Version herunterladen",
"tray.menu.installVersion": "Version {version} installieren",
"tray.menu.guiVersion": "Oberfläche: {version}",
"tray.menu.daemonVersion": "Daemon: {version}",
"tray.menu.versionUnknown": "—",
"tray.menu.quit": "Beenden",
"notify.update.title": "NetBird-Update verfügbar",
"notify.update.body": "NetBird {version} ist verfügbar.",
"notify.update.enforcedSuffix": " Ihr Administrator verlangt dieses Update.",
"notify.error.title": "Fehler",
"notify.error.connect": "Verbindung fehlgeschlagen",
"notify.error.disconnect": "Trennen fehlgeschlagen",
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an."
}

View File

@@ -1,23 +1,28 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
// The wide-panel toggle lives in plain React state here so every launch
// starts in the small layout — no localStorage, no cross-machine drift.
// Header drives the toggle; Main reads it via Outlet context to decide
// whether to mount the right-side panel.
export type MainOutletContext = { expanded: boolean };
export const AppLayout = () => {
const [expanded, setExpanded] = useState(false);
return (
<div className={"relative flex h-full flex-col"}>
<AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Header />
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Header expanded={expanded} setExpanded={setExpanded} />
<Outlet context={{ expanded } satisfies MainOutletContext} />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</div>
);
};

View File

@@ -1,117 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { ConnectionState, NetBirdConnectToggle } from "@/components/NetBirdConnectToggle.tsx";
import Button from "@/components/Button.tsx";
import { cn } from "@/lib/cn.ts";
const CONNECT_DURATION_MS = 1500;
const DISCONNECT_DURATION_MS = 800;
const STATUS_LABEL: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "Disconnected",
[ConnectionState.Connecting]: "Connecting...",
[ConnectionState.Connected]: "Connected",
[ConnectionState.Disconnecting]: "Disconnecting...",
};
export const ConnectionStatus = () => {
const [state, setState] = useState<ConnectionState>(ConnectionState.Disconnected);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
const transition = (next: ConnectionState, after: ConnectionState, delay: number) => {
if (timerRef.current) clearTimeout(timerRef.current);
setState(next);
timerRef.current = setTimeout(() => {
setState(after);
timerRef.current = null;
}, delay);
};
const connect = () =>
transition(ConnectionState.Connecting, ConnectionState.Connected, CONNECT_DURATION_MS);
const disconnect = () =>
transition(
ConnectionState.Disconnecting,
ConnectionState.Disconnected,
DISCONNECT_DURATION_MS,
);
const handleToggleClick = () => {
if (state === ConnectionState.Disconnected) connect();
else if (state === ConnectionState.Connected) disconnect();
};
const handleButtonClick = () => {
if (state === ConnectionState.Disconnected) {
connect();
return;
}
if (state === ConnectionState.Connected) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState(ConnectionState.Disconnected);
}
};
const isTransitioning =
state === ConnectionState.Connecting || state === ConnectionState.Disconnecting;
const isConnectedSide =
state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
const buttonLabel = isConnectedSide ? "Disconnect" : "Connect";
const buttonVariant = isConnectedSide ? "secondary" : "primary";
return (
<div className={cn("flex flex-col h-full w-full items-center justify-between", "-mt-4")}>
<div className={"w-full h-full flex flex-col items-center justify-center"}>
<div className={"flex flex-col items-center justify-center"}>
<p
className={
"font-mono text-xs text-nb-gray-300 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
peer-hostname.netbird.cloud
</p>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-0.5 mb-6 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
192.168.0.1
</p>
</div>
<NetBirdConnectToggle state={state} onClick={handleToggleClick} />
<div
className={
"flex flex-col w-full items-center justify-center gap-3 p-4 rounded-2xl mt-2"
}
>
<h1 className={"text-sm font-medium text-nb-gray-200 tracking-wide"}>
{STATUS_LABEL[state]}
</h1>
<div className={"w-full"}>
<Button
variant={buttonVariant}
size={"xs"}
className={"w-full"}
disabled={isTransitioning}
onClick={handleButtonClick}
>
{buttonLabel}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,13 +1,19 @@
import { useMemo, useState } from "react";
import { Dialogs, Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import { ConnectionState } from "@/components/NetBirdConnectToggle.tsx";
import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
import { useStatus } from "@/hooks/useStatus";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
const STATUS_LABEL: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "Disconnected",
[ConnectionState.Connecting]: "Connecting...",

View File

@@ -4,7 +4,6 @@ import { Window } from "@wailsio/runtime";
import { WindowManager } from "@bindings/services";
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
import { IconButton } from "@/components/IconButton.tsx";
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
import { cn } from "@/lib/cn";
const WINDOW_SMALL_WIDTH = 380;
@@ -12,8 +11,12 @@ const WINDOW_BIG_WIDTH = 925;
const WINDOW_HEIGHT = 615;
const EXPANDED_THRESHOLD = 500;
export const Header = () => {
const { showProfileSelector, showSettingsButton, expanded, setField } = useAppearance();
type HeaderProps = {
expanded: boolean;
setExpanded: (next: boolean) => void;
};
export const Header = ({ expanded, setExpanded }: HeaderProps) => {
const didInitialResize = useRef(false);
useEffect(() => {
@@ -33,15 +36,15 @@ export const Header = () => {
useEffect(() => {
const onResize = () => {
const isWide = window.innerWidth >= EXPANDED_THRESHOLD;
if (isWide !== expanded) setField("expanded", isWide);
if (isWide !== expanded) setExpanded(isWide);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [expanded, setField]);
}, [expanded, setExpanded]);
const togglePanel = () => {
const next = !expanded;
setField("expanded", next);
setExpanded(next);
const w = next ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH;
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
};
@@ -57,19 +60,15 @@ export const Header = () => {
"pt-4",
)}
>
{showProfileSelector && (
<div className={"ml-20"}>
<ProfileSelector />
</div>
)}
<div className={"ml-20"}>
<ProfileSelector />
</div>
<IconButton
icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon}
onClick={togglePanel}
/>
{showSettingsButton && (
<IconButton icon={SettingsIcon} onClick={openSettings} />
)}
<IconButton icon={SettingsIcon} onClick={openSettings} />
</div>
);
};

View File

@@ -1,13 +1,13 @@
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
import { useOutletContext } from "react-router-dom";
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
import { Peers } from "@/modules/peers/Peers.tsx";
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
import type { MainOutletContext } from "@/layouts/AppLayout.tsx";
import { cn } from "@/lib/cn";
export const Main = () => {
const { connectionLayout, expanded } = useAppearance();
const { expanded } = useOutletContext<MainOutletContext>();
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div
@@ -16,11 +16,7 @@ export const Main = () => {
expanded && "max-w-xs",
)}
>
{connectionLayout === "switch" ? (
<ConnectionStatusSwitch />
) : (
<ConnectionStatus />
)}
<ConnectionStatusSwitch />
<Navigation peersActive />
</div>
{expanded && (

View File

@@ -1,7 +1,6 @@
import { CardNavItem } from "@/components/CardNavItem.tsx";
import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react";
import deFlag from "@/assets/flags/1x1/de.svg";
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
type Props = {
peersActive?: boolean;
@@ -9,42 +8,34 @@ type Props = {
};
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
const { showPeersNav, showResourcesNav, showExitNodeNav } = useAppearance();
return (
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
{showPeersNav && (
<CardNavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}
description={"17 of 25 Online"}
active={peersActive}
onClick={onPeersClick}
/>
)}
{showResourcesNav && (
<CardNavItem
icon={Layers3Icon}
title={"Resources"}
description={"13 of 16 Active"}
iconSize={14}
/>
)}
{showExitNodeNav && (
<CardNavItem
iconNode={
<img
src={deFlag}
alt={"Germany"}
className={
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
}
/>
}
title={"Exit Node Berlin"}
description={"100.92.14.37"}
/>
)}
<CardNavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}
description={"17 of 25 Online"}
active={peersActive}
onClick={onPeersClick}
/>
<CardNavItem
icon={Layers3Icon}
title={"Resources"}
description={"13 of 16 Active"}
iconSize={14}
/>
<CardNavItem
iconNode={
<img
src={deFlag}
alt={"Germany"}
className={
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
}
/>
}
title={"Exit Node Berlin"}
description={"100.92.14.37"}
/>
</nav>
);
};

View File

@@ -1,5 +1,4 @@
import { Outlet } from "react-router-dom";
import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
@@ -10,27 +9,26 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
// selector / panel toggle / settings icon.
//
// The 38px placeholder strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windows.go: the native title
// bar is invisible but the traffic-light buttons still float in the top-left
// corner. Without this strip the buttons would overlap the settings content.
// The strip is `wails-draggable` so users can move the window by dragging it.
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
// title bar is invisible but the traffic-light buttons still float in the
// top-left corner. Without this strip the buttons would overlap the settings
// content. The strip is `wails-draggable` so users can move the window by
// dragging it.
export const SettingsLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
<AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<div
className={
"wails-draggable cursor-default select-none h-[38px] shrink-0"
}
/>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<div
className={
"wails-draggable cursor-default select-none h-[38px] shrink-0"
}
/>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</div>
);
};

View File

@@ -1,86 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export type ConnectionLayout = "default" | "switch";
export type AppearanceState = {
connectionLayout: ConnectionLayout;
expanded: boolean;
showPeersNav: boolean;
showResourcesNav: boolean;
showExitNodeNav: boolean;
showProfileSelector: boolean;
showSettingsButton: boolean;
};
const STORAGE_KEY = "netbird:appearance";
const DEFAULTS: AppearanceState = {
connectionLayout: "default",
expanded: true,
showPeersNav: true,
showResourcesNav: true,
showExitNodeNav: true,
showProfileSelector: true,
showSettingsButton: true,
};
const readStored = (): AppearanceState => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
const parsed = JSON.parse(raw) as Partial<AppearanceState>;
return { ...DEFAULTS, ...parsed };
} catch {
return DEFAULTS;
}
};
type AppearanceContextValue = AppearanceState & {
setField: <K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => void;
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export const useAppearance = () => {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used inside AppearanceProvider");
}
return ctx;
};
export const AppearanceProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<AppearanceState>(() => readStored());
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// ignore quota / unavailable storage
}
}, [state]);
const setField = useCallback(
<K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => {
setState((s) => ({ ...s, [k]: v }));
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({ ...state, setField }),
[state, setField],
);
return (
<AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>
);
};

View File

@@ -7,7 +7,6 @@ import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsAppearance } from "@/modules/settings/SettingsAppearance.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
@@ -58,9 +57,6 @@ export const Settings = () => {
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"appearance"}>
<SettingsAppearance />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>

View File

@@ -1,49 +0,0 @@
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
export function SettingsAppearance() {
const {
showPeersNav,
showResourcesNav,
showExitNodeNav,
showProfileSelector,
showSettingsButton,
setField,
} = useAppearance();
return (
<SectionGroup title={"Interface"}>
<FancyToggleSwitch
value={showPeersNav}
onChange={(v) => setField("showPeersNav", v)}
label={"Peers"}
helpText={"Show the Peers item in the side navigation."}
/>
<FancyToggleSwitch
value={showResourcesNav}
onChange={(v) => setField("showResourcesNav", v)}
label={"Resources"}
helpText={"Show the Resources item in the side navigation."}
/>
<FancyToggleSwitch
value={showExitNodeNav}
onChange={(v) => setField("showExitNodeNav", v)}
label={"Exit Node"}
helpText={"Show the active exit node in the side navigation."}
/>
<FancyToggleSwitch
value={showProfileSelector}
onChange={(v) => setField("showProfileSelector", v)}
label={"Profile Selector"}
helpText={"Show the profile selector in the header."}
/>
<FancyToggleSwitch
value={showSettingsButton}
onChange={(v) => setField("showSettingsButton", v)}
label={"Settings Button"}
helpText={"Show the settings button in the header."}
/>
</SectionGroup>
);
}

View File

@@ -10,7 +10,6 @@ import {
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
SwatchBookIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
@@ -30,11 +29,6 @@ export const SettingsNavigationTriggers = () => {
icon={SlidersHorizontalIcon}
title={"General"}
/>
<VerticalTabs.Trigger
value={"appearance"}
icon={SwatchBookIcon}
title={"Appearance"}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}

View File

@@ -1,211 +0,0 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "@bindings/services";
import type { SystemEvent } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { cn } from "../lib/cn";
import { NetBirdConnectToggle, ConnectionState } from "../components/NetBirdConnectToggle";
export default function Status() {
const { status, error } = useStatus();
const navigate = useNavigate();
const connState = status?.status ?? "Idle";
const connected = connState === "Connected";
const connecting = connState === "Connecting";
// The daemon reports "NeedsLogin" on a fresh install or after a session
// expires; "SessionExpired" once a previously good session lapses. In both
// cases Connect would fail without a fresh SSO login.
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired" || connState === "LoginFailed";
// DaemonUnavailable is the synthetic status the UI emits when the gRPC
// socket is unreachable; Up/Down would just error, so the toggle is dead.
const unreachable = connState === "DaemonUnavailable";
// Always offer Login while we aren't Connected — including Connecting,
// because a stuck Login on the daemon leaves us in Connecting forever and
// the user has no other way out. Disconnect is the manual unstick path.
const showLogin = !connected;
const toggleState: ConnectionState =
connected ? ConnectionState.Connected
: connecting ? ConnectionState.Connecting
: ConnectionState.Disconnected;
const login = () => navigate("/login");
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
const disconnect = () => Connection.Down().catch(console.error);
const toggleConnection = () => {
if (needsLogin) {
navigate("/login");
return;
}
if (connected) {
disconnect();
return;
}
connect();
};
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StateIcon state={connState} />
<div>
<h1 className="text-xl font-semibold leading-none">{connState}</h1>
<p className="mt-1 text-sm text-nb-gray-500">
{status?.local.fqdn || "—"}
</p>
</div>
</div>
<div className="flex gap-2">
{needsLogin ? (
<Button onClick={login}>
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
) : (
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
{showLogin && !needsLogin && (
<Button onClick={login} variant="secondary">
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
)}
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
Disconnect
</Button>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
<AlertTriangle className="mt-0.5 h-4 w-4" strokeWidth={1.5} />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<InfoCard label="Local IP" value={status?.local.ip || "—"} />
<InfoCard label="Peers" value={String(status?.peers?.length ?? 0)} />
<LinkCard label="Management" link={status?.management} />
<LinkCard label="Signal" link={status?.signal} />
</div>
<Card>
<h2 className="mb-3 text-sm font-semibold text-nb-gray-700 dark:text-nb-gray-200">
Recent events
</h2>
{(() => {
const events = dedupEvents(status?.events ?? []).slice(0, 8);
if (events.length === 0) {
return <p className="text-sm text-nb-gray-500">No recent events.</p>;
}
return (
<ul className="space-y-2 text-sm">
{events.map((e, i) => (
<li key={`${e.id}-${i}`} className="flex gap-2">
<span className="shrink-0 font-mono text-xs text-nb-gray-500">
{e.severity}
</span>
<span className="text-nb-gray-700 dark:text-nb-gray-200">
{e.userMessage || e.message}
</span>
</li>
))}
</ul>
);
})()}
</Card>
<div className="flex justify-center py-6">
<NetBirdConnectToggle
state={toggleState}
onClick={toggleConnection}
disabled={unreachable}
/>
</div>
</div>
);
}
function StateIcon({ state }: { state: string }) {
const cls = "h-7 w-7";
switch (state) {
case "Connected":
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
case "Connecting":
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
case "Error":
return <AlertTriangle className={cn(cls, "text-red-500")} strokeWidth={1.5} />;
default:
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
}
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<Card>
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<p className="mt-1 truncate font-mono text-sm">{value}</p>
</Card>
);
}
// dedupEvents collapses repeated daemon events that carry the same logical
// content. The daemon emits one "new_version_available" event per check tick,
// so its 10-event ring buffer fills with duplicates after a quiet hour. Same
// goes for periodic "DNS unreachable" or "auth retry" events. We key by
// message + a small set of identity-bearing metadata fields and keep the
// newest occurrence (the events array is already in publish order).
function dedupEvents(events: SystemEvent[]): SystemEvent[] {
const seen = new Set<string>();
const out: SystemEvent[] = [];
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const md = e.metadata ?? {};
const key = [
e.severity,
e.category,
e.userMessage || e.message,
md["new_version_available"] ?? "",
md["enforced"] ?? "",
].join("|");
// eslint-disable-next-line no-console
console.log("[dedup]", { key, event: e });
if (seen.has(key)) continue;
seen.add(key);
out.unshift(e);
}
return out;
}
function LinkCard({
label,
link,
}: {
label: string;
link?: { url: string; connected: boolean; error?: string };
}) {
return (
<Card>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<span
className={cn(
"h-2 w-2 rounded-full",
link?.connected ? "bg-green-500" : "bg-nb-gray-400",
)}
/>
</div>
<p className="mt-1 truncate text-xs text-nb-gray-600 dark:text-nb-gray-300">
{link?.url || "—"}
</p>
{link?.error && (
<p className="mt-1 truncate text-xs text-red-500">{link.error}</p>
)}
</Card>
);
}

View File

@@ -162,9 +162,14 @@ func main() {
app.RegisterService(application.NewService(services.NewI18n(bundle)))
app.RegisterService(application.NewService(services.NewPreferences(prefStore)))
// Initial size matches AppearanceContext's default `expanded: false`
// (small / simple view). When the user has previously expanded the
// window, Header.tsx's mount effect resizes back up to 925 via
// Window.SetSize — that's a one-shot grow rather than a shrink, which
// reads better than starting wide and snapping narrow on every launch.
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "NetBird",
Width: 925,
Width: 380,
Height: 615,
Hidden: true,
BackgroundColour: application.NewRGB(24, 26, 29),