mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 21:59:56 +00:00
remove old code, add german lang
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,7 +36,6 @@ export {
|
||||
DebugBundleResult,
|
||||
Features,
|
||||
ForwardingRule,
|
||||
Language,
|
||||
LocalPeer,
|
||||
LogLevel,
|
||||
LoginParams,
|
||||
@@ -53,7 +52,6 @@ export {
|
||||
SetConfigParams,
|
||||
Status,
|
||||
SystemEvent,
|
||||
UIPreferences,
|
||||
UpParams,
|
||||
UpdateAvailable,
|
||||
UpdateProgress,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
}
|
||||
|
||||
34
client/ui/frontend/src/i18n/locales/de/common.json
Normal file
34
client/ui/frontend/src/i18n/locales/de/common.json
Normal 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."
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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...",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user