mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
add i18n to frontend
This commit is contained in:
@@ -2,32 +2,23 @@
|
||||
|
||||
This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; the React/TS frontend lives in `frontend/`; bindings between them are generated under `frontend/bindings/`.
|
||||
|
||||
> **Keep these notes current.** When working in this directory with Claude, update this file (and `frontend/CLAUDE.md` for frontend-only changes) whenever you add a service, change an event name, shift a convention, rename a key directory, or land any other change that future-you would want to know about before reading the code. The goal is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
|
||||
|
||||
## Layout
|
||||
|
||||
### Go (top-level package `main`)
|
||||
- `main.go` — app entry. Builds the gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, starts the in-process Linux SNI watcher, then the tray, then `peers.Watch`, then `app.Run`. Also wires `--daemon-addr`, `--log-file` (repeatable, defaults to `console`), `--log-level` flags.
|
||||
- `tray.go` — `Tray` struct and its menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns the per-status icon/dot, the Profiles submenu, the Connect/Disconnect swap, the About → Update flow, session-expired toast.
|
||||
- `tray_linux.go` — `init()` that sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process `org.kde.StatusNotifierWatcher` and XEmbed bridge so the tray works on minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without AppIndicator). See "Linux tray support" below.
|
||||
- `tray_watcher_other.go` — no-op stub on non-Linux builds.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. On Unix, SIGUSR1 brings the window forward. On Windows, a named event `Global\NetBirdQuickActionsTriggerEvent` does the same. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — `Conn` is a lazy, mutex-protected gRPC channel to the daemon. One `Conn` is shared by every service. `DaemonAddr()` returns `unix:///var/run/netbird.sock` on Linux/macOS and `tcp://127.0.0.1:41731` on Windows.
|
||||
- `icons.go` — `//go:embed` the tray/window PNGs from `assets/`. macOS uses template variants (`*-macos.png`); Linux ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`).
|
||||
- `desktop/desktop.go` — tiny helper returning `GetUIUserAgent()` (`netbird-desktop-ui/<version>`) for the gRPC dialer.
|
||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
|
||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
|
||||
- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`).
|
||||
|
||||
### Wails services (`services/*.go`)
|
||||
Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. See "Services rundown" below.
|
||||
Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. Frontend-facing details (TS signatures, push events, models) are in `frontend/WAILS-API.md`. After editing any `services/*.go` or the proto, regenerate with `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). `frontend/bindings/**` is gitignored.
|
||||
|
||||
### 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 `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 (`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`).
|
||||
|
||||
### Generated bindings
|
||||
- `frontend/bindings/**` — generated, **gitignored**, do not edit by hand. After editing any `services/*.go`, regenerate from this directory via `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). Fresh clones need to run this once before `pnpm typecheck` will succeed; `wails3 dev` regenerates on its own.
|
||||
For frontend-side conventions (routing, providers, contexts) see `frontend/CLAUDE.md`.
|
||||
|
||||
## Services rundown
|
||||
|
||||
@@ -45,6 +36,8 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Update` | `update.go` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin`. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
|
||||
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage`, persists, and broadcasts `netbird:preferences:changed`. |
|
||||
|
||||
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
|
||||
|
||||
@@ -56,27 +49,13 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
|
||||
## Events bus
|
||||
|
||||
`main.go` registers four event types so the frontend can subscribe with typed payloads:
|
||||
`main.go` registers four typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). Plus three plain-string events:
|
||||
|
||||
```go
|
||||
application.RegisterEvent[services.Status](services.EventStatus) // "netbird:status"
|
||||
application.RegisterEvent[services.SystemEvent](services.EventSystem) // "netbird:event"
|
||||
application.RegisterEvent[services.UpdateAvailable](services.EventUpdateAvailable) // "netbird:update:available"
|
||||
application.RegisterEvent[services.UpdateProgress](services.EventUpdateProgress) // "netbird:update:progress"
|
||||
```
|
||||
- `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow.
|
||||
- `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`.
|
||||
- `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere.
|
||||
|
||||
Two additional plain-string events flow between Go and JS without a typed payload:
|
||||
|
||||
- `EventTriggerLogin = "trigger-login"` — emitted by the tray (or other Go-side triggers) to ask the frontend's `startLogin()` orchestrator to begin an SSO flow.
|
||||
- `EventBrowserLoginCancel = "browser-login:cancel"` — emitted by the `BrowserLogin` window when the user clicks Cancel or closes the window (red X). `startLogin()` listens and tears down the pending daemon SSO wait.
|
||||
|
||||
Daemon connection status strings (`services/peers.go`) — mirror `internal.Status*` in `client/internal/state.go`:
|
||||
|
||||
```go
|
||||
StatusConnected, StatusConnecting, StatusIdle,
|
||||
StatusNeedsLogin, StatusLoginFailed, StatusSessionExpired,
|
||||
StatusDaemonUnavailable // synthetic, emitted by Peers when the socket is unreachable
|
||||
```
|
||||
Daemon connection status strings (`services/peers.go`) mirror `internal.Status*` in `client/internal/state.go`: `Connected`, `Connecting`, `Idle`, `NeedsLogin`, `LoginFailed`, `SessionExpired`, plus the synthetic `DaemonUnavailable` emitted by `Peers` when the socket is unreachable.
|
||||
|
||||
## Profile switching
|
||||
|
||||
@@ -116,149 +95,55 @@ Both windows are **destroyed** on close (mutex-guarded singleton; `closing` hook
|
||||
|
||||
The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel(); window.Hide()`). The user reaches "really quit" through the tray → Quit menu entry.
|
||||
|
||||
## Linux tray support (StatusNotifierWatcher + XEmbed)
|
||||
## Localisation (i18n)
|
||||
|
||||
Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control.
|
||||
The locale tree under `frontend/src/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
|
||||
|
||||
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
Adding a language: drop a `<code>/common.json`, append a row to `_index.json`, add the static import in `frontend/src/i18n/index.ts`, rebuild. Embed lives in `client/ui/main.go`'s `embed.FS`.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
Package layout:
|
||||
- `client/ui/i18n/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`.
|
||||
- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
|
||||
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
|
||||
|
||||
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpired.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
|
||||
|
||||
## Linux tray support
|
||||
|
||||
The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`.
|
||||
|
||||
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
||||
`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
|
||||
### Message dialogs
|
||||
|
||||
```ts
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
|
||||
await Dialogs.Info({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Warning({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Error({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Question({ Title, Message, Buttons?, Detached? });
|
||||
```
|
||||
|
||||
All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses.
|
||||
|
||||
`MessageDialogOptions` fields:
|
||||
- `Title?: string` — window title (short).
|
||||
- `Message?: string` — the body text.
|
||||
- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers.
|
||||
- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS).
|
||||
|
||||
### File dialogs
|
||||
|
||||
`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc).
|
||||
|
||||
### Per-OS behavior
|
||||
|
||||
| Platform | Behavior |
|
||||
|---|---|
|
||||
| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. |
|
||||
| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. |
|
||||
| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. |
|
||||
|
||||
Behavioural notes that affect us:
|
||||
- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`).
|
||||
- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.
|
||||
- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`.
|
||||
- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag.
|
||||
|
||||
### Frameless / custom-window dialogs (Go side)
|
||||
|
||||
When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options:
|
||||
- `Parent` — attach to a parent so OS treats it as a child.
|
||||
- `AlwaysOnTop: true` — float above the parent.
|
||||
- `Frameless: true` — no titlebar/chrome.
|
||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
||||
|
||||
We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window.
|
||||
|
||||
In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon.
|
||||
API surface — `Dialogs.Info` / `Warning` / `Error` / `Question` / `OpenFile` / `SaveFile`, options shape, per-OS behaviour, and the Go-side frameless-window pattern — lives in `WAILS-DIALOGS.md` (sibling). The conventions for **when** to use a native dialog vs inline UI are in the "Conventions" section below.
|
||||
|
||||
## Conventions in this codebase
|
||||
|
||||
### Errors → native dialogs
|
||||
|
||||
We surface user-actionable errors via `Dialogs.Error` rather than red inline text. This started with the profile selector and applies broadly to operation failures (config save, profile switch, debug bundle, update, etc.).
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, etc.) surface via `Dialogs.Error` with an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong". The dialog itself already says "Error" visually.
|
||||
|
||||
Pattern:
|
||||
```ts
|
||||
try {
|
||||
await SomeSvc.Operation(...);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Operation Failed", // short, action-named
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
```
|
||||
Confirmations use `Dialogs.Warning` with explicit `Buttons`. The promise resolves with the **button Label string**, not an index — pin the label into a variable before comparing (especially with i18n, where labels translate). Full API in `WAILS-DIALOGS.md`.
|
||||
|
||||
Title rules:
|
||||
- Action-named, short: "Switch Profile Failed", "Save Settings Failed", "Debug Bundle Failed".
|
||||
- Not "Error" / "Something went wrong" — the dialog already says that visually.
|
||||
|
||||
When **not** to use a native dialog:
|
||||
- **Form validation** (`Input.tsx`, URL-format checks, etc.) — inline next to the field. Native dialogs are too heavy for keystroke-driven feedback.
|
||||
- **Status/result chrome on a dedicated screen** — e.g. the `/update` and `/login` pages can show a brief "Update failed" header *in addition to* the dialog, so the screen isn't blank after dismissal.
|
||||
- **Transient link errors on the dashboard** (e.g. `link.error` on a management/signal card) — these flap in/out as the daemon recovers; an inline indicator is more appropriate than a dialog.
|
||||
- **Result notifications inside a success flow** — e.g. "bundle saved but upload failed" can stay inline since the operation otherwise succeeded.
|
||||
|
||||
### Confirmations
|
||||
Use `Dialogs.Warning` with explicit `Buttons`:
|
||||
```ts
|
||||
const r = await Dialogs.Warning({
|
||||
Title: "Delete Profile",
|
||||
Message: `Are you sure you want to delete "${name}"?`,
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true },
|
||||
{ Label: "Delete", IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (r !== "Delete") return;
|
||||
```
|
||||
Compare against the **Label string** returned, not an index.
|
||||
**Skip native dialogs** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline); dedicated screens like `/update` may show an additional inline header alongside the dialog so the screen isn't blank after dismissal.
|
||||
|
||||
### OS notifications
|
||||
|
||||
The tray uses Wails' built-in `notifications` service (`github.com/wailsapp/wails/v3/pkg/services/notifications`). One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing (`netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`).
|
||||
The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`.
|
||||
|
||||
OS notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne event.Manager.
|
||||
### Profile switching invariants
|
||||
|
||||
### Bindings & types
|
||||
Always import generated bindings from `@bindings/services` and types from `@bindings/services/models.js`. The path alias is set up in `tsconfig.json` / `vite.config.ts`.
|
||||
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` and `screens/Profiles.tsx` both call it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
|
||||
|
||||
After editing any `services/*.go` (or the underlying proto), regenerate:
|
||||
```
|
||||
wails3 generate bindings -clean=true -ts
|
||||
```
|
||||
|
||||
### Profile context
|
||||
|
||||
`modules/profile/ProfileContext.tsx` is the React-side source of truth for `username`, `activeProfile`, and the `profiles` list. It exposes `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`, and `refresh`.
|
||||
|
||||
Two important nuances:
|
||||
|
||||
1. **Two switch paths exist.** `screens/Profiles.tsx` calls `ProfileSwitcher.SwitchActive` (the Go-side single-source-of-truth path that also drives the optimistic-Connecting paint and the Peers suppression filter). `ProfileContext.switchProfile`, used elsewhere, still implements the reconnect policy in TS: it calls `Profiles.Switch` and, only if the daemon was actively online, follows up with `Connection.Down` + `Connection.Up`. The TS path skips `Peers.BeginProfileSwitch` so it won't paint optimistic Connecting through the tray. Prefer `ProfileSwitcher.SwitchActive` for new call sites.
|
||||
|
||||
2. **Don't call `Connection.Up` on an Idle/NeedsLogin daemon.** The daemon's internal 50s `waitForUp` will block until `DeadlineExceeded`. Both switch paths gate `Up` on a previously-online status (Connected/Connecting). Callers should not bring the connection up themselves outside this flow — `Connection.Up` is reserved for the explicit Connect button and the post-switch resume.
|
||||
**Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`.
|
||||
|
||||
## Build / dev tasks
|
||||
- `task dev` — Wails dev mode (live reload).
|
||||
- `task build` — production build for the current OS (`Taskfile.yml` dispatches to `build/{darwin,linux,windows}/Taskfile.yml`).
|
||||
- `task build:server` / `task run:server` / `task build:docker` / `task run:docker` — server-mode (HTTP, no GUI) variants. See `build/Taskfile.yml`.
|
||||
- `task generate:bindings` does **not** exist as a top-level alias — run `wails3 generate bindings -clean=true -ts` directly from this directory.
|
||||
|
||||
CLI flags (parsed in `main.go`):
|
||||
- `--daemon-addr <addr>` — gRPC address, default per `DaemonAddr()` (Unix socket on Linux/macOS, `tcp://127.0.0.1:41731` on Windows).
|
||||
- `--log-file <target>` — repeatable. Each value is `console`, `syslog`, or a file path. First user-provided value drops the seeded `console` default.
|
||||
- `--log-level <level>` — `trace|debug|info|warn|error` (default `info`).
|
||||
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
|
||||
|
||||
## Useful references
|
||||
- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern.
|
||||
- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details.
|
||||
- `frontend/WAILS-API.md` — frontend-facing binding signatures and model shapes.
|
||||
- Wails v3 dialog docs: https://v3.wails.io/features/dialogs/message/ and https://v3.wails.io/features/dialogs/custom/ (may 403 from some clients).
|
||||
- Wails v3 multiple-windows guidance: https://v3.wails.io/learn/multiple-windows/
|
||||
- Authoritative TS signatures: `frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
|
||||
8
client/ui/LINUX-TRAY.md
Normal file
8
client/ui/LINUX-TRAY.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Linux tray support (StatusNotifierWatcher + XEmbed)
|
||||
|
||||
Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control.
|
||||
|
||||
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
56
client/ui/WAILS-DIALOGS.md
Normal file
56
client/ui/WAILS-DIALOGS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
||||
`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
|
||||
See `CLAUDE.md` for project conventions on *when* to use these (errors vs. inline validation, confirmation flow, etc.).
|
||||
|
||||
## Message dialogs
|
||||
|
||||
```ts
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
|
||||
await Dialogs.Info({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Warning({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Error({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Question({ Title, Message, Buttons?, Detached? });
|
||||
```
|
||||
|
||||
All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses.
|
||||
|
||||
`MessageDialogOptions` fields:
|
||||
- `Title?: string` — window title (short).
|
||||
- `Message?: string` — the body text.
|
||||
- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers.
|
||||
- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS).
|
||||
|
||||
## File dialogs
|
||||
|
||||
`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc).
|
||||
|
||||
## Per-OS behavior
|
||||
|
||||
| Platform | Behavior |
|
||||
|---|---|
|
||||
| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. |
|
||||
| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. |
|
||||
| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. |
|
||||
|
||||
Behavioural notes that affect us:
|
||||
- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`).
|
||||
- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.
|
||||
- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`.
|
||||
- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag.
|
||||
|
||||
## Frameless / custom-window dialogs (Go side)
|
||||
|
||||
When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options:
|
||||
- `Parent` — attach to a parent so OS treats it as a child.
|
||||
- `AlwaysOnTop: true` — float above the parent.
|
||||
- `Frameless: true` — no titlebar/chrome.
|
||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
||||
|
||||
We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window.
|
||||
|
||||
In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon.
|
||||
@@ -2,47 +2,19 @@
|
||||
|
||||
This is the React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus two auxiliary windows (`/#/settings` and `/#/browser-login`) opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
|
||||
|
||||
> **Keep these notes current.** When working in this directory with Claude, update this file whenever you change conventions, rename a context/provider, shift the route table, add or remove a top-level dependency, or introduce a new cross-cutting feature (i18n, theming, telemetry, etc.). The aim is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
|
||||
|
||||
> **Work in progress.** Big chunks of the UI are still mocked, prototyped, or duplicated across screens that pre-date the current AppLayout. Anything marked "prototype" / "mocked" / "legacy" below should be assumed half-wired. The polished surface today is: the main connect toggle, the Settings window, the debug-bundle flow, the auto-update overlay, and the profile selector. Everything else is in flight.
|
||||
|
||||
## Stack
|
||||
## Stack & tooling
|
||||
|
||||
- **React 18** with `react-dom/client` + `<React.StrictMode>` (app.tsx).
|
||||
- **TypeScript 5.7**, `"strict": true`, `noUnusedLocals: true`, `noImplicitAny: false`, `jsx: react-jsx`.
|
||||
- **Vite 6** + `@vitejs/plugin-react`. Wails ships its own Vite plugin (`@wailsio/runtime/plugins/vite`) that's wired in for binding regen / runtime injection.
|
||||
- **React Router v7** (`HashRouter` — Wails serves a static bundle so hash-based routing avoids server-side fallback).
|
||||
- **Tailwind CSS 3** with `darkMode: "class"`. Class-merging via `cn(...inputs)` (`src/lib/cn.ts` — `twMerge(clsx(inputs))`).
|
||||
- **Radix UI primitives** for Dialog / DropdownMenu / Popover / RadioGroup / ScrollArea / Switch / Tabs / Tooltip / VisuallyHidden / Label.
|
||||
- **framer-motion** for the central connect-toggle animation only.
|
||||
- **lucide-react** for icons. **chroma-js** for the deterministic-color helper. **cmdk** is installed but not currently used.
|
||||
- **`@wailsio/runtime`** for `Dialogs`, `Events`, `Browser`, `Window` APIs.
|
||||
- **Package manager: pnpm** (`pnpm-lock.yaml`). No `package-lock.json` / `yarn.lock`.
|
||||
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by `NetBirdConnectToggle`. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
|
||||
|
||||
Scripts (`package.json`):
|
||||
## Path aliases & bindings
|
||||
|
||||
```
|
||||
pnpm dev # vite dev server (port 9245, host 127.0.0.1)
|
||||
pnpm build:dev # tsc + vite build, mode=development, --minify false
|
||||
pnpm build # tsc + vite build, mode=production
|
||||
pnpm preview # vite preview
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm format # prettier write on src/**
|
||||
pnpm format:check
|
||||
```
|
||||
`@/*` → `src/*`, `@bindings/*` → `bindings/github.com/netbirdio/netbird/client/ui/*` (set in both `tsconfig.json` and `vite.config.ts`). Canonical imports: `from "@bindings/services"` (functions) and `from "@bindings/services/models.js"` (types). A few legacy screens (`screens/Profiles.tsx`, `pages/Update.tsx`) still use deep `../../bindings/...` paths — treat as a smell.
|
||||
|
||||
`task dev` from `client/ui/` starts the Wails dev harness, which in turn runs `vite` (port `WAILS_VITE_PORT || 9245`).
|
||||
|
||||
## Path aliases
|
||||
|
||||
`tsconfig.json` and `vite.config.ts` agree on two aliases:
|
||||
|
||||
| Alias | Resolves to |
|
||||
|---|---|
|
||||
| `@/*` | `src/*` |
|
||||
| `@bindings/*` | `bindings/github.com/netbirdio/netbird/client/ui/*` |
|
||||
|
||||
So `import { Connection } from "@bindings/services"` and `import type { Status } from "@bindings/services/models.js"` are the canonical imports. **Don't** hand-write deep `../../bindings/github.com/...` paths — a few legacy screens (`screens/Profiles.tsx`, `pages/Update.tsx`) still do; treat that as a smell.
|
||||
|
||||
`bindings/` is **gitignored** — every file is generated and never hand-edited. Regenerate from `client/ui/` via `wails3 generate bindings -clean=true -ts`, or use the `pnpm bindings` shortcut from this directory. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` will fail until you run it once; `wails3 dev` regenerates on its own.
|
||||
`bindings/` is gitignored and fully generated. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` fails until you run `pnpm bindings` (or `wails3 generate bindings -clean=true -ts` from `client/ui/`) once. `wails3 dev` regenerates on its own.
|
||||
|
||||
## Routing (app.tsx)
|
||||
|
||||
@@ -64,62 +36,7 @@ So `import { Connection } from "@bindings/services"` and `import type { Status }
|
||||
|
||||
## Directory layout (src/)
|
||||
|
||||
```
|
||||
app.tsx # entry, routes, <SkeletonTheme>, welcome() console banner
|
||||
globals.css # Tailwind layers + custom CSS variables
|
||||
vite-env.d.ts
|
||||
|
||||
assets/ # flags/, fonts/, logos/ (svg)
|
||||
components/ # presentational primitives — see "Components" below
|
||||
hooks/ # useStatus.ts (currently the only hook here)
|
||||
layouts/ # AppLayout, SettingsLayout, Header, Main, MainRightSide,
|
||||
# Navigation, ConnectionStatus, ConnectionStatusSwitch
|
||||
lib/ # cn (tailwind merge), color (hash → hex), welcome (console art),
|
||||
# MainModuleContext (unused legacy)
|
||||
modules/ # feature folders that own their own contexts/state
|
||||
auto-update/ # ClientVersionContext + overlays/banners/badges
|
||||
debug-bundle/ # useDebugBundle hook + Provider wrapper
|
||||
peers/ # Peers UI (currently mockPeers; not wired to daemon data)
|
||||
profile/ # ProfileContext
|
||||
settings/ # Settings root + per-tab files + SettingsContext
|
||||
skeletons/ # SkeletonSettings
|
||||
pages/ # full-screen single-purpose pages routed via app.tsx
|
||||
BrowserLogin.tsx # auxiliary window content
|
||||
SessionExpired.tsx # prototype, no wiring
|
||||
Update.tsx # enforced-update install screen (real one)
|
||||
Debug.tsx # legacy debug bundle UI, superseded by SettingsTroubleshooting
|
||||
screens/ # in-window screens (mostly legacy; pre-AppLayout era)
|
||||
Peers.tsx # legacy peer-detail UI (uses real Peers.Get data)
|
||||
Networks.tsx # legacy networks UI
|
||||
Profiles.tsx # uses ProfileSwitcher.SwitchActive (current preferred path)
|
||||
Settings.tsx # legacy — superseded by modules/settings/Settings.tsx
|
||||
Update.tsx # legacy update page (different from pages/Update.tsx)
|
||||
QuickActions.tsx # legacy quick-action panel
|
||||
Debug.tsx # legacy
|
||||
```
|
||||
|
||||
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. Today: `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell).
|
||||
|
||||
## Generated bindings
|
||||
|
||||
Re-exported from `@bindings/services/index.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Connection, Debug, Forwarding, Networks, Peers,
|
||||
ProfileSwitcher, Profiles, Settings, Update, WindowManager,
|
||||
} from "@bindings/services";
|
||||
|
||||
import type {
|
||||
ActiveProfile, Config, ConfigParams, DebugBundleParams, DebugBundleResult,
|
||||
Features, ForwardingRule, LocalPeer, LogLevel, LoginParams, LoginResult,
|
||||
LogoutParams, Network, PeerLink, PeerStatus, PortInfo, PortRange, Profile,
|
||||
ProfileRef, SelectNetworksParams, SetConfigParams, Status, SystemEvent,
|
||||
UpParams, UpdateAvailable, UpdateProgress, UpdateResult, WaitSSOParams,
|
||||
} from "@bindings/services/models.js";
|
||||
```
|
||||
|
||||
Every service method returns a `$CancellablePromise<T>` (Wails3 wrapper) — call `.cancel()` to abort the underlying gRPC call. In practice we `await` them and never call `.cancel()`; the few stream-driven cases use `AbortController` (see `useDebugBundle`).
|
||||
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. **Today:** `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell). `lib/MainModuleContext.tsx` is exported but unused — candidate for deletion.
|
||||
|
||||
## Wails event bus
|
||||
|
||||
@@ -140,62 +57,66 @@ If you wire a new daemon-event subscriber on the TS side, prefer subscribing onc
|
||||
|
||||
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
|
||||
|
||||
### `useStatus` (hooks/useStatus.ts)
|
||||
- **`useStatus`** (`hooks/useStatus.ts`) — `{ status, error, refresh }`. Fetches `Peers.Get()` once, re-renders on every `netbird:status` push. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag.
|
||||
|
||||
Returns `{ status, error, refresh }`. Fetches `Peers.Get()` once, then re-renders on every `netbird:status` push. `refresh()` is for forcing a re-read after a user action (Connect / Disconnect) so the UI doesn't lag the event stream by a few hundred ms.
|
||||
- **`ProfileContext`** (`modules/profile/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`.
|
||||
|
||||
### `ProfileContext` (modules/profile/ProfileContext.tsx)
|
||||
- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `<SkeletonSettings/>` while `config === null` so tabs never see null. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"`.
|
||||
|
||||
Single source of truth for `username`, `activeProfile`, `profiles`. Exposes `refresh`, `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`.
|
||||
- **`DebugBundleProvider` + `useDebugBundle`** (`modules/debug-bundle/`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
|
||||
|
||||
**Caveat:** `ProfileContext.switchProfile` implements the reconnect policy in TS (Switch + conditional Down/Up gated on previous Connected/Connecting). The Go-side `ProfileSwitcher.SwitchActive` does the same thing **plus** drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. Prefer `ProfileSwitcher.SwitchActive` for new call sites — `screens/Profiles.tsx` already does. The duplicate logic in `ProfileContext` is on the cleanup list.
|
||||
- **`ClientVersionContext`** (`modules/auto-update/`) — derives `updateAvailable` / `updateVersion` from `Status.events` metadata (`new_version_available` key), exposes `triggerUpdate`, mounts `<UpdateAvailableBanner/>` + `<UpdatingOverlay/>` so every screen inherits them. **Dev preview flags at the top of the file** (`FORCE_UPDATE_AVAILABLE`, `FORCE_UPDATING`, `FORCE_VERSION`, `HIDE_UPDATE_AVAILABLE`, `FORCE_ERROR`, `FORCE_ERROR_MSG`) override daemon state for UI preview. `FORCE_UPDATE_AVAILABLE = true` is currently committed — flip back to `false` before a real release. `UpdateAvailableBanner` additionally returns null in `import.meta.env.DEV`.
|
||||
|
||||
### `SettingsContext` (modules/settings/SettingsContext.tsx)
|
||||
### Wide/narrow panel + no client-side persistence
|
||||
|
||||
Loads `SettingsSvc.GetConfig` for the active profile, then debounces every `setField` write (`SAVE_DEBOUNCE_MS = 400`). API:
|
||||
The `expanded` flag (380px ↔ 925px) lives in `AppLayout` as plain `useState(false)` — the only shell-layout knob. `Header.tsx` reads it via props and calls `Window.SetSize(w, 615)`; `Main.tsx` reads it via `MainOutletContext` to mount/unmount the right-side panel. Every app launch starts small. **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`). Nav-item visibility and header buttons are hardcoded to always-render (the old Appearance toggles are gone).
|
||||
|
||||
- `setField(k, v)` — optimistic update + debounced save. Use for toggles.
|
||||
- `saveField(k, v)` — flush pending + save immediately. Use for explicit Save buttons.
|
||||
- `saveFields(partial)` — same as `saveField` but for multiple keys at once (used by the Advanced tab's batched save).
|
||||
- `saveNow()` — flush pending without changing values.
|
||||
## Localisation (i18n)
|
||||
|
||||
While `config` is `null` the provider renders `<SkeletonSettings/>` instead of children — the actual tabs never need to handle a null config.
|
||||
Bootstrap lives in `src/i18n/index.ts` and is awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, statically imports every bundle JSON (`en/common.json`, `de/common.json`, `hu/common.json` today), initialises i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to the `netbird:preferences:changed` Wails event so a flip from any window (tray, settings, another renderer) calls `i18next.changeLanguage` here.
|
||||
|
||||
**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.
|
||||
**No first-run detection.** When no preferences file exists, `Preferences.Get()` returns `{language: "en"}` from the Go-side in-memory default. The frontend treats `en` as the fallback (i18next `fallbackLng: "en"`) and users pick a different language via the picker in `SettingsGeneral`. The Go store persists on the first explicit `SetLanguage`.
|
||||
|
||||
### Wide/narrow panel state
|
||||
The frontend deliberately uses **no `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job (settings via `SettingsContext.save → SetConfig`, language via `Preferences.SetLanguage`). The previous wide-panel and settings-tab persistence experiments were removed; every window opens at its baseline state.
|
||||
|
||||
There is no appearance context or localStorage. The `expanded` flag lives in `AppLayout` as plain `useState(false)` and is the only shell-layout knob. `Header.tsx` reads it via props (sets the panel-toggle icon and calls `Window.SetSize(925|380, 615)` on change); `Main.tsx` reads it via Outlet context (`MainOutletContext`) to decide whether to mount the right-side panel. Every app launch starts small — no cross-machine drift.
|
||||
|
||||
Nav-item visibility (Peers / Resources / Exit Node) and the header buttons (profile selector, settings) are hardcoded to always-render in `Navigation.tsx` and `Header.tsx` respectively; the previous toggles are gone along with the Appearance settings tab.
|
||||
|
||||
### `DebugBundleProvider` + `useDebugBundle` (modules/debug-bundle/)
|
||||
|
||||
Stateful hook driving the debug-bundle flow. Wrapped in a context so the troubleshooting tab inside the Settings window keeps the same stage if the user navigates away and back. Stages:
|
||||
|
||||
```
|
||||
idle → preparing-trace → reconnecting → capturing (per-second countdown) →
|
||||
restoring-level → bundling → uploading → done
|
||||
```
|
||||
|
||||
Cancellable via `AbortController` from any stage. On cancel the original log level is restored best-effort. `NETBIRD_UPLOAD_URL = https://upload.debug.netbird.io/upload-url` is hardcoded.
|
||||
|
||||
### `ClientVersionContext` (modules/auto-update/ClientVersionContext.tsx)
|
||||
|
||||
Reads `Status.events`, finds the most recent event whose metadata carries `new_version_available`, and exposes `{ updateAvailable, updateVersion, triggerUpdate, updating, updateError, dismissUpdateError }`. Mounts `<UpdateAvailableBanner/>` and the `<UpdatingOverlay/>` so any screen inherits the overlay without opting in.
|
||||
|
||||
**Dev preview flags at the top of the file** (flip and save to preview UI states without involving the daemon):
|
||||
**Usage in components.** Default to the hook:
|
||||
|
||||
```ts
|
||||
const FORCE_UPDATE_AVAILABLE = true; // currently TRUE — banner is forced on
|
||||
const FORCE_UPDATING = false;
|
||||
const FORCE_VERSION = "0.65.0";
|
||||
const HIDE_UPDATE_AVAILABLE = false; // hard-hide everything regardless of state
|
||||
const FORCE_ERROR: ForceError = null; // "timeout" | "cancel" | "fail" | null
|
||||
const FORCE_ERROR_MSG = "installer exited with code 1";
|
||||
import { useTranslation } from "react-i18next";
|
||||
const { t } = useTranslation();
|
||||
return <span>{t("settings.tabs.general")}</span>;
|
||||
// with placeholders:
|
||||
t("update.card.versionAvailable", { version: updateVersion })
|
||||
```
|
||||
|
||||
`FORCE_UPDATE_AVAILABLE = true` means the banner shows in production builds too right now. Flip it back to `false` before a real release. `UpdateAvailableBanner` additionally returns null in `import.meta.env.DEV` to avoid noise during `pnpm dev`.
|
||||
For strings outside React (event handlers in modules, `Dialogs.Error` titles set from `useDebugBundle`, `useManagementUrl`, `ProfileContext`, `SettingsContext`) import the i18next instance directly:
|
||||
|
||||
```ts
|
||||
import i18next from "@/i18n";
|
||||
await Dialogs.Error({ Title: i18next.t("settings.error.saveTitle"), Message: ... });
|
||||
```
|
||||
|
||||
**Confirm dialogs.** `Dialogs.Warning` resolves with the **button label string** — not an index. After translation, those labels change per language. Pin the label into a variable so the comparison stays correct:
|
||||
|
||||
```ts
|
||||
const confirmLabel = t("profile.delete.message"); // wrong example — show your real key
|
||||
const cancelLabel = t("common.cancel");
|
||||
const result = await Dialogs.Warning({ Title, Message, Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
]});
|
||||
if (result !== confirmLabel) return;
|
||||
```
|
||||
|
||||
Compare against the variable, never against an English literal.
|
||||
|
||||
**Bundle files.** Keys live in `src/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
|
||||
|
||||
**Adding a language.** Drop `src/i18n/locales/<code>/common.json` and append the row to `src/i18n/locales/_index.json`. That's it — `src/i18n/index.ts` discovers bundles via `import.meta.glob('./locales/*/common.json', { eager: true })`, so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
|
||||
|
||||
**Language picker.** `src/modules/settings/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to.
|
||||
|
||||
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
|
||||
|
||||
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
|
||||
|
||||
@@ -215,51 +136,15 @@ This is the only SSO entry point used by the polished Main UI. Legacy screens (`
|
||||
|
||||
## Components
|
||||
|
||||
`src/components/` holds presentational primitives (no daemon RPCs, no router):
|
||||
`src/components/` holds presentational primitives (no daemon RPCs, no router) — see the directory listing. Settings rows use `FancyToggleSwitch` inside `<SectionGroup title=…>` (section-group dimming via `disabled` → greyed + `pointer-events-none`). In-app modals use the Radix `Dialog` primitive in the main webview; the two auxiliary OS windows (Settings, BrowserLogin) are created Go-side via `WindowManager`.
|
||||
|
||||
- **Form / interactive:** `Button`, `IconButton`, `Input` (label + help text + reveal toggle + suffix + readonly + copy slot), `Switch`, `ToggleSwitch`, `FancyToggleSwitch` (label + helpText + value), `Label`, `HelpText`, `SearchInput`, `Tabs`, `VerticalTabs`, `CardSelect`, `CardNavItem`, `Card`.
|
||||
- **Layout / overlays:** `Dialog` (Radix wrapper with `Root/Trigger/Content/Title/Description/Footer`), `BottomSheet`, `Tooltip`, `StatusPanel`.
|
||||
- **Domain-specific:** `NetBirdConnectToggle` (the big animated brand circle — framer-motion + tailwind keyframes), `ProfileSelector`, `NewProfileDialog`, `Avatar`.
|
||||
## Dialogs convention
|
||||
|
||||
Settings rows mostly use `FancyToggleSwitch` inside `<SectionGroup title=…>`. Section group dimming is handled with `disabled` (greyed + `pointer-events-none`).
|
||||
|
||||
In-app modals (NewProfileDialog, the delete-profile confirm in `screens/Profiles.tsx`) use the Radix `Dialog` primitive inside the main webview. The two auxiliary OS windows (Settings, BrowserLogin) are created by Go via `WindowManager`, not by frontend code.
|
||||
|
||||
## Dialogs convention (recap)
|
||||
|
||||
Errors surface via `Dialogs.Error` from `@wailsio/runtime` with an action-named title:
|
||||
|
||||
```ts
|
||||
await Dialogs.Error({
|
||||
Title: "Save Settings Failed", // not "Error"
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
```
|
||||
|
||||
Confirmations use `Dialogs.Warning` with explicit `Buttons` and compare against the **Label string**, not an index:
|
||||
|
||||
```ts
|
||||
const r = await Dialogs.Warning({
|
||||
Title: "Delete Profile",
|
||||
Message: `Delete "${name}"?`,
|
||||
Buttons: [{ Label: "Cancel", IsCancel: true }, { Label: "Delete", IsDefault: true }],
|
||||
});
|
||||
if (r !== "Delete") return;
|
||||
```
|
||||
|
||||
When **not** to use native dialogs: inline form validation, transient link errors on the dashboard, "partial success" notes inside an otherwise-OK flow. See `../CLAUDE.md` for the full rules. The settings management-URL switch is a good example: `useManagementUrl` shows inline URL-format errors but throws up a `Dialogs.Warning` confirmation when the user is about to flip from self-hosted to NetBird Cloud (because that forces a reconnect/re-login).
|
||||
Errors → `Dialogs.Error` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `Dialogs.Warning` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
Custom colors in `tailwind.config.ts`:
|
||||
|
||||
- `nb-gray` — main neutral palette, 50–960. `nb-gray-950` (#181a1d) is the app background; tabs/cards step through 900/910/920/925/935/940 as you go deeper. `DEFAULT` is `nb-gray-950`.
|
||||
- `netbird` — brand orange. `DEFAULT` is `#f68330` (500-ish). Used for primary buttons, focused tab borders, the connect-toggle border ring.
|
||||
- `gray`, `red`, `yellow`, `green`, `blue`, `indigo`, `purple`, `pink` — Flowbite-style 50–900 palettes used in the legacy screens (`screens/*`). Avoid in new code — stick to `nb-gray` + `netbird` + the semantic dot colors (`green-500`, `red-500`, `yellow-500`).
|
||||
|
||||
Background image `bg-conic-netbird` and keyframes `pulse-reverse` / `spin-slow` / `ping-slow` (plus the animations `animate-pulse-slow`, `animate-pulse-slower`, `animate-spin-slow`, `animate-ping-slow`) are used by `NetBirdConnectToggle`.
|
||||
|
||||
Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono) — both shipped under `src/assets/fonts/`.
|
||||
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = `nb-gray-950`); `netbird` is brand orange (`#f68330`). The Flowbite-style `gray`/`red`/`yellow`/`...` palettes are legacy — only use them inside `screens/*`; new code sticks to `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by `NetBirdConnectToggle`. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), shipped under `src/assets/fonts/`.
|
||||
|
||||
## Wails-specific quirks
|
||||
|
||||
@@ -281,275 +166,11 @@ Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono) — both shipped u
|
||||
|
||||
## Wails Go API reference
|
||||
|
||||
Quick reference for every binding method and model shape exposed to the frontend. Generated from `services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
|
||||
|
||||
Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
|
||||
|
||||
### Imports
|
||||
|
||||
```ts
|
||||
// Services
|
||||
import {
|
||||
Connection, Peers, ProfileSwitcher, Profiles,
|
||||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||||
} from "@bindings/services";
|
||||
|
||||
// Models (types-only)
|
||||
import type {
|
||||
Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
|
||||
Profile, ProfileRef, ActiveProfile,
|
||||
Config, ConfigParams, SetConfigParams, Features,
|
||||
Network, SelectNetworksParams,
|
||||
ForwardingRule, PortInfo, PortRange,
|
||||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||||
} from "@bindings/services/models.js";
|
||||
```
|
||||
|
||||
### Push events
|
||||
|
||||
Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: <payload> }`.
|
||||
|
||||
| Event | Payload | Fires on |
|
||||
|---|---|---|
|
||||
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
|
||||
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
|
||||
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
|
||||
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
|
||||
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow; not currently wired on the TS side. |
|
||||
|
||||
The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
|
||||
|
||||
### `Connection`
|
||||
|
||||
```ts
|
||||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email
|
||||
Connection.Up(p: UpParams): Promise<void> // async on the daemon
|
||||
Connection.Down(): Promise<void>
|
||||
Connection.Logout(p: LogoutParams): Promise<void>
|
||||
Connection.OpenURL(url: string): Promise<void> // honors $BROWSER
|
||||
```
|
||||
|
||||
`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
|
||||
|
||||
Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
|
||||
|
||||
### `Peers`
|
||||
|
||||
```ts
|
||||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||
Peers.Watch(): Promise<void> // already invoked from main.go
|
||||
Peers.BeginProfileSwitch(): Promise<void>
|
||||
Peers.CancelProfileSwitch(): Promise<void>
|
||||
```
|
||||
|
||||
`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
|
||||
|
||||
### `ProfileSwitcher`
|
||||
|
||||
```ts
|
||||
ProfileSwitcher.SwitchActive(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
|
||||
|
||||
Reconnect policy (driven by `prevStatus` captured at entry):
|
||||
|
||||
| Previous status | Action | Optimistic UI | Suppressed events until new flow |
|
||||
|---|---|---|---|
|
||||
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||||
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||||
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||||
| Idle | Switch only | (no change) | — |
|
||||
|
||||
### `Profiles`
|
||||
|
||||
```ts
|
||||
Profiles.Username(): Promise<string> // current OS username
|
||||
Profiles.List(username: string): Promise<Profile[]>
|
||||
Profiles.GetActive(): Promise<ActiveProfile>
|
||||
Profiles.Switch(p: ProfileRef): Promise<void> // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
|
||||
Profiles.Add(p: ProfileRef): Promise<void>
|
||||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/<name>.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
|
||||
|
||||
### `Settings`
|
||||
|
||||
```ts
|
||||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||
Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||||
```
|
||||
|
||||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
||||
|
||||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||||
|
||||
`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
|
||||
|
||||
### `Networks`
|
||||
|
||||
```ts
|
||||
Networks.List(): Promise<Network[]>
|
||||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
```
|
||||
|
||||
`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
|
||||
|
||||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||||
|
||||
### `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||||
|
||||
### `Debug`
|
||||
|
||||
```ts
|
||||
Debug.GetLogLevel(): Promise<LogLevel>
|
||||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||
Debug.RevealFile(path: string): Promise<void> // OS file-manager focus
|
||||
```
|
||||
|
||||
**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
|
||||
|
||||
`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
|
||||
|
||||
### `Update`
|
||||
|
||||
```ts
|
||||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the outcome (long-running)
|
||||
Update.Quit(): Promise<void> // 100ms later, app.Quit()
|
||||
```
|
||||
|
||||
Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
|
||||
|
||||
### `WindowManager`
|
||||
|
||||
```ts
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
```
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
### Daemon `Status.status` values
|
||||
|
||||
Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
|
||||
| `"Connecting"` | Up in progress |
|
||||
| `"Connected"` | Tunnel up |
|
||||
| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
|
||||
| `"LoginFailed"` | Previous Login attempt errored |
|
||||
| `"SessionExpired"` | SSO token expired; needs re-Login |
|
||||
| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
|
||||
|
||||
The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
|
||||
|
||||
### Model field reference
|
||||
|
||||
`Status`:
|
||||
```ts
|
||||
{ status, daemonVersion: string;
|
||||
management: PeerLink; signal: PeerLink;
|
||||
local: LocalPeer;
|
||||
peers: PeerStatus[];
|
||||
events: SystemEvent[]; }
|
||||
```
|
||||
|
||||
`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
|
||||
|
||||
`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
|
||||
|
||||
`PeerStatus`:
|
||||
```ts
|
||||
{ ip, pubKey, fqdn, connStatus: string;
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
|
||||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
|
||||
bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
|
||||
relayAddress: string; // set when relayed=true
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[]; }
|
||||
```
|
||||
|
||||
`SystemEvent`:
|
||||
```ts
|
||||
{ id: string;
|
||||
severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
|
||||
category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
|
||||
message: string; // technical / log line
|
||||
userMessage: string; // human-friendly — render this
|
||||
timestamp: number; // unix seconds
|
||||
metadata: Record<string, string>; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
|
||||
```
|
||||
|
||||
`Profile`: `{ name: string; isActive: boolean; email: string }`.
|
||||
|
||||
`Config` (read-only mirror, all required):
|
||||
```ts
|
||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||
disableAutoConnect, serverSshAllowed,
|
||||
rosenpassEnabled, rosenpassPermissive,
|
||||
disableNotifications, lazyConnectionEnabled, blockInbound,
|
||||
networkMonitor, disableClientRoutes, disableServerRoutes,
|
||||
disableDns, disableIpv6, blockLanAccess,
|
||||
enableSshRoot, enableSshSftp,
|
||||
enableSshLocalPortForwarding, enableSshRemotePortForwarding,
|
||||
disableSshAuth: boolean; }
|
||||
```
|
||||
|
||||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
||||
|
||||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||||
|
||||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||||
|
||||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||||
|
||||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||||
|
||||
`PortRange`: `{ start, end: number }` (inclusive).
|
||||
|
||||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||||
|
||||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||||
|
||||
`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
|
||||
|
||||
`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
|
||||
|
||||
`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
|
||||
|
||||
`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
|
||||
|
||||
`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
|
||||
|
||||
`UpdateResult`: `{ success: boolean; errorMsg: string }`.
|
||||
|
||||
`UpdateAvailable`: `{ version: string; enforced: boolean }`.
|
||||
|
||||
`UpdateProgress`: `{ action: string; version: string }`.
|
||||
Full per-service binding signatures, push-event payloads, and model field shapes live in `WAILS-API.md` (sibling). Every service method returns `$CancellablePromise<T>` — `await` and ignore `.cancel()` in practice. Regenerate bindings via `pnpm bindings` after any Go-side change.
|
||||
|
||||
## Useful references
|
||||
|
||||
- `WAILS-API.md` (sibling) — full binding signatures, push events, and model shapes.
|
||||
- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
- Wails v3 docs (may 403 from some clients): https://v3.wails.io/
|
||||
- `../CLAUDE.md` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals.
|
||||
|
||||
292
client/ui/frontend/WAILS-API.md
Normal file
292
client/ui/frontend/WAILS-API.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Wails Go API reference (frontend)
|
||||
|
||||
Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
|
||||
|
||||
Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
// Services
|
||||
import {
|
||||
Connection, Peers, ProfileSwitcher, Profiles,
|
||||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||||
I18n, Preferences,
|
||||
} from "@bindings/services";
|
||||
|
||||
// Models (types-only)
|
||||
import type {
|
||||
Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
|
||||
Profile, ProfileRef, ActiveProfile,
|
||||
Config, ConfigParams, SetConfigParams, Features,
|
||||
Network, SelectNetworksParams,
|
||||
ForwardingRule, PortInfo, PortRange,
|
||||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||||
} from "@bindings/services/models.js";
|
||||
|
||||
// i18n / preferences models live in sibling packages, not services/models
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import type { UIPreferences } from "@bindings/preferences/models.js";
|
||||
```
|
||||
|
||||
## Push events
|
||||
|
||||
Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: <payload> }`.
|
||||
|
||||
| Event | Payload | Fires on |
|
||||
|---|---|---|
|
||||
| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
|
||||
| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
|
||||
| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/i18n/index.ts` subscribes and calls `i18next.changeLanguage`. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
|
||||
| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
|
||||
| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow; not currently wired on the TS side. |
|
||||
|
||||
The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
|
||||
|
||||
## `Connection`
|
||||
|
||||
```ts
|
||||
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email
|
||||
Connection.Up(p: UpParams): Promise<void> // async on the daemon
|
||||
Connection.Down(): Promise<void>
|
||||
Connection.Logout(p: LogoutParams): Promise<void>
|
||||
Connection.OpenURL(url: string): Promise<void> // honors $BROWSER
|
||||
```
|
||||
|
||||
`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
|
||||
|
||||
Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
|
||||
|
||||
## `Peers`
|
||||
|
||||
```ts
|
||||
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||
Peers.Watch(): Promise<void> // already invoked from main.go
|
||||
Peers.BeginProfileSwitch(): Promise<void>
|
||||
Peers.CancelProfileSwitch(): Promise<void>
|
||||
```
|
||||
|
||||
`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
|
||||
|
||||
## `ProfileSwitcher`
|
||||
|
||||
```ts
|
||||
ProfileSwitcher.SwitchActive(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
|
||||
|
||||
Reconnect policy (driven by `prevStatus` captured at entry):
|
||||
|
||||
| Previous status | Action | Optimistic UI | Suppressed events until new flow |
|
||||
|---|---|---|---|
|
||||
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||||
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||||
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||||
| Idle | Switch only | (no change) | — |
|
||||
|
||||
## `Profiles`
|
||||
|
||||
```ts
|
||||
Profiles.Username(): Promise<string> // current OS username
|
||||
Profiles.List(username: string): Promise<Profile[]>
|
||||
Profiles.GetActive(): Promise<ActiveProfile>
|
||||
Profiles.Switch(p: ProfileRef): Promise<void> // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
|
||||
Profiles.Add(p: ProfileRef): Promise<void>
|
||||
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||
```
|
||||
|
||||
`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/<name>.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
|
||||
|
||||
## `Settings`
|
||||
|
||||
```ts
|
||||
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||
Settings.SetConfig(p: SetConfigParams): Promise<void> // partial update
|
||||
Settings.GetFeatures(): Promise<Features> // operator-disabled UI sections
|
||||
```
|
||||
|
||||
`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
|
||||
|
||||
**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
|
||||
|
||||
`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
|
||||
|
||||
## `Networks`
|
||||
|
||||
```ts
|
||||
Networks.List(): Promise<Network[]>
|
||||
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
```
|
||||
|
||||
`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
|
||||
|
||||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||||
|
||||
## `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||||
|
||||
## `Debug`
|
||||
|
||||
```ts
|
||||
Debug.GetLogLevel(): Promise<LogLevel>
|
||||
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||
Debug.RevealFile(path: string): Promise<void> // OS file-manager focus
|
||||
```
|
||||
|
||||
**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
|
||||
|
||||
`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
|
||||
|
||||
## `Update`
|
||||
|
||||
```ts
|
||||
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||
Update.GetInstallerResult(): Promise<UpdateResult> // poll the outcome (long-running)
|
||||
Update.Quit(): Promise<void> // 100ms later, app.Quit()
|
||||
```
|
||||
|
||||
Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
|
||||
|
||||
## `WindowManager`
|
||||
|
||||
```ts
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
```
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
## `I18n`
|
||||
|
||||
```ts
|
||||
I18n.Languages(): Promise<Language[]> // from _index.json
|
||||
I18n.Bundle(code: LanguageCode): Promise<Record<string,string>> // full key→text map
|
||||
```
|
||||
|
||||
Source of truth is `frontend/src/i18n/locales/`. The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS.
|
||||
|
||||
## `Preferences`
|
||||
|
||||
```ts
|
||||
Preferences.Get(): Promise<UIPreferences> // { language: string }
|
||||
Preferences.SetLanguage(code: LanguageCode): Promise<void> // rejects on unknown code
|
||||
```
|
||||
|
||||
`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/i18n/index.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read.
|
||||
|
||||
## Daemon `Status.status` values
|
||||
|
||||
Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
|
||||
| `"Connecting"` | Up in progress |
|
||||
| `"Connected"` | Tunnel up |
|
||||
| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
|
||||
| `"LoginFailed"` | Previous Login attempt errored |
|
||||
| `"SessionExpired"` | SSO token expired; needs re-Login |
|
||||
| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
|
||||
|
||||
The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
|
||||
|
||||
## Model field reference
|
||||
|
||||
`Status`:
|
||||
```ts
|
||||
{ status, daemonVersion: string;
|
||||
management: PeerLink; signal: PeerLink;
|
||||
local: LocalPeer;
|
||||
peers: PeerStatus[];
|
||||
events: SystemEvent[]; }
|
||||
```
|
||||
|
||||
`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
|
||||
|
||||
`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
|
||||
|
||||
`PeerStatus`:
|
||||
```ts
|
||||
{ ip, pubKey, fqdn, connStatus: string;
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
|
||||
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
|
||||
bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
|
||||
relayAddress: string; // set when relayed=true
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[]; }
|
||||
```
|
||||
|
||||
`SystemEvent`:
|
||||
```ts
|
||||
{ id: string;
|
||||
severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
|
||||
category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
|
||||
message: string; // technical / log line
|
||||
userMessage: string; // human-friendly — render this
|
||||
timestamp: number; // unix seconds
|
||||
metadata: Record<string, string>; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
|
||||
```
|
||||
|
||||
`Profile`: `{ name: string; isActive: boolean; email: string }`.
|
||||
|
||||
`Config` (read-only mirror, all required):
|
||||
```ts
|
||||
{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
|
||||
wireguardPort, mtu, sshJwtCacheTtl: number;
|
||||
disableAutoConnect, serverSshAllowed,
|
||||
rosenpassEnabled, rosenpassPermissive,
|
||||
disableNotifications, lazyConnectionEnabled, blockInbound,
|
||||
networkMonitor, disableClientRoutes, disableServerRoutes,
|
||||
disableDns, disableIpv6, blockLanAccess,
|
||||
enableSshRoot, enableSshSftp,
|
||||
enableSshLocalPortForwarding, enableSshRemotePortForwarding,
|
||||
disableSshAuth: boolean; }
|
||||
```
|
||||
|
||||
`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
|
||||
|
||||
`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
|
||||
|
||||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||||
|
||||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||||
|
||||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||||
|
||||
`PortRange`: `{ start, end: number }` (inclusive).
|
||||
|
||||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||||
|
||||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||||
|
||||
`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
|
||||
|
||||
`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
|
||||
|
||||
`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
|
||||
|
||||
`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
|
||||
|
||||
`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
|
||||
|
||||
`UpdateResult`: `{ success: boolean; errorMsg: string }`.
|
||||
|
||||
`UpdateAvailable`: `{ version: string; enforced: boolean }`.
|
||||
|
||||
`UpdateProgress`: `{ action: string; version: string }`.
|
||||
@@ -31,9 +31,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.2.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
|
||||
67
client/ui/frontend/pnpm-lock.yaml
generated
67
client/ui/frontend/pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ dependencies:
|
||||
framer-motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@18.3.1)(react@18.3.1)
|
||||
i18next:
|
||||
specifier: ^26.2.0
|
||||
version: 26.2.0(typescript@5.9.3)
|
||||
lucide-react:
|
||||
specifier: ^0.469.0
|
||||
version: 0.469.0(react@18.3.1)
|
||||
@@ -65,6 +68,9 @@ dependencies:
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-i18next:
|
||||
specifier: ^17.0.8
|
||||
version: 17.0.8(i18next@26.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3)
|
||||
react-loading-skeleton:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0(react@18.3.1)
|
||||
@@ -264,6 +270,11 @@ packages:
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
dev: true
|
||||
|
||||
/@babel/runtime@7.29.2:
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dev: false
|
||||
|
||||
/@babel/template@7.28.6:
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1995,6 +2006,23 @@ packages:
|
||||
function-bind: 1.1.2
|
||||
dev: true
|
||||
|
||||
/html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
dev: false
|
||||
|
||||
/i18next@26.2.0(typescript@5.9.3):
|
||||
resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
dev: false
|
||||
|
||||
/is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2260,6 +2288,31 @@ packages:
|
||||
scheduler: 0.23.2
|
||||
dev: false
|
||||
|
||||
/react-i18next@17.0.8(i18next@26.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3):
|
||||
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.2.0(typescript@5.9.3)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
typescript: 5.9.3
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/react-loading-skeleton@3.5.0(react@18.3.1):
|
||||
resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==}
|
||||
peerDependencies:
|
||||
@@ -2552,7 +2605,6 @@ packages:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
@@ -2600,6 +2652,14 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.6.0(react@18.3.1):
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
dev: true
|
||||
@@ -2655,6 +2715,11 @@ packages:
|
||||
fsevents: 2.3.3
|
||||
dev: true
|
||||
|
||||
/void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
dev: true
|
||||
|
||||
@@ -13,30 +13,40 @@ import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import BrowserLogin from "@/pages/BrowserLogin.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
|
||||
welcome();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/browser-login" element={<BrowserLogin />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route path="/session-expired" element={<SessionExpired />} />
|
||||
<Route element={<SettingsLayout />}>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Main />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// Surface init failures in the console so a misconfigured glob
|
||||
// doesn't quietly blank the UI; render anyway with i18next in
|
||||
// whatever state it ended up in (t() will fall back to keys).
|
||||
console.error("i18n init failed:", e);
|
||||
})
|
||||
.finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/browser-login" element={<BrowserLogin />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route path="/session-expired" element={<SessionExpired />} />
|
||||
<Route element={<SettingsLayout />}>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Main />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
7
client/ui/frontend/src/assets/flags/1x1/en.svg
Normal file
7
client/ui/frontend/src/assets/flags/1x1/en.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 512 512">
|
||||
<path fill="#012169" d="M0 0h512v512H0z"/>
|
||||
<path fill="#FFF" d="M512 0v64L322 256l190 187v69h-67L254 324 68 512H0v-68l186-187L0 74V0h62l192 188L440 0z"/>
|
||||
<path fill="#C8102E" d="m184 324 11 34L42 512H0v-3zm124-12 54 8 150 147v45zM512 0 320 196l-4-44L466 0zM0 1l193 189-59-8L0 49z"/>
|
||||
<path fill="#FFF" d="M176 0v512h160V0zM0 176v160h512V176z"/>
|
||||
<path fill="#C8102E" d="M0 208v96h512v-96zM208 0v512h96V0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 505 B |
7
client/ui/frontend/src/assets/flags/4x3/en.svg
Normal file
7
client/ui/frontend/src/assets/flags/4x3/en.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 640 480">
|
||||
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0z"/>
|
||||
<path fill="#C8102E" d="m424 281 216 159v40L369 281zm-184 20 6 35L54 480H0zM640 0v3L391 191l2-44L590 0zM0 0l239 176h-60L0 42z"/>
|
||||
<path fill="#FFF" d="M241 0v480h160V0zM0 160v160h640V160z"/>
|
||||
<path fill="#C8102E" d="M0 193v96h640v-96zM273 0v480h96V0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
@@ -1,4 +1,5 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@/components/Dialog";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Button } from "@/components/Button";
|
||||
@@ -10,6 +11,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,17 +36,16 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-8 pt-2">
|
||||
<Dialog.Title>New Profile</Dialog.Title>
|
||||
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Profiles let you keep separate NetBird connections
|
||||
side by side. Give your profile a memorable name.
|
||||
{t("profile.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="e.g. Work"
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
@@ -56,14 +57,14 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Create
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
@@ -14,6 +15,7 @@ import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export const ProfileSelector = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
profiles,
|
||||
activeProfile,
|
||||
@@ -53,34 +55,38 @@ export const ProfileSelector = () => {
|
||||
const handleSelect = (name: string) => {
|
||||
setOpen(false);
|
||||
if (name === activeProfile) return;
|
||||
void guarded("Switch Profile Failed", () => switchProfile(name));
|
||||
void guarded(t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const cancelLabel = t("common.cancel");
|
||||
const confirmLabel = t("profile.deregister.confirm");
|
||||
const result = await Dialogs.Warning({
|
||||
Title: "Deregister Profile",
|
||||
Message: `Are you sure you want to deregister "${name}"? You will need to log in again to use it.`,
|
||||
Title: t("profile.deregister.title"),
|
||||
Message: t("profile.deregister.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true },
|
||||
{ Label: "Deregister", IsDefault: true },
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== "Deregister") return;
|
||||
void guarded("Deregister Profile Failed", () => logoutProfile(name));
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const cancelLabel = t("common.cancel");
|
||||
const confirmLabel = t("common.delete");
|
||||
const result = await Dialogs.Warning({
|
||||
Title: "Delete Profile",
|
||||
Message: `Are you sure you want to delete "${name}"? This action cannot be undone.`,
|
||||
Title: t("profile.delete.title"),
|
||||
Message: t("profile.delete.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true },
|
||||
{ Label: "Delete", IsDefault: true },
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== "Delete") return;
|
||||
void guarded("Delete Profile Failed", () => removeProfile(name));
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
};
|
||||
|
||||
const handleNewProfile = () => {
|
||||
@@ -89,10 +95,12 @@ export const ProfileSelector = () => {
|
||||
};
|
||||
|
||||
const handleCreateProfile = (name: string) => {
|
||||
void guarded("Create Profile Failed", () => addProfile(name));
|
||||
void guarded(t("profile.error.createTitle"), () => addProfile(name));
|
||||
};
|
||||
|
||||
const displayName = selected?.name ?? (loaded ? "No profile" : "Loading...");
|
||||
const displayName =
|
||||
selected?.name ??
|
||||
(loaded ? t("profile.selector.noProfile") : t("profile.selector.loading"));
|
||||
const initial = (selected?.name ?? "?").charAt(0).toUpperCase();
|
||||
const initialColor = generateColorFromString(selected?.name);
|
||||
|
||||
@@ -155,7 +163,7 @@ export const ProfileSelector = () => {
|
||||
<Search size={12} className="text-nb-gray-300 shrink-0" />
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Search profile by name..."
|
||||
placeholder={t("profile.selector.searchPlaceholder")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"outline-none border-none",
|
||||
@@ -170,11 +178,10 @@ export const ProfileSelector = () => {
|
||||
<Command.Empty>
|
||||
<div className="flex flex-col items-center text-center px-4 pt-2 pb-3">
|
||||
<h3 className="text-xs font-semibold text-nb-gray-200">
|
||||
No Profiles Found
|
||||
{t("profile.selector.emptyTitle")}
|
||||
</h3>
|
||||
<p className="text-[0.7rem] leading-snug text-nb-gray-400 mt-1 text-balance">
|
||||
Try a different search term or create a new
|
||||
profile.
|
||||
{t("profile.selector.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</Command.Empty>
|
||||
@@ -220,7 +227,7 @@ export const ProfileSelector = () => {
|
||||
>
|
||||
<PlusCircle size={12} className="text-netbird" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold">New Profile</span>
|
||||
<span className="text-xs font-semibold">{t("profile.selector.newProfile")}</span>
|
||||
</button>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
@@ -252,6 +259,7 @@ const ProfileRow = ({
|
||||
onDelete,
|
||||
deletable,
|
||||
}: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const initial = profile.name.charAt(0).toUpperCase();
|
||||
const initialColor = generateColorFromString(profile.name);
|
||||
@@ -300,7 +308,7 @@ const ProfileRow = ({
|
||||
"hover:bg-nb-gray-800 hover:text-nb-gray-200 outline-none",
|
||||
"data-[state=open]:bg-nb-gray-800 data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
aria-label="More options"
|
||||
aria-label={t("profile.selector.moreOptions")}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
@@ -328,7 +336,7 @@ const ProfileRow = ({
|
||||
)}
|
||||
>
|
||||
<UserMinus size={14} className="text-nb-gray-300" />
|
||||
<span>Deregister</span>
|
||||
<span>{t("profile.selector.deregister")}</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={!deletable}
|
||||
@@ -347,7 +355,7 @@ const ProfileRow = ({
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete Profile</span>
|
||||
<span>{t("profile.selector.delete")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SwitchItemGroup = ({ value, onChange, children, className }: Props)
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className={cn(
|
||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,5 +30,248 @@
|
||||
"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."
|
||||
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.save": "Speichern",
|
||||
"common.saveChanges": "Änderungen speichern",
|
||||
"common.saving": "Speichert…",
|
||||
"common.close": "Schließen",
|
||||
"common.delete": "Löschen",
|
||||
"common.create": "Erstellen",
|
||||
"common.add": "Hinzufügen",
|
||||
"common.remove": "Entfernen",
|
||||
"common.refresh": "Aktualisieren",
|
||||
"common.loading": "Lädt…",
|
||||
"common.netbird": "NetBird",
|
||||
|
||||
"connect.status.disconnected": "Getrennt",
|
||||
"connect.status.connecting": "Verbindet…",
|
||||
"connect.status.connected": "Verbunden",
|
||||
"connect.status.disconnecting": "Trennt…",
|
||||
"connect.status.daemonUnavailable": "Daemon nicht verfügbar",
|
||||
"connect.status.loginRequired": "Anmeldung erforderlich",
|
||||
|
||||
"connect.error.loginTitle": "Anmeldung fehlgeschlagen",
|
||||
"connect.error.connectTitle": "Verbindung fehlgeschlagen",
|
||||
"connect.error.disconnectTitle": "Trennen fehlgeschlagen",
|
||||
|
||||
"nav.peers.title": "Peers",
|
||||
"nav.peers.description": "{online} von {total} online",
|
||||
"nav.resources.title": "Ressourcen",
|
||||
"nav.resources.description": "{active} von {total} aktiv",
|
||||
"nav.exitNode.title": "Exit-Node {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
|
||||
"header.openSettings": "Einstellungen öffnen",
|
||||
"header.togglePanel": "Seitenleiste umschalten",
|
||||
|
||||
"profile.selector.loading": "Lädt…",
|
||||
"profile.selector.noProfile": "Kein Profil",
|
||||
"profile.selector.searchPlaceholder": "Profil nach Namen suchen…",
|
||||
"profile.selector.emptyTitle": "Keine Profile gefunden",
|
||||
"profile.selector.emptyDescription": "Versuchen Sie einen anderen Suchbegriff oder erstellen Sie ein neues Profil.",
|
||||
"profile.selector.newProfile": "Neues Profil",
|
||||
"profile.selector.moreOptions": "Weitere Optionen",
|
||||
"profile.selector.deregister": "Abmelden",
|
||||
"profile.selector.delete": "Profil löschen",
|
||||
|
||||
"profile.dialog.title": "Neues Profil",
|
||||
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
|
||||
"profile.dialog.placeholder": "z. B. Arbeit",
|
||||
|
||||
"profile.deregister.title": "Profil abmelden",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie \"{name}\" abmelden möchten? Sie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.deregister.confirm": "Abmelden",
|
||||
"profile.delete.title": "Profil löschen",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.error.switchTitle": "Profilwechsel fehlgeschlagen",
|
||||
"profile.error.deregisterTitle": "Abmeldung fehlgeschlagen",
|
||||
"profile.error.deleteTitle": "Löschen des Profils fehlgeschlagen",
|
||||
"profile.error.createTitle": "Erstellen des Profils fehlgeschlagen",
|
||||
"profile.error.loadTitle": "Laden der Profile fehlgeschlagen",
|
||||
|
||||
"settings.error.loadTitle": "Laden der Einstellungen fehlgeschlagen",
|
||||
"settings.error.saveTitle": "Speichern der Einstellungen fehlgeschlagen",
|
||||
"settings.error.debugBundleTitle": "Debug-Paket fehlgeschlagen",
|
||||
|
||||
"settings.tabs.general": "Allgemein",
|
||||
"settings.tabs.network": "Netzwerk",
|
||||
"settings.tabs.security": "Sicherheit",
|
||||
"settings.tabs.ssh": "SSH",
|
||||
"settings.tabs.advanced": "Erweitert",
|
||||
"settings.tabs.troubleshooting": "Fehlerbehebung",
|
||||
"settings.tabs.about": "Über",
|
||||
"settings.tabs.updateAvailable": "Update verfügbar",
|
||||
|
||||
"settings.general.section.general": "Allgemein",
|
||||
"settings.general.section.connection": "Verbindung",
|
||||
"settings.general.connectOnStartup.label": "Beim Start verbinden",
|
||||
"settings.general.connectOnStartup.help": "Beim Start des Dienstes automatisch eine Verbindung herstellen.",
|
||||
"settings.general.notifications.label": "Desktop-Benachrichtigungen",
|
||||
"settings.general.notifications.help": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen.",
|
||||
"settings.general.language.label": "Anzeigesprache",
|
||||
"settings.general.language.help": "Wählen Sie die Sprache der NetBird-Oberfläche.",
|
||||
"settings.general.language.search": "Sprache suchen…",
|
||||
"settings.general.language.empty": "Keine Sprachen gefunden.",
|
||||
"settings.general.management.label": "Management-Server",
|
||||
"settings.general.management.help": "Mit NetBird Cloud oder Ihrem eigenen self-hosted Management-Server verbinden. Änderungen lösen eine Neuverbindung des Clients aus.",
|
||||
"settings.general.management.cloud": "Cloud",
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?",
|
||||
"settings.general.management.switchCloudMessage": "Dadurch wird die Verbindung zu Ihrem self-hosted Management-Server getrennt und eine neue Verbindung zu NetBird Cloud hergestellt. Möglicherweise müssen Sie sich erneut anmelden.",
|
||||
"settings.general.management.switchCloudConfirm": "Zu Cloud wechseln",
|
||||
|
||||
"settings.network.section.connectivity": "Konnektivität",
|
||||
"settings.network.section.routingDns": "Routing & DNS",
|
||||
"settings.network.lazy.label": "Verzögerte Verbindungen",
|
||||
"settings.network.lazy.help": "Statt durchgehend aktive Verbindungen zu halten, aktiviert NetBird sie bei Bedarf anhand von Aktivität oder Signalisierung.",
|
||||
"settings.network.monitor.label": "Bei Netzwerkwechsel neu verbinden",
|
||||
"settings.network.monitor.help": "Das Netzwerk überwachen und bei Änderungen (z. B. WLAN-Wechsel, Ethernet-Änderungen oder Rückkehr aus dem Ruhezustand) automatisch neu verbinden.",
|
||||
"settings.network.dns.label": "DNS aktivieren",
|
||||
"settings.network.dns.help": "NetBird-verwaltete DNS-Einstellungen auf den Host-Resolver anwenden.",
|
||||
"settings.network.clientRoutes.label": "Client-Routen aktivieren",
|
||||
"settings.network.clientRoutes.help": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen.",
|
||||
"settings.network.serverRoutes.label": "Server-Routen aktivieren",
|
||||
"settings.network.serverRoutes.help": "Lokale Routen dieses Hosts an andere Peers ankündigen.",
|
||||
|
||||
"settings.security.section.firewall": "Firewall",
|
||||
"settings.security.section.encryption": "Verschlüsselung",
|
||||
"settings.security.blockInbound.label": "Eingehenden Verkehr blockieren",
|
||||
"settings.security.blockInbound.help": "Unaufgeforderte Verbindungen von Peers zu diesem Gerät und den von ihm gerouteten Netzwerken ablehnen. Ausgehender Verkehr ist nicht betroffen.",
|
||||
"settings.security.blockLan.label": "LAN-Zugriff blockieren",
|
||||
"settings.security.blockLan.help": "Verhindert, dass Peers Ihr lokales Netzwerk oder dessen Geräte erreichen, wenn dieses Gerät deren Verkehr routet.",
|
||||
"settings.security.rosenpass.label": "Quantenresistenz aktivieren",
|
||||
"settings.security.rosenpass.help": "Einen post-quanten Schlüsselaustausch via Rosenpass zusätzlich zu WireGuard® hinzufügen.",
|
||||
"settings.security.rosenpassPermissive.label": "Permissiven Modus aktivieren",
|
||||
"settings.security.rosenpassPermissive.help": "Verbindungen zu Peers ohne Quantenresistenz-Unterstützung erlauben.",
|
||||
|
||||
"settings.ssh.section.server": "Server",
|
||||
"settings.ssh.section.capabilities": "Funktionen",
|
||||
"settings.ssh.section.authentication": "Authentifizierung",
|
||||
"settings.ssh.server.label": "SSH-Server aktivieren",
|
||||
"settings.ssh.server.help": "Den NetBird SSH-Server auf diesem Host ausführen, damit andere Peers sich verbinden können.",
|
||||
"settings.ssh.root.label": "Root-Login erlauben",
|
||||
"settings.ssh.root.help": "Peers dürfen sich als root anmelden. Deaktivieren, um ein nicht-privilegiertes Konto zu erfordern.",
|
||||
"settings.ssh.sftp.label": "SFTP erlauben",
|
||||
"settings.ssh.sftp.help": "Dateien sicher über native SFTP- oder SCP-Clients übertragen.",
|
||||
"settings.ssh.localForward.label": "Lokale Portweiterleitung",
|
||||
"settings.ssh.localForward.help": "Verbundene Peers können lokale Ports zu von diesem Host erreichbaren Diensten tunneln.",
|
||||
"settings.ssh.remoteForward.label": "Remote-Portweiterleitung",
|
||||
"settings.ssh.remoteForward.help": "Verbundene Peers können Ports dieses Hosts an ihren eigenen Rechner weitergeben.",
|
||||
"settings.ssh.jwt.label": "JWT-Authentifizierung aktivieren",
|
||||
"settings.ssh.jwt.help": "Jede SSH-Sitzung gegen Ihren IdP für Identität und Audit prüfen. Deaktivieren, um sich nur auf Netzwerk-ACL-Richtlinien zu verlassen — sinnvoll, wenn kein IdP verfügbar ist.",
|
||||
"settings.ssh.jwtTtl.label": "JWT-Cache-TTL",
|
||||
"settings.ssh.jwtTtl.help": "Wie lange dieser Client ein JWT zwischenspeichert, bevor bei ausgehenden SSH-Verbindungen erneut nachgefragt wird. Auf 0 setzen, um den Cache zu deaktivieren und bei jeder Verbindung zu authentifizieren.",
|
||||
"settings.ssh.jwtTtl.suffix": "Sekunde(n)",
|
||||
|
||||
"settings.advanced.section.interface": "Schnittstelle",
|
||||
"settings.advanced.section.security": "Sicherheit",
|
||||
"settings.advanced.interfaceName.label": "Name",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
"settings.advanced.psk.help": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden.",
|
||||
|
||||
"settings.troubleshooting.section.title": "Debug-Paket",
|
||||
"settings.troubleshooting.intro": "Ein Debug-Paket hilft dem NetBird-Support bei der Untersuchung von Verbindungsproblemen. <br /> Es ist eine .zip-Datei mit Logs, Systemdetails und Debug-Informationen Ihres Geräts.",
|
||||
"settings.troubleshooting.anonymize.label": "Sensible Informationen anonymisieren",
|
||||
"settings.troubleshooting.anonymize.help": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs.",
|
||||
"settings.troubleshooting.systemInfo.label": "Systeminformationen einschließen",
|
||||
"settings.troubleshooting.systemInfo.help": "OS, Kernel, Netzwerkschnittstellen und Routing-Tabellen einschließen.",
|
||||
"settings.troubleshooting.upload.label": "Paket an NetBird-Server hochladen",
|
||||
"settings.troubleshooting.upload.help": "Lädt das Paket sicher hoch und gibt einen Upload-Schlüssel zurück. Teilen Sie den Schlüssel über GitHub oder Slack mit dem NetBird-Support, anstatt die Datei direkt anzuhängen.",
|
||||
"settings.troubleshooting.trace.label": "Trace-Logs erfassen",
|
||||
"settings.troubleshooting.trace.help": "Erhöht das Logging auf TRACE und schaltet NetBird kurz aus und wieder ein, um Verbindungs-Logs zu erfassen. Das vorherige Level wird nach Erstellung des Pakets wiederhergestellt.",
|
||||
"settings.troubleshooting.duration.label": "Aufzeichnungsdauer",
|
||||
"settings.troubleshooting.duration.help": "Wie lange Trace-Logs vor der Paketerstellung erfasst werden sollen.",
|
||||
"settings.troubleshooting.duration.suffix": "Minute(n)",
|
||||
"settings.troubleshooting.create": "Paket erstellen",
|
||||
"settings.troubleshooting.progress.description": "Logs, Systemdetails und Verbindungszustand werden gesammelt. Dies dauert in der Regel einen Moment — lassen Sie dieses Fenster geöffnet, bis es abgeschlossen ist.",
|
||||
"settings.troubleshooting.cancelling": "Wird abgebrochen…",
|
||||
"settings.troubleshooting.done.uploadedTitle": "Debug-Paket erfolgreich hochgeladen!",
|
||||
"settings.troubleshooting.done.savedTitle": "Paket gespeichert",
|
||||
"settings.troubleshooting.done.uploadedDescription": "Teilen Sie den unten angezeigten Upload-Schlüssel mit dem NetBird-Support. Eine lokale Kopie wurde ebenfalls auf Ihrem Gerät gespeichert.",
|
||||
"settings.troubleshooting.done.savedDescription": "Ihr Debug-Paket wurde lokal gespeichert.",
|
||||
"settings.troubleshooting.done.copyKey": "Schlüssel kopieren",
|
||||
"settings.troubleshooting.done.openFolder": "Ordner öffnen",
|
||||
"settings.troubleshooting.done.openFileLocation": "Speicherort öffnen",
|
||||
"settings.troubleshooting.uploadFailedWithReason": "Upload fehlgeschlagen: {reason} Das Paket wurde trotzdem lokal gespeichert.",
|
||||
"settings.troubleshooting.uploadFailed": "Upload fehlgeschlagen. Das Paket wurde trotzdem lokal gespeichert.",
|
||||
"settings.troubleshooting.stage.preparingTrace": "Wechsel zu Trace-Logging…",
|
||||
"settings.troubleshooting.stage.reconnecting": "NetBird wird neu verbunden…",
|
||||
"settings.troubleshooting.stage.capturing": "Logs werden erfasst — {elapsed} / {total}",
|
||||
"settings.troubleshooting.stage.restoring": "Vorheriges Log-Level wird wiederhergestellt…",
|
||||
"settings.troubleshooting.stage.bundling": "Debug-Paket wird erstellt…",
|
||||
"settings.troubleshooting.stage.uploading": "Wird zu NetBird hochgeladen…",
|
||||
"settings.troubleshooting.stage.cancelling": "Wird abgebrochen…",
|
||||
|
||||
"settings.about.client": "NetBird Client v{version}",
|
||||
"settings.about.gui": "Oberfläche v{version}",
|
||||
"settings.about.copyright": "© {year} NetBird. Alle Rechte vorbehalten.",
|
||||
"settings.about.links.imprint": "Impressum",
|
||||
"settings.about.links.privacy": "Datenschutz",
|
||||
"settings.about.links.cla": "CLA",
|
||||
"settings.about.links.terms": "Nutzungsbedingungen",
|
||||
|
||||
"update.banner.message": "NetBird {version} ist installationsbereit.",
|
||||
"update.banner.later": "Später",
|
||||
"update.banner.installNow": "Jetzt installieren",
|
||||
"update.card.versionAvailable": "Version {version} ist verfügbar.",
|
||||
"update.card.whatsNew": "Was ist neu?",
|
||||
"update.card.installNow": "Jetzt installieren",
|
||||
"update.card.getInstaller": "Installer holen",
|
||||
"update.card.lastChecked": "Zuletzt geprüft am {date}",
|
||||
"update.card.changelog": "Änderungsprotokoll",
|
||||
"update.card.checkForUpdates": "Nach Updates suchen",
|
||||
"update.header.tooltip": "Update verfügbar",
|
||||
"update.overlay.updatingVersion": "NetBird wird auf v{version} aktualisiert",
|
||||
"update.overlay.updating": "NetBird wird aktualisiert",
|
||||
"update.overlay.description": "Eine neuere Version ist verfügbar und wird installiert. NetBird startet nach Abschluss des Updates automatisch neu.",
|
||||
"update.overlay.error.timeoutTitle": "Update dauert zu lange",
|
||||
"update.overlay.error.timeoutDescription": "Die Installation von {target} hat zu lange gedauert und wurde nicht abgeschlossen.",
|
||||
"update.overlay.error.canceledTitle": "Update wurde abgebrochen",
|
||||
"update.overlay.error.canceledDescription": "Das Update auf {target} wurde vor dem Abschluss abgebrochen.",
|
||||
"update.overlay.error.failTitle": "Update konnte nicht installiert werden",
|
||||
"update.overlay.error.failDescription": "{target} konnte nicht installiert werden.",
|
||||
"update.overlay.error.unknownMessage": "unbekannter Fehler",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "die neue Version",
|
||||
|
||||
"update.page.versionLine": "Client wird aktualisiert auf: {version}.",
|
||||
"update.page.versionLineGeneric": "Client wird aktualisiert.",
|
||||
"update.page.outdated": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version.",
|
||||
"update.page.status.running": "Aktualisiert",
|
||||
"update.page.status.timeout": "Zeitüberschreitung beim Update. Bitte erneut versuchen.",
|
||||
"update.page.status.canceled": "Update abgebrochen.",
|
||||
"update.page.status.failed": "Update fehlgeschlagen: {message}",
|
||||
"update.page.status.unknownError": "unbekannter Update-Fehler",
|
||||
"update.page.failedTitle": "Update fehlgeschlagen",
|
||||
"update.page.timeoutMessage": "Zeitüberschreitung beim Update.",
|
||||
"update.page.dontClose": "Bitte schließen Sie dieses Fenster nicht.",
|
||||
"update.page.updating": "Aktualisiert…",
|
||||
"update.page.complete": "Update abgeschlossen",
|
||||
"update.page.failed": "Update fehlgeschlagen",
|
||||
|
||||
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
|
||||
"browserLogin.description": "Bitte schließen Sie die Kontoauthentifizierung im Browser-Tab ab und fahren Sie dort fort.",
|
||||
"browserLogin.waiting": "Warten auf Anmeldung…",
|
||||
"browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?",
|
||||
"browserLogin.tryAgain": "Erneut versuchen",
|
||||
|
||||
"sessionExpired.title": "Sitzung abgelaufen",
|
||||
"sessionExpired.description": "Ihre NetBird-Sitzung ist abgelaufen. Melden Sie sich erneut an, damit Ihre Geräte verbunden bleiben.",
|
||||
"sessionExpired.later": "Später",
|
||||
"sessionExpired.signIn": "Anmelden",
|
||||
|
||||
"peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen",
|
||||
"peers.filter.all": "Alle",
|
||||
"peers.filter.online": "Online",
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "Keine Peers entsprechen den aktuellen Filtern.",
|
||||
|
||||
"quickActions.connect": "Verbinden",
|
||||
"quickActions.disconnect": "Trennen"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,248 @@
|
||||
"notify.error.disconnect": "Failed to disconnect",
|
||||
"notify.error.switchProfile": "Failed to switch to {profile}",
|
||||
"notify.sessionExpired.title": "NetBird session expired",
|
||||
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again."
|
||||
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
"common.saveChanges": "Save Changes",
|
||||
"common.saving": "Saving…",
|
||||
"common.close": "Close",
|
||||
"common.delete": "Delete",
|
||||
"common.create": "Create",
|
||||
"common.add": "Add",
|
||||
"common.remove": "Remove",
|
||||
"common.refresh": "Refresh",
|
||||
"common.loading": "Loading…",
|
||||
"common.netbird": "NetBird",
|
||||
|
||||
"connect.status.disconnected": "Disconnected",
|
||||
"connect.status.connecting": "Connecting...",
|
||||
"connect.status.connected": "Connected",
|
||||
"connect.status.disconnecting": "Disconnecting...",
|
||||
"connect.status.daemonUnavailable": "Daemon unavailable",
|
||||
"connect.status.loginRequired": "Login required",
|
||||
|
||||
"connect.error.loginTitle": "Login Failed",
|
||||
"connect.error.connectTitle": "Connect Failed",
|
||||
"connect.error.disconnectTitle": "Disconnect Failed",
|
||||
|
||||
"nav.peers.title": "Peers",
|
||||
"nav.peers.description": "{online} of {total} Online",
|
||||
"nav.resources.title": "Resources",
|
||||
"nav.resources.description": "{active} of {total} Active",
|
||||
"nav.exitNode.title": "Exit Node {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
|
||||
"header.openSettings": "Open settings",
|
||||
"header.togglePanel": "Toggle side panel",
|
||||
|
||||
"profile.selector.loading": "Loading...",
|
||||
"profile.selector.noProfile": "No profile",
|
||||
"profile.selector.searchPlaceholder": "Search profile by name...",
|
||||
"profile.selector.emptyTitle": "No Profiles Found",
|
||||
"profile.selector.emptyDescription": "Try a different search term or create a new profile.",
|
||||
"profile.selector.newProfile": "New Profile",
|
||||
"profile.selector.moreOptions": "More options",
|
||||
"profile.selector.deregister": "Deregister",
|
||||
"profile.selector.delete": "Delete Profile",
|
||||
|
||||
"profile.dialog.title": "New Profile",
|
||||
"profile.dialog.description": "Profiles let you keep separate NetBird connections side by side. Give your profile a memorable name.",
|
||||
"profile.dialog.placeholder": "e.g. Work",
|
||||
|
||||
"profile.deregister.title": "Deregister Profile",
|
||||
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
|
||||
"profile.deregister.confirm": "Deregister",
|
||||
"profile.delete.title": "Delete Profile",
|
||||
"profile.delete.message": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"profile.error.switchTitle": "Switch Profile Failed",
|
||||
"profile.error.deregisterTitle": "Deregister Profile Failed",
|
||||
"profile.error.deleteTitle": "Delete Profile Failed",
|
||||
"profile.error.createTitle": "Create Profile Failed",
|
||||
"profile.error.loadTitle": "Load Profiles Failed",
|
||||
|
||||
"settings.error.loadTitle": "Load Settings Failed",
|
||||
"settings.error.saveTitle": "Save Settings Failed",
|
||||
"settings.error.debugBundleTitle": "Debug Bundle Failed",
|
||||
|
||||
"settings.tabs.general": "General",
|
||||
"settings.tabs.network": "Network",
|
||||
"settings.tabs.security": "Security",
|
||||
"settings.tabs.ssh": "SSH",
|
||||
"settings.tabs.advanced": "Advanced",
|
||||
"settings.tabs.troubleshooting": "Troubleshooting",
|
||||
"settings.tabs.about": "About",
|
||||
"settings.tabs.updateAvailable": "Update Available",
|
||||
|
||||
"settings.general.section.general": "General",
|
||||
"settings.general.section.connection": "Connection",
|
||||
"settings.general.connectOnStartup.label": "Connect on Startup",
|
||||
"settings.general.connectOnStartup.help": "Automatically establish a connection when the service starts.",
|
||||
"settings.general.notifications.label": "Desktop Notifications",
|
||||
"settings.general.notifications.help": "Show desktop notifications for new updates and connection events.",
|
||||
"settings.general.language.label": "Display Language",
|
||||
"settings.general.language.help": "Choose the language for the NetBird interface.",
|
||||
"settings.general.language.search": "Search language…",
|
||||
"settings.general.language.empty": "No languages match.",
|
||||
"settings.general.management.label": "Management Server",
|
||||
"settings.general.management.help": "Connect to NetBird Cloud or your own self-hosted management server. Changes will reconnect the client.",
|
||||
"settings.general.management.cloud": "Cloud",
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?",
|
||||
"settings.general.management.switchCloudMessage": "This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
|
||||
"settings.general.management.switchCloudConfirm": "Switch to Cloud",
|
||||
|
||||
"settings.network.section.connectivity": "Connectivity",
|
||||
"settings.network.section.routingDns": "Routing & DNS",
|
||||
"settings.network.lazy.label": "Lazy Connections",
|
||||
"settings.network.lazy.help": "Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling.",
|
||||
"settings.network.monitor.label": "Reconnect on Network Change",
|
||||
"settings.network.monitor.help": "Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep.",
|
||||
"settings.network.dns.label": "Enable DNS",
|
||||
"settings.network.dns.help": "Apply NetBird-managed DNS settings to the host resolver.",
|
||||
"settings.network.clientRoutes.label": "Enable Client Routes",
|
||||
"settings.network.clientRoutes.help": "Accept routes from other peers to reach their networks.",
|
||||
"settings.network.serverRoutes.label": "Enable Server Routes",
|
||||
"settings.network.serverRoutes.help": "Advertise this host's local routes to other peers.",
|
||||
|
||||
"settings.security.section.firewall": "Firewall",
|
||||
"settings.security.section.encryption": "Encryption",
|
||||
"settings.security.blockInbound.label": "Block Inbound Traffic",
|
||||
"settings.security.blockInbound.help": "Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected.",
|
||||
"settings.security.blockLan.label": "Block LAN Access",
|
||||
"settings.security.blockLan.help": "Prevent peers from reaching your local network or its devices when this device routes their traffic.",
|
||||
"settings.security.rosenpass.label": "Enable Quantum-Resistance",
|
||||
"settings.security.rosenpass.help": "Add a post-quantum key exchange via Rosenpass on top of WireGuard®.",
|
||||
"settings.security.rosenpassPermissive.label": "Enable Permissive Mode",
|
||||
"settings.security.rosenpassPermissive.help": "Allow connections to peers without quantum-resistance support.",
|
||||
|
||||
"settings.ssh.section.server": "Server",
|
||||
"settings.ssh.section.capabilities": "Capabilities",
|
||||
"settings.ssh.section.authentication": "Authentication",
|
||||
"settings.ssh.server.label": "Enable SSH Server",
|
||||
"settings.ssh.server.help": "Run the NetBird SSH server on this host so other peers can connect to it.",
|
||||
"settings.ssh.root.label": "Allow Root Login",
|
||||
"settings.ssh.root.help": "Let peers sign in as the root user. Disable to require a non-privileged account.",
|
||||
"settings.ssh.sftp.label": "Allow SFTP",
|
||||
"settings.ssh.sftp.help": "Transfer files securely using native SFTP or SCP clients.",
|
||||
"settings.ssh.localForward.label": "Local Port Forwarding",
|
||||
"settings.ssh.localForward.help": "Let connecting peers tunnel local ports to services reachable from this host.",
|
||||
"settings.ssh.remoteForward.label": "Remote Port Forwarding",
|
||||
"settings.ssh.remoteForward.help": "Let connecting peers expose ports on this host back to their own machine.",
|
||||
"settings.ssh.jwt.label": "Enable JWT Authentication",
|
||||
"settings.ssh.jwt.help": "Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available.",
|
||||
"settings.ssh.jwtTtl.label": "JWT Cache TTL",
|
||||
"settings.ssh.jwtTtl.help": "How long this client caches a JWT before prompting again on outgoing SSH connections. Set to 0 to disable caching and authenticate on every connection.",
|
||||
"settings.ssh.jwtTtl.suffix": "Second(s)",
|
||||
|
||||
"settings.advanced.section.interface": "Interface",
|
||||
"settings.advanced.section.security": "Security",
|
||||
"settings.advanced.interfaceName.label": "Name",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
"settings.advanced.psk.help": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.",
|
||||
|
||||
"settings.troubleshooting.section.title": "Debug bundle",
|
||||
"settings.troubleshooting.intro": "A debug bundle helps NetBird support investigate connection problems. <br /> It's a .zip file with logs, system details and debug information from your device.",
|
||||
"settings.troubleshooting.anonymize.label": "Anonymize Sensitive Information",
|
||||
"settings.troubleshooting.anonymize.help": "Hides public IP addresses and non-NetBird domains from logs.",
|
||||
"settings.troubleshooting.systemInfo.label": "Include System Information",
|
||||
"settings.troubleshooting.systemInfo.help": "Include OS, kernel, network interfaces, and routing tables.",
|
||||
"settings.troubleshooting.upload.label": "Upload Bundle to NetBird Servers",
|
||||
"settings.troubleshooting.upload.help": "Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly.",
|
||||
"settings.troubleshooting.trace.label": "Capture Trace Logs",
|
||||
"settings.troubleshooting.trace.help": "Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built.",
|
||||
"settings.troubleshooting.duration.label": "Capture Duration",
|
||||
"settings.troubleshooting.duration.help": "How long to capture trace logs before generating the bundle.",
|
||||
"settings.troubleshooting.duration.suffix": "Minute(s)",
|
||||
"settings.troubleshooting.create": "Create Bundle",
|
||||
"settings.troubleshooting.progress.description": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.",
|
||||
"settings.troubleshooting.cancelling": "Cancelling…",
|
||||
"settings.troubleshooting.done.uploadedTitle": "Debug bundle successfully uploaded!",
|
||||
"settings.troubleshooting.done.savedTitle": "Bundle saved",
|
||||
"settings.troubleshooting.done.uploadedDescription": "Share the upload key below with NetBird support. A local copy was also saved on your device.",
|
||||
"settings.troubleshooting.done.savedDescription": "Your debug bundle has been saved locally.",
|
||||
"settings.troubleshooting.done.copyKey": "Copy Key",
|
||||
"settings.troubleshooting.done.openFolder": "Open Folder",
|
||||
"settings.troubleshooting.done.openFileLocation": "Open file location",
|
||||
"settings.troubleshooting.uploadFailedWithReason": "Upload failed: {reason} The bundle is still saved locally.",
|
||||
"settings.troubleshooting.uploadFailed": "Upload failed. The bundle is still saved locally.",
|
||||
"settings.troubleshooting.stage.preparingTrace": "Switching to trace logging…",
|
||||
"settings.troubleshooting.stage.reconnecting": "Reconnecting NetBird…",
|
||||
"settings.troubleshooting.stage.capturing": "Capturing logs — {elapsed} / {total}",
|
||||
"settings.troubleshooting.stage.restoring": "Restoring previous log level…",
|
||||
"settings.troubleshooting.stage.bundling": "Generating debug bundle…",
|
||||
"settings.troubleshooting.stage.uploading": "Uploading to NetBird…",
|
||||
"settings.troubleshooting.stage.cancelling": "Cancelling…",
|
||||
|
||||
"settings.about.client": "NetBird Client v{version}",
|
||||
"settings.about.gui": "GUI v{version}",
|
||||
"settings.about.copyright": "© {year} NetBird. All Rights Reserved.",
|
||||
"settings.about.links.imprint": "Imprint",
|
||||
"settings.about.links.privacy": "Privacy",
|
||||
"settings.about.links.cla": "CLA",
|
||||
"settings.about.links.terms": "Terms of Service",
|
||||
|
||||
"update.banner.message": "NetBird {version} is ready to install.",
|
||||
"update.banner.later": "Later",
|
||||
"update.banner.installNow": "Install now",
|
||||
"update.card.versionAvailable": "Version {version} is available.",
|
||||
"update.card.whatsNew": "What's new?",
|
||||
"update.card.installNow": "Install now",
|
||||
"update.card.getInstaller": "Get installer",
|
||||
"update.card.lastChecked": "Last checked on {date}",
|
||||
"update.card.changelog": "Changelog",
|
||||
"update.card.checkForUpdates": "Check for updates",
|
||||
"update.header.tooltip": "Update Available",
|
||||
"update.overlay.updatingVersion": "Updating NetBird to v{version}",
|
||||
"update.overlay.updating": "Updating NetBird",
|
||||
"update.overlay.description": "A newer version is available and is being installed. NetBird will restart automatically once the update is finished.",
|
||||
"update.overlay.error.timeoutTitle": "Update Is Taking Too Long",
|
||||
"update.overlay.error.timeoutDescription": "Installing {target} took too long and didn't finish.",
|
||||
"update.overlay.error.canceledTitle": "Update Was Stopped",
|
||||
"update.overlay.error.canceledDescription": "The update to {target} was canceled before it finished.",
|
||||
"update.overlay.error.failTitle": "Couldn't Install the Update",
|
||||
"update.overlay.error.failDescription": "{target} couldn't be installed.",
|
||||
"update.overlay.error.unknownMessage": "unknown error",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "the new version",
|
||||
|
||||
"update.page.versionLine": "Updating client to: {version}.",
|
||||
"update.page.versionLineGeneric": "Updating client.",
|
||||
"update.page.outdated": "Your client version is older than the auto-update version set in Management.",
|
||||
"update.page.status.running": "Updating",
|
||||
"update.page.status.timeout": "Update timed out. Please try again.",
|
||||
"update.page.status.canceled": "Update canceled.",
|
||||
"update.page.status.failed": "Update failed: {message}",
|
||||
"update.page.status.unknownError": "unknown update error",
|
||||
"update.page.failedTitle": "Update Failed",
|
||||
"update.page.timeoutMessage": "Update timed out.",
|
||||
"update.page.dontClose": "Please don't close this window.",
|
||||
"update.page.updating": "Updating…",
|
||||
"update.page.complete": "Update complete",
|
||||
"update.page.failed": "Update failed",
|
||||
|
||||
"browserLogin.title": "Continue in your browser to complete the login",
|
||||
"browserLogin.description": "Please complete the account authentication process in the browser tab and continue from there.",
|
||||
"browserLogin.waiting": "Waiting for sign-in…",
|
||||
"browserLogin.notSeeing": "Not seeing the browser tab?",
|
||||
"browserLogin.tryAgain": "Try again",
|
||||
|
||||
"sessionExpired.title": "Session expired",
|
||||
"sessionExpired.description": "Your NetBird session has expired. Sign in again to keep your devices connected.",
|
||||
"sessionExpired.later": "Later",
|
||||
"sessionExpired.signIn": "Sign in",
|
||||
|
||||
"peers.search.placeholder": "Search by peer name, DNS or IP address",
|
||||
"peers.filter.all": "All",
|
||||
"peers.filter.online": "Online",
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "No peers match the current filters.",
|
||||
|
||||
"quickActions.connect": "Connect",
|
||||
"quickActions.disconnect": "Disconnect"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,248 @@
|
||||
"notify.error.disconnect": "Bontás sikertelen",
|
||||
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
|
||||
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
|
||||
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra."
|
||||
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
|
||||
"common.cancel": "Mégse",
|
||||
"common.save": "Mentés",
|
||||
"common.saveChanges": "Módosítások mentése",
|
||||
"common.saving": "Mentés…",
|
||||
"common.close": "Bezárás",
|
||||
"common.delete": "Törlés",
|
||||
"common.create": "Létrehozás",
|
||||
"common.add": "Hozzáadás",
|
||||
"common.remove": "Eltávolítás",
|
||||
"common.refresh": "Frissítés",
|
||||
"common.loading": "Betöltés…",
|
||||
"common.netbird": "NetBird",
|
||||
|
||||
"connect.status.disconnected": "Lekapcsolva",
|
||||
"connect.status.connecting": "Csatlakozás…",
|
||||
"connect.status.connected": "Csatlakoztatva",
|
||||
"connect.status.disconnecting": "Bontás…",
|
||||
"connect.status.daemonUnavailable": "Daemon nem elérhető",
|
||||
"connect.status.loginRequired": "Bejelentkezés szükséges",
|
||||
|
||||
"connect.error.loginTitle": "Bejelentkezés sikertelen",
|
||||
"connect.error.connectTitle": "Csatlakozás sikertelen",
|
||||
"connect.error.disconnectTitle": "Bontás sikertelen",
|
||||
|
||||
"nav.peers.title": "Társak",
|
||||
"nav.peers.description": "{online} / {total} online",
|
||||
"nav.resources.title": "Erőforrások",
|
||||
"nav.resources.description": "{active} / {total} aktív",
|
||||
"nav.exitNode.title": "Kilépő csomópont: {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
|
||||
"header.openSettings": "Beállítások megnyitása",
|
||||
"header.togglePanel": "Oldalsó panel váltása",
|
||||
|
||||
"profile.selector.loading": "Betöltés…",
|
||||
"profile.selector.noProfile": "Nincs profil",
|
||||
"profile.selector.searchPlaceholder": "Profil keresése név alapján…",
|
||||
"profile.selector.emptyTitle": "Nem található profil",
|
||||
"profile.selector.emptyDescription": "Próbáljon más keresőkifejezést, vagy hozzon létre új profilt.",
|
||||
"profile.selector.newProfile": "Új profil",
|
||||
"profile.selector.moreOptions": "További műveletek",
|
||||
"profile.selector.deregister": "Leválasztás",
|
||||
"profile.selector.delete": "Profil törlése",
|
||||
|
||||
"profile.dialog.title": "Új profil",
|
||||
"profile.dialog.description": "A profilok lehetővé teszik, hogy különálló NetBird-kapcsolatokat tartson egymás mellett. Adjon profiljának egy könnyen megjegyezhető nevet.",
|
||||
"profile.dialog.placeholder": "pl. Munka",
|
||||
|
||||
"profile.deregister.title": "Profil leválasztása",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani a következőt: \"{name}\"? Újra be kell jelentkeznie a használatához.",
|
||||
"profile.deregister.confirm": "Leválasztás",
|
||||
"profile.delete.title": "Profil törlése",
|
||||
"profile.delete.message": "Biztosan törölni szeretné a következőt: \"{name}\"? Ez a művelet nem vonható vissza.",
|
||||
"profile.error.switchTitle": "Profilváltás sikertelen",
|
||||
"profile.error.deregisterTitle": "Leválasztás sikertelen",
|
||||
"profile.error.deleteTitle": "Profil törlése sikertelen",
|
||||
"profile.error.createTitle": "Profil létrehozása sikertelen",
|
||||
"profile.error.loadTitle": "Profilok betöltése sikertelen",
|
||||
|
||||
"settings.error.loadTitle": "Beállítások betöltése sikertelen",
|
||||
"settings.error.saveTitle": "Beállítások mentése sikertelen",
|
||||
"settings.error.debugBundleTitle": "Hibakeresési csomag sikertelen",
|
||||
|
||||
"settings.tabs.general": "Általános",
|
||||
"settings.tabs.network": "Hálózat",
|
||||
"settings.tabs.security": "Biztonság",
|
||||
"settings.tabs.ssh": "SSH",
|
||||
"settings.tabs.advanced": "Speciális",
|
||||
"settings.tabs.troubleshooting": "Hibaelhárítás",
|
||||
"settings.tabs.about": "Névjegy",
|
||||
"settings.tabs.updateAvailable": "Frissítés elérhető",
|
||||
|
||||
"settings.general.section.general": "Általános",
|
||||
"settings.general.section.connection": "Kapcsolat",
|
||||
"settings.general.connectOnStartup.label": "Csatlakozás indításkor",
|
||||
"settings.general.connectOnStartup.help": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít.",
|
||||
"settings.general.notifications.label": "Asztali értesítések",
|
||||
"settings.general.notifications.help": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről.",
|
||||
"settings.general.language.label": "Megjelenítési nyelv",
|
||||
"settings.general.language.help": "Válassza ki a NetBird felület nyelvét.",
|
||||
"settings.general.language.search": "Nyelv keresése…",
|
||||
"settings.general.language.empty": "Nincs találat.",
|
||||
"settings.general.management.label": "Kezelőszerver",
|
||||
"settings.general.management.help": "Csatlakozás a NetBird Cloudhoz vagy saját self-hosted kezelőszerverhez. A módosítások újracsatlakozást váltanak ki.",
|
||||
"settings.general.management.cloud": "Felhő",
|
||||
"settings.general.management.selfHosted": "Saját üzemeltetésű",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?",
|
||||
"settings.general.management.switchCloudMessage": "Ez megszünteti a kapcsolatot a saját üzemeltetésű kezelőszerverrel, és újra csatlakozik a NetBird Cloudhoz. Lehet, hogy újra be kell jelentkeznie.",
|
||||
"settings.general.management.switchCloudConfirm": "Váltás a felhőre",
|
||||
|
||||
"settings.network.section.connectivity": "Kapcsolódás",
|
||||
"settings.network.section.routingDns": "Útválasztás és DNS",
|
||||
"settings.network.lazy.label": "Késleltetett kapcsolatok",
|
||||
"settings.network.lazy.help": "Állandó kapcsolatok fenntartása helyett a NetBird igény szerint, aktivitás vagy jelzés alapján aktiválja azokat.",
|
||||
"settings.network.monitor.label": "Újracsatlakozás hálózatváltáskor",
|
||||
"settings.network.monitor.help": "A hálózat figyelése és automatikus újracsatlakozás változások (pl. Wi-Fi-váltás, Ethernet-változás vagy alvó állapotból való visszatérés) esetén.",
|
||||
"settings.network.dns.label": "DNS engedélyezése",
|
||||
"settings.network.dns.help": "A NetBird által kezelt DNS-beállítások alkalmazása a gazda DNS-feloldójára.",
|
||||
"settings.network.clientRoutes.label": "Kliens útvonalak engedélyezése",
|
||||
"settings.network.clientRoutes.help": "Más társak útvonalainak elfogadása az ő hálózataik eléréséhez.",
|
||||
"settings.network.serverRoutes.label": "Szerver útvonalak engedélyezése",
|
||||
"settings.network.serverRoutes.help": "Ennek a gazdának a helyi útvonalainak meghirdetése más társak számára.",
|
||||
|
||||
"settings.security.section.firewall": "Tűzfal",
|
||||
"settings.security.section.encryption": "Titkosítás",
|
||||
"settings.security.blockInbound.label": "Bejövő forgalom blokkolása",
|
||||
"settings.security.blockInbound.help": "Visszautasítja a társaktól érkező nem kért kapcsolatokat ezen eszközhöz és az általa irányított hálózatokhoz. A kimenő forgalmat nem érinti.",
|
||||
"settings.security.blockLan.label": "LAN-hozzáférés blokkolása",
|
||||
"settings.security.blockLan.help": "Megakadályozza, hogy a társak elérjék a helyi hálózatot vagy annak eszközeit, amikor ez az eszköz irányítja a forgalmukat.",
|
||||
"settings.security.rosenpass.label": "Kvantumellenálló titkosítás engedélyezése",
|
||||
"settings.security.rosenpass.help": "Post-kvantum kulcscsere hozzáadása Rosenpass segítségével a WireGuard® tetejére.",
|
||||
"settings.security.rosenpassPermissive.label": "Engedékeny mód engedélyezése",
|
||||
"settings.security.rosenpassPermissive.help": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli társakkal.",
|
||||
|
||||
"settings.ssh.section.server": "Szerver",
|
||||
"settings.ssh.section.capabilities": "Képességek",
|
||||
"settings.ssh.section.authentication": "Hitelesítés",
|
||||
"settings.ssh.server.label": "SSH szerver engedélyezése",
|
||||
"settings.ssh.server.help": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más társak csatlakozhassanak.",
|
||||
"settings.ssh.root.label": "Root bejelentkezés engedélyezése",
|
||||
"settings.ssh.root.help": "Társak bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges.",
|
||||
"settings.ssh.sftp.label": "SFTP engedélyezése",
|
||||
"settings.ssh.sftp.help": "Fájlok biztonságos átvitele natív SFTP- vagy SCP-kliensekkel.",
|
||||
"settings.ssh.localForward.label": "Helyi porttovábbítás",
|
||||
"settings.ssh.localForward.help": "A csatlakozó társak helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz.",
|
||||
"settings.ssh.remoteForward.label": "Távoli porttovábbítás",
|
||||
"settings.ssh.remoteForward.help": "A csatlakozó társak ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára.",
|
||||
"settings.ssh.jwt.label": "JWT-hitelesítés engedélyezése",
|
||||
"settings.ssh.jwt.help": "Minden SSH-munkamenet ellenőrzése az IdP-vel a felhasználói identitás és audit céljából. Tiltsa le, ha csak a hálózati ACL-szabályokra kíván támaszkodni — hasznos, ha nincs elérhető IdP.",
|
||||
"settings.ssh.jwtTtl.label": "JWT gyorsítótár TTL",
|
||||
"settings.ssh.jwtTtl.help": "Mennyi ideig őrzi meg a kliens a JWT-t, mielőtt újra kérné a kimenő SSH-kapcsolatoknál. 0 érték esetén a gyorsítótárazás kikapcsol, és minden kapcsolatnál hitelesít.",
|
||||
"settings.ssh.jwtTtl.suffix": "másodperc",
|
||||
|
||||
"settings.advanced.section.interface": "Interfész",
|
||||
"settings.advanced.section.security": "Biztonság",
|
||||
"settings.advanced.interfaceName.label": "Név",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.psk.label": "Pre-shared kulcs",
|
||||
"settings.advanced.psk.help": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan társakkal kommunikál, akik ugyanazt a pre-shared kulcsot használják.",
|
||||
|
||||
"settings.troubleshooting.section.title": "Hibakeresési csomag",
|
||||
"settings.troubleshooting.intro": "A hibakeresési csomag segít a NetBird támogatásnak a kapcsolati problémák kivizsgálásában. <br /> Egy .zip fájl, amely naplókat, rendszerinformációkat és hibakeresési adatokat tartalmaz az eszközéről.",
|
||||
"settings.troubleshooting.anonymize.label": "Érzékeny információk anonimizálása",
|
||||
"settings.troubleshooting.anonymize.help": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban.",
|
||||
"settings.troubleshooting.systemInfo.label": "Rendszerinformációk beillesztése",
|
||||
"settings.troubleshooting.systemInfo.help": "Tartalmazza az OS-t, a kernelt, a hálózati interfészeket és az útválasztási táblákat.",
|
||||
"settings.troubleshooting.upload.label": "Csomag feltöltése a NetBird szerverekre",
|
||||
"settings.troubleshooting.upload.help": "Biztonságosan feltölti a csomagot, és visszaad egy feltöltési kulcsot. Ossza meg a kulcsot a NetBird támogatással a GitHubon vagy Slacken keresztül a fájl közvetlen csatolása helyett.",
|
||||
"settings.troubleshooting.trace.label": "Trace naplók rögzítése",
|
||||
"settings.troubleshooting.trace.help": "TRACE szintre emeli a naplózást, és újraindítja a NetBird kapcsolatot a kapcsolati naplók rögzítéséhez. Az előző szint a csomag elkészülte után visszaáll.",
|
||||
"settings.troubleshooting.duration.label": "Rögzítés időtartama",
|
||||
"settings.troubleshooting.duration.help": "Mennyi ideig rögzítse a trace naplókat a csomag elkészítése előtt.",
|
||||
"settings.troubleshooting.duration.suffix": "perc",
|
||||
"settings.troubleshooting.create": "Csomag létrehozása",
|
||||
"settings.troubleshooting.progress.description": "Naplók, rendszerinformációk és kapcsolati állapot gyűjtése folyamatban. Általában néhány pillanatot vesz igénybe — tartsa nyitva ezt az ablakot a befejezésig.",
|
||||
"settings.troubleshooting.cancelling": "Megszakítás…",
|
||||
"settings.troubleshooting.done.uploadedTitle": "A hibakeresési csomag feltöltése sikeres!",
|
||||
"settings.troubleshooting.done.savedTitle": "Csomag elmentve",
|
||||
"settings.troubleshooting.done.uploadedDescription": "Ossza meg az alábbi feltöltési kulcsot a NetBird támogatással. A helyi másolat is elmentve van az eszközén.",
|
||||
"settings.troubleshooting.done.savedDescription": "A hibakeresési csomag helyileg elmentve.",
|
||||
"settings.troubleshooting.done.copyKey": "Kulcs másolása",
|
||||
"settings.troubleshooting.done.openFolder": "Mappa megnyitása",
|
||||
"settings.troubleshooting.done.openFileLocation": "Fájl helyének megnyitása",
|
||||
"settings.troubleshooting.uploadFailedWithReason": "Feltöltés sikertelen: {reason} A csomag továbbra is el van mentve helyileg.",
|
||||
"settings.troubleshooting.uploadFailed": "Feltöltés sikertelen. A csomag továbbra is el van mentve helyileg.",
|
||||
"settings.troubleshooting.stage.preparingTrace": "Váltás trace naplózásra…",
|
||||
"settings.troubleshooting.stage.reconnecting": "NetBird újracsatlakoztatása…",
|
||||
"settings.troubleshooting.stage.capturing": "Naplók rögzítése — {elapsed} / {total}",
|
||||
"settings.troubleshooting.stage.restoring": "Korábbi napló szint visszaállítása…",
|
||||
"settings.troubleshooting.stage.bundling": "Hibakeresési csomag generálása…",
|
||||
"settings.troubleshooting.stage.uploading": "Feltöltés a NetBirdhöz…",
|
||||
"settings.troubleshooting.stage.cancelling": "Megszakítás…",
|
||||
|
||||
"settings.about.client": "NetBird Kliens v{version}",
|
||||
"settings.about.gui": "Felület v{version}",
|
||||
"settings.about.copyright": "© {year} NetBird. Minden jog fenntartva.",
|
||||
"settings.about.links.imprint": "Impresszum",
|
||||
"settings.about.links.privacy": "Adatvédelem",
|
||||
"settings.about.links.cla": "CLA",
|
||||
"settings.about.links.terms": "Felhasználási feltételek",
|
||||
|
||||
"update.banner.message": "A NetBird {version} telepítésre kész.",
|
||||
"update.banner.later": "Később",
|
||||
"update.banner.installNow": "Telepítés most",
|
||||
"update.card.versionAvailable": "Elérhető a {version} verzió.",
|
||||
"update.card.whatsNew": "Mi az újdonság?",
|
||||
"update.card.installNow": "Telepítés most",
|
||||
"update.card.getInstaller": "Telepítő letöltése",
|
||||
"update.card.lastChecked": "Utolsó ellenőrzés: {date}",
|
||||
"update.card.changelog": "Változásnapló",
|
||||
"update.card.checkForUpdates": "Frissítések keresése",
|
||||
"update.header.tooltip": "Frissítés elérhető",
|
||||
"update.overlay.updatingVersion": "NetBird frissítése a következőre: v{version}",
|
||||
"update.overlay.updating": "NetBird frissítése",
|
||||
"update.overlay.description": "Egy újabb verzió elérhető és települ. A NetBird automatikusan újraindul a frissítés befejeztével.",
|
||||
"update.overlay.error.timeoutTitle": "A frissítés túl sokáig tart",
|
||||
"update.overlay.error.timeoutDescription": "A(z) {target} telepítése túl sokáig tartott, és nem fejeződött be.",
|
||||
"update.overlay.error.canceledTitle": "A frissítés megszakítva",
|
||||
"update.overlay.error.canceledDescription": "A(z) {target} frissítését megszakították a befejezés előtt.",
|
||||
"update.overlay.error.failTitle": "A frissítés nem telepíthető",
|
||||
"update.overlay.error.failDescription": "A(z) {target} nem volt telepíthető.",
|
||||
"update.overlay.error.unknownMessage": "ismeretlen hiba",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "az új verzió",
|
||||
|
||||
"update.page.versionLine": "Kliens frissítése erre: {version}.",
|
||||
"update.page.versionLineGeneric": "Kliens frissítése.",
|
||||
"update.page.outdated": "Az Ön kliensverziója régebbi, mint a Managementben beállított automatikus frissítési verzió.",
|
||||
"update.page.status.running": "Frissítés",
|
||||
"update.page.status.timeout": "A frissítés időtúllépés miatt megszakadt. Kérjük, próbálja újra.",
|
||||
"update.page.status.canceled": "Frissítés megszakítva.",
|
||||
"update.page.status.failed": "Frissítés sikertelen: {message}",
|
||||
"update.page.status.unknownError": "ismeretlen frissítési hiba",
|
||||
"update.page.failedTitle": "Frissítés sikertelen",
|
||||
"update.page.timeoutMessage": "Frissítés időtúllépés.",
|
||||
"update.page.dontClose": "Kérjük, ne zárja be ezt az ablakot.",
|
||||
"update.page.updating": "Frissítés…",
|
||||
"update.page.complete": "Frissítés kész",
|
||||
"update.page.failed": "Frissítés sikertelen",
|
||||
|
||||
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
|
||||
"browserLogin.description": "Kérjük, fejezze be a fiókhitelesítést a böngésző fülén, és folytassa onnan.",
|
||||
"browserLogin.waiting": "Várakozás a bejelentkezésre…",
|
||||
"browserLogin.notSeeing": "Nem látja a böngésző fülét?",
|
||||
"browserLogin.tryAgain": "Próbálja újra",
|
||||
|
||||
"sessionExpired.title": "Munkamenet lejárt",
|
||||
"sessionExpired.description": "A NetBird munkamenete lejárt. Jelentkezzen be újra, hogy az eszközei kapcsolatban maradjanak.",
|
||||
"sessionExpired.later": "Később",
|
||||
"sessionExpired.signIn": "Bejelentkezés",
|
||||
|
||||
"peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján",
|
||||
"peers.filter.all": "Összes",
|
||||
"peers.filter.online": "Online",
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "Egyetlen társ sem felel meg a jelenlegi szűrőknek.",
|
||||
|
||||
"quickActions.connect": "Csatlakozás",
|
||||
"quickActions.disconnect": "Bontás"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialogs, Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
@@ -14,11 +16,11 @@ enum ConnectionState {
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "Disconnected",
|
||||
[ConnectionState.Connecting]: "Connecting...",
|
||||
[ConnectionState.Connected]: "Connected",
|
||||
[ConnectionState.Disconnecting]: "Disconnecting...",
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
@@ -90,7 +92,7 @@ async function startLogin(): Promise<void> {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (cancelled) return;
|
||||
await Dialogs.Error({
|
||||
Title: "Login Failed",
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -100,6 +102,7 @@ async function startLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
export const ConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
const { activeProfile, username } = useProfile();
|
||||
|
||||
@@ -141,7 +144,7 @@ export const ConnectionStatusSwitch = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Connect Failed",
|
||||
Title: t("connect.error.connectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -156,7 +159,7 @@ export const ConnectionStatusSwitch = () => {
|
||||
await Connection.Down();
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Disconnect Failed",
|
||||
Title: t("connect.error.disconnectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -215,10 +218,10 @@ export const ConnectionStatusSwitch = () => {
|
||||
}
|
||||
>
|
||||
{unreachable
|
||||
? "Daemon unavailable"
|
||||
? t("connect.status.daemonUnavailable")
|
||||
: needsLogin
|
||||
? "Login required"
|
||||
: STATUS_LABEL[connState]}
|
||||
? t("connect.status.loginRequired")
|
||||
: t(STATUS_KEY[connState])}
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||
import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import deFlag from "@/assets/flags/1x1/de.svg";
|
||||
@@ -8,32 +9,33 @@ type Props = {
|
||||
};
|
||||
|
||||
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||
<CardNavItem
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={"Peers"}
|
||||
description={"17 of 25 Online"}
|
||||
title={t("nav.peers.title")}
|
||||
description={t("nav.peers.description", { online: 17, total: 25 })}
|
||||
active={peersActive}
|
||||
onClick={onPeersClick}
|
||||
/>
|
||||
<CardNavItem
|
||||
icon={Layers3Icon}
|
||||
title={"Resources"}
|
||||
description={"13 of 16 Active"}
|
||||
title={t("nav.resources.title")}
|
||||
description={t("nav.resources.description", { active: 13, total: 16 })}
|
||||
iconSize={14}
|
||||
/>
|
||||
<CardNavItem
|
||||
iconNode={
|
||||
<img
|
||||
src={deFlag}
|
||||
alt={"Germany"}
|
||||
alt={t("nav.exitNode.flagAlt", { country: "Germany" })}
|
||||
className={
|
||||
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
|
||||
}
|
||||
/>
|
||||
}
|
||||
title={"Exit Node Berlin"}
|
||||
title={t("nav.exitNode.title", { location: "Berlin" })}
|
||||
description={"100.92.14.37"}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
71
client/ui/frontend/src/lib/i18n.ts
Normal file
71
client/ui/frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
|
||||
import { Preferences, I18n } from "@bindings/services";
|
||||
|
||||
// Vite glob-imports every shipped bundle at build time. Adding a language
|
||||
// only requires dropping the new folder under src/i18n/locales/ and the
|
||||
// row in _index.json — no edit to this file. The `eager: true` import
|
||||
// keeps the bundles inlined in the main JS chunk, same shape as a static
|
||||
// import. Path is relative on purpose — alias-based globs (`@/…`) silently
|
||||
// resolve to an empty match in some Vite dev-mode setups.
|
||||
const bundleModules = import.meta.glob<Record<string, string>>(
|
||||
"../i18n/locales/*/common.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = path.match(/locales\/([^/]+)\/common\.json$/);
|
||||
if (match) {
|
||||
resources[match[1]] = { common: bundleModules[path] };
|
||||
}
|
||||
}
|
||||
|
||||
// initI18n is awaited from app.tsx before the first render. The Go-side
|
||||
// preferences.Store returns the in-memory default "en" when no on-disk
|
||||
// preferences file exists; if Get() rejects (daemon unreachable) we also
|
||||
// fall through with "en" so the UI still renders.
|
||||
export async function initI18n(): Promise<void> {
|
||||
let language = "en";
|
||||
try {
|
||||
const prefs = await Preferences.Get();
|
||||
if (prefs?.language) {
|
||||
language = prefs.language;
|
||||
}
|
||||
} catch {
|
||||
// Daemon / preferences store unreachable — fall through with "en".
|
||||
}
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: language,
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
resources,
|
||||
interpolation: {
|
||||
prefix: "{",
|
||||
suffix: "}",
|
||||
escapeValue: false,
|
||||
},
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
// The event name + payload type come from Wails' generated module
|
||||
// augmentation (bindings/.../wails/v3/internal/eventdata.d.ts) which
|
||||
// extends @wailsio/runtime's CustomEvents interface, so e.data is
|
||||
// typed as UIPreferences without any hand-written cast.
|
||||
Events.On("netbird:preferences:changed", (e) => {
|
||||
const next = e.data?.language;
|
||||
if (next && next !== i18next.language) {
|
||||
void i18next.changeLanguage(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLanguages() {
|
||||
return I18n.Languages();
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -9,6 +10,7 @@ import { cn } from "@/lib/cn";
|
||||
// tray menu instead; the force-install branch (installing=true) takes over
|
||||
// with the full-screen UpdatingOverlay.
|
||||
export const UpdateAvailableBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updateVersion, enforced, installing, triggerUpdate } = useClientVersion();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
@@ -26,14 +28,14 @@ export const UpdateAvailableBanner = () => {
|
||||
)}
|
||||
>
|
||||
<p className={"text-sm text-nb-gray-900 pr-4 pl-2 font-medium"}>
|
||||
NetBird {updateVersion} is ready to install.
|
||||
{t("update.banner.message", { version: updateVersion })}
|
||||
</p>
|
||||
<div className={"flex gap-2"}>
|
||||
<Button variant={"subtle"} size={"xs"} onClick={() => setDismissed(true)}>
|
||||
Later
|
||||
{t("update.banner.later")}
|
||||
</Button>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Install now
|
||||
{t("update.banner.installNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpCircleIcon } from "lucide-react";
|
||||
import { IconButton } from "@/components/IconButton.tsx";
|
||||
import { Tooltip } from "@/components/Tooltip.tsx";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
|
||||
|
||||
export const UpdateHeaderTrigger = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
|
||||
if (!updateAvailable) return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={"Update Available"}>
|
||||
<Tooltip content={t("update.header.tooltip")}>
|
||||
<div className={"relative h-11 w-11 flex items-center justify-center"}>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
|
||||
@@ -10,8 +11,8 @@ function openUrl(url: string) {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
function formatLastChecked(date: Date) {
|
||||
return date.toLocaleString(undefined, {
|
||||
function formatLastChecked(date: Date, locale?: string) {
|
||||
return date.toLocaleString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -20,22 +21,23 @@ function formatLastChecked(date: Date) {
|
||||
}
|
||||
|
||||
export function UpdateVersionCard() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
|
||||
|
||||
if (updateVersion) {
|
||||
return (
|
||||
<Card>
|
||||
<div>
|
||||
<Title>Version {updateVersion} is available.</Title>
|
||||
<Title>{t("update.card.versionAvailable", { version: updateVersion })}</Title>
|
||||
<Link
|
||||
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
|
||||
>
|
||||
What's new?
|
||||
{t("update.card.whatsNew")}
|
||||
</Link>
|
||||
</div>
|
||||
{enforced ? (
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Install now
|
||||
{t("update.card.installNow")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -43,7 +45,7 @@ export function UpdateVersionCard() {
|
||||
size={"xs"}
|
||||
onClick={() => openUrl(GITHUB_RELEASES)}
|
||||
>
|
||||
Get installer
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
@@ -53,11 +55,15 @@ export function UpdateVersionCard() {
|
||||
return (
|
||||
<Card className={"max-w-md"}>
|
||||
<div>
|
||||
<Title>Last checked on {formatLastChecked(new Date())}</Title>
|
||||
<Link url={"https://github.com/netbirdio/netbird/releases/latest"}>Changelog</Link>
|
||||
<Title>
|
||||
{t("update.card.lastChecked", {
|
||||
date: formatLastChecked(new Date(), i18n.language),
|
||||
})}
|
||||
</Title>
|
||||
<Link url={GITHUB_RELEASES}>{t("update.card.changelog")}</Link>
|
||||
</div>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Check for updates
|
||||
{t("update.card.checkForUpdates")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
@@ -13,31 +14,38 @@ type Variant = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function classifyError(msg: string, version: string | null): Variant {
|
||||
function classifyError(
|
||||
msg: string,
|
||||
version: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): Variant {
|
||||
const lower = msg.toLowerCase();
|
||||
const target = version ? `v${version}` : "the new version";
|
||||
const target = version
|
||||
? t("update.overlay.error.targetVersion", { version })
|
||||
: t("update.overlay.error.targetFallback");
|
||||
if (lower.includes("timeout") || lower.includes("timed out")) {
|
||||
return {
|
||||
title: "Update Is Taking Too Long",
|
||||
description: `Installing ${target} took too long and didn't finish.`,
|
||||
title: t("update.overlay.error.timeoutTitle"),
|
||||
description: t("update.overlay.error.timeoutDescription", { target }),
|
||||
};
|
||||
}
|
||||
if (lower.includes("cancel")) {
|
||||
return {
|
||||
title: "Update Was Stopped",
|
||||
description: `The update to ${target} was canceled before it finished.`,
|
||||
title: t("update.overlay.error.canceledTitle"),
|
||||
description: t("update.overlay.error.canceledDescription", { target }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Couldn't Install the Update",
|
||||
description: `${target} couldn't be installed.`,
|
||||
message: msg || "unknown error",
|
||||
title: t("update.overlay.error.failTitle"),
|
||||
description: t("update.overlay.error.failDescription", { target }),
|
||||
message: msg || t("update.overlay.error.unknownMessage"),
|
||||
};
|
||||
}
|
||||
|
||||
export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isError = Boolean(error);
|
||||
const errorInfo = error ? classifyError(error, version) : null;
|
||||
const errorInfo = error ? classifyError(error, version, t) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,8 +83,8 @@ export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
|
||||
{isError
|
||||
? errorInfo!.title
|
||||
: version
|
||||
? `Updating NetBird to v${version}`
|
||||
: "Updating NetBird"}
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating")}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>
|
||||
{isError ? (
|
||||
@@ -92,7 +100,7 @@ export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"A newer version is available and is being installed. NetBird will restart automatically once the update is finished."
|
||||
t("update.overlay.description")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -100,7 +108,7 @@ export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
|
||||
{isError && (
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onDismiss}>
|
||||
Close
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Debug as DebugSvc,
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -156,7 +157,7 @@ export const useDebugBundle = () => {
|
||||
}
|
||||
setStage({ kind: "idle" });
|
||||
await Dialogs.Error({
|
||||
Title: "Debug Bundle Failed",
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
|
||||
export type StatusFilter = "all" | "online" | "offline";
|
||||
|
||||
const FILTERS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "online", label: "Online" },
|
||||
{ value: "offline", label: "Offline" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
value: StatusFilter;
|
||||
onChange: (value: StatusFilter) => void;
|
||||
@@ -16,13 +11,21 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const filters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: t("peers.filter.all") },
|
||||
{ value: "online", label: t("peers.filter.online") },
|
||||
{ value: "offline", label: t("peers.filter.offline") },
|
||||
];
|
||||
|
||||
return (
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as StatusFilter)}
|
||||
className={"w-full"}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
{filters.map((f) => (
|
||||
<SwitchItem key={f.value} value={f.value} className={"flex-1"}>
|
||||
{f.label}
|
||||
<span className={"font-normal text-nb-gray-200"}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
@@ -9,6 +10,7 @@ import { PeersList } from "./PeersList";
|
||||
const isOnline = (status: string) => status === "connected";
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
|
||||
@@ -37,7 +39,7 @@ export const Peers = () => {
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-6"}>
|
||||
<SearchInput
|
||||
placeholder={"Search by peer name, DNS or IP address"}
|
||||
placeholder={t("peers.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Peer, PeerStatus } from "./types";
|
||||
|
||||
@@ -8,10 +9,11 @@ const DOT: Record<PeerStatus, string> = {
|
||||
};
|
||||
|
||||
export const PeersList = ({ data }: { data: Peer[] }) => {
|
||||
const { t } = useTranslation();
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||
No peers match the current filters.
|
||||
{t("peers.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Connection, Peers, Profiles as ProfilesSvc } from "@bindings/services";
|
||||
import {
|
||||
Connection,
|
||||
ProfileSwitcher,
|
||||
Profiles as ProfilesSvc,
|
||||
} from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
|
||||
type ProfileContextValue = {
|
||||
username: string;
|
||||
@@ -50,7 +55,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
setProfiles(list);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Load Profiles Failed",
|
||||
Title: i18next.t("profile.error.loadTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -64,26 +69,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const switchProfile = useCallback(
|
||||
async (name: string) => {
|
||||
// Mirror tray.go switchProfile: only reconnect when the daemon was
|
||||
// actively online. Calling Up on an Idle/NeedsLogin daemon makes
|
||||
// the daemon wait 50s on its internal waitForUp and return
|
||||
// DeadlineExceeded.
|
||||
let wasActive = false;
|
||||
try {
|
||||
const prev = await Peers.Get();
|
||||
const s = (prev?.status ?? "").toLowerCase();
|
||||
wasActive = s === "connected" || s === "connecting";
|
||||
} catch {
|
||||
wasActive = false;
|
||||
}
|
||||
|
||||
await ProfilesSvc.Switch({ profileName: name, username });
|
||||
|
||||
if (wasActive) {
|
||||
await Connection.Down();
|
||||
await Connection.Up({ profileName: name, username });
|
||||
}
|
||||
|
||||
await ProfileSwitcher.SwitchActive({ profileName: name, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
|
||||
243
client/ui/frontend/src/modules/settings/LanguagePicker.tsx
Normal file
243
client/ui/frontend/src/modules/settings/LanguagePicker.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { CheckIcon, ChevronDown, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
|
||||
// build time; the JS bundle only holds URL refs, not the SVG bytes.
|
||||
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?url",
|
||||
});
|
||||
|
||||
const flagByCode: Record<string, string> = {};
|
||||
for (const path in FLAG_URLS) {
|
||||
const match = path.match(/1x1\/([^/]+)\.svg$/);
|
||||
if (match) flagByCode[match[1]] = FLAG_URLS[path];
|
||||
}
|
||||
|
||||
const flagFor = (code: string): string | undefined =>
|
||||
flagByCode[code.toLowerCase().split("-")[0]];
|
||||
|
||||
function Flag({ code, label }: { code: string; label: string }) {
|
||||
const src = flagFor(code);
|
||||
if (!src) {
|
||||
return (
|
||||
<span
|
||||
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLanguages()
|
||||
.then((list) => {
|
||||
if (!cancelled) setLanguages(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[languages],
|
||||
);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
languages.find((l) => l.code === i18n.language) ??
|
||||
languages.find((l) => l.code === "en"),
|
||||
[languages, i18n.language],
|
||||
);
|
||||
|
||||
const select = async (code: string) => {
|
||||
if (busy || code === i18n.language) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await Preferences.SetLanguage(code as LanguageCode);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center gap-6 justify-between"}>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>{t("settings.general.language.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
disabled={busy || languages.length === 0}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{current && <Flag code={current.code} label={current.displayName} />}
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current?.displayName ?? "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"start"}
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)]",
|
||||
"rounded-md border border-nb-gray-700 bg-nb-gray-900 shadow-lg p-1 z-50",
|
||||
"origin-[var(--radix-popover-content-transform-origin)]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-1",
|
||||
"data-[side=top]:slide-in-from-bottom-1",
|
||||
"duration-150 ease-out",
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
||||
)}
|
||||
>
|
||||
<div className={"px-1 pb-1"}>
|
||||
<div className={"group flex items-center gap-2 px-1 h-8"}>
|
||||
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"outline-none border-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
|
||||
<ScrollArea.Viewport className={"max-h-64 px-1"}>
|
||||
<Command.List>
|
||||
<Command.Empty>
|
||||
<div
|
||||
className={
|
||||
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
|
||||
}
|
||||
>
|
||||
{t("settings.general.language.empty")}
|
||||
</div>
|
||||
</Command.Empty>
|
||||
|
||||
{sorted.map((lang) => {
|
||||
const checked = lang.code === i18n.language;
|
||||
return (
|
||||
<Command.Item
|
||||
key={lang.code}
|
||||
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
|
||||
onSelect={() => void select(lang.code)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none",
|
||||
"text-xs font-semibold text-nb-gray-100",
|
||||
"data-[selected=true]:bg-nb-gray-850 my-0.5",
|
||||
checked &&
|
||||
"bg-nb-gray-800 data-[selected=true]:bg-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
<Flag
|
||||
code={lang.code}
|
||||
label={lang.displayName}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{lang.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={12}
|
||||
className={"text-white"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
@@ -9,13 +10,20 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
return (
|
||||
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as ManagementMode)}
|
||||
>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
Cloud
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,35 +14,19 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
|
||||
const LAST_TAB_KEY = "netbird:settings:lastTab";
|
||||
|
||||
const readLastTab = () => {
|
||||
try {
|
||||
return localStorage.getItem(LAST_TAB_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The settings window always opens at General. The only way to land on a
|
||||
// different tab is via navigation state (e.g. the update-available header
|
||||
// trigger jumps to About). No persistence across opens — a user who wants
|
||||
// to revisit a deep tab gets there in two clicks.
|
||||
export const Settings = () => {
|
||||
const location = useLocation();
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const [active, setActive] = useState(
|
||||
() => navState?.tab ?? readLastTab() ?? "general",
|
||||
);
|
||||
const [active, setActive] = useState(() => navState?.tab ?? "general");
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.tab) setActive(navState.tab);
|
||||
}, [navState?.tab, location.key]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(LAST_TAB_KEY, active);
|
||||
} catch {
|
||||
// ignore quota / unavailable storage
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||
<SettingsNavigationTriggers />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import pkg from "../../../package.json";
|
||||
@@ -5,24 +6,25 @@ import { useStatus } from "@/hooks/useStatus";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: "Imprint", url: "https://netbird.io/imprint" },
|
||||
{ label: "Privacy", url: "https://netbird.io/privacy" },
|
||||
{ label: "CLA", url: "https://netbird.io/cla" },
|
||||
{ label: "Terms of Service", url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
function openUrl(url: string) {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export function SettingsAbout() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const guiVersion = pkg.version;
|
||||
const daemonVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" },
|
||||
{ label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" },
|
||||
{ label: t("settings.about.links.cla"), url: "https://netbird.io/cla" },
|
||||
{ label: t("settings.about.links.terms"), url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -35,15 +37,17 @@ export function SettingsAbout() {
|
||||
className={"text-sm font-semibold text-nb-gray-100 cursor-default select-none"}
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
NetBird Client v{daemonVersion}
|
||||
{t("settings.about.client", { version: daemonVersion })}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>
|
||||
{t("settings.about.gui", { version: guiVersion })}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
|
||||
</div>
|
||||
|
||||
<UpdateVersionCard />
|
||||
|
||||
<p className={"text-sm text-nb-gray-300 text-center"}>
|
||||
© {new Date().getFullYear()} NetBird. All Rights Reserved.
|
||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
<div
|
||||
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "@/components/Button";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
@@ -7,6 +8,7 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveFields } = useSettings();
|
||||
|
||||
const [values, setValues] = useState({
|
||||
@@ -35,9 +37,9 @@ export function SettingsAdvanced() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Interface"}>
|
||||
<SectionGroup title={t("settings.advanced.section.interface")}>
|
||||
<Input
|
||||
label={"Name"}
|
||||
label={t("settings.advanced.interfaceName.label")}
|
||||
value={values.interfaceName}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||
@@ -45,7 +47,7 @@ export function SettingsAdvanced() {
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={"Port"}
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
value={values.wireguardPort}
|
||||
onChange={(e) =>
|
||||
@@ -56,7 +58,7 @@ export function SettingsAdvanced() {
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={"MTU"}
|
||||
label={t("settings.advanced.mtu.label")}
|
||||
type={"number"}
|
||||
value={values.mtu}
|
||||
onChange={(e) =>
|
||||
@@ -66,13 +68,11 @@ export function SettingsAdvanced() {
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Security"}>
|
||||
<SectionGroup title={t("settings.advanced.section.security")}>
|
||||
<div>
|
||||
<Label as={"div"}>Pre-shared Key</Label>
|
||||
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
|
||||
<HelpText>
|
||||
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
||||
NetBird Setup Key. You will only communicate with peers that use the same
|
||||
pre-shared key.
|
||||
{t("settings.advanced.psk.help")}
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
@@ -94,7 +94,7 @@ export function SettingsAdvanced() {
|
||||
disabled={!hasChanges || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
|
||||
@@ -52,7 +53,7 @@ const useSettingsState = () => {
|
||||
setConfig(c);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Load Settings Failed",
|
||||
Title: i18next.t("settings.error.loadTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
@@ -82,7 +83,7 @@ const useSettingsState = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Save Settings Failed",
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
@@ -8,8 +9,10 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/modules/settings/LanguagePicker.tsx";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
|
||||
@@ -27,29 +30,29 @@ export function SettingsGeneral() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"General"}>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableAutoConnect}
|
||||
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||
label={"Connect on Startup"}
|
||||
helpText={"Automatically establish a connection when the service starts."}
|
||||
label={t("settings.general.connectOnStartup.label")}
|
||||
helpText={t("settings.general.connectOnStartup.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
label={"Desktop Notifications"}
|
||||
helpText={"Show desktop notifications for new updates and connection events."}
|
||||
label={t("settings.general.notifications.label")}
|
||||
helpText={t("settings.general.notifications.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Connection"}>
|
||||
<SectionGroup title={t("settings.general.section.connection")}>
|
||||
<div>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>Management Server</Label>
|
||||
<Label as={"div"}>{t("settings.general.management.label")}</Label>
|
||||
<HelpText>
|
||||
Connect to NetBird Cloud or your own self-hosted management server.
|
||||
Changes will reconnect the client.
|
||||
{t("settings.general.management.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
@@ -60,10 +63,10 @@ export function SettingsGeneral() {
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={"https://netbird.selfhosted.com:443"}
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
error={
|
||||
showError
|
||||
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
|
||||
? t("settings.general.management.urlError")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -73,7 +76,7 @@ export function SettingsGeneral() {
|
||||
disabled={!canSave}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@/components/Tooltip.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigationTriggers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
|
||||
const aboutAdornment = updateAvailable ? (
|
||||
<Tooltip content={"Update Available"} side={"right"}>
|
||||
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
|
||||
<UpdateBadge />
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
@@ -27,37 +29,37 @@ export const SettingsNavigationTriggers = () => {
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={"General"}
|
||||
title={t("settings.tabs.general")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={"Network"}
|
||||
title={t("settings.tabs.network")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={"Security"}
|
||||
title={t("settings.tabs.security")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={"SSH"}
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={"Advanced"}
|
||||
title={t("settings.tabs.advanced")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={"Troubleshooting"}
|
||||
title={t("settings.tabs.troubleshooting")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={"About"}
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsNetwork() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Connectivity"}>
|
||||
<SectionGroup title={t("settings.network.section.connectivity")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.lazyConnectionEnabled}
|
||||
onChange={(v) => setField("lazyConnectionEnabled", v)}
|
||||
label={"Lazy Connections"}
|
||||
helpText={
|
||||
"Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling."
|
||||
}
|
||||
label={t("settings.network.lazy.label")}
|
||||
helpText={t("settings.network.lazy.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.networkMonitor}
|
||||
onChange={(v) => setField("networkMonitor", v)}
|
||||
label={"Reconnect on Network Change"}
|
||||
helpText={
|
||||
"Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep."
|
||||
}
|
||||
label={t("settings.network.monitor.label")}
|
||||
helpText={t("settings.network.monitor.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Routing & DNS"}>
|
||||
<SectionGroup title={t("settings.network.section.routingDns")}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableDns}
|
||||
onChange={(v) => setField("disableDns", !v)}
|
||||
label={"Enable DNS"}
|
||||
helpText={"Apply NetBird-managed DNS settings to the host resolver."}
|
||||
label={t("settings.network.dns.label")}
|
||||
helpText={t("settings.network.dns.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableClientRoutes}
|
||||
onChange={(v) => setField("disableClientRoutes", !v)}
|
||||
label={"Enable Client Routes"}
|
||||
helpText={"Accept routes from other peers to reach their networks."}
|
||||
label={t("settings.network.clientRoutes.label")}
|
||||
helpText={t("settings.network.clientRoutes.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableServerRoutes}
|
||||
onChange={(v) => setField("disableServerRoutes", !v)}
|
||||
label={"Enable Server Routes"}
|
||||
helpText={"Advertise this host's local routes to other peers."}
|
||||
label={t("settings.network.serverRoutes.label")}
|
||||
helpText={t("settings.network.serverRoutes.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
@@ -8,11 +9,11 @@ import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
// Keep the local input in sync when the config changes from elsewhere
|
||||
useEffect(() => {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}, [config.sshJwtCacheTtl]);
|
||||
@@ -40,58 +41,48 @@ export function SettingsSSH() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Server"}>
|
||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label={"Enable SSH Server"}
|
||||
helpText={
|
||||
"Run the NetBird SSH server on this host so other peers can connect to it."
|
||||
}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
|
||||
<SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label={"Allow Root Login"}
|
||||
helpText={
|
||||
"Let peers sign in as the root user. Disable to require a non-privileged account."
|
||||
}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label={"Allow SFTP"}
|
||||
helpText={"Transfer files securely using native SFTP or SCP clients."}
|
||||
label={t("settings.ssh.sftp.label")}
|
||||
helpText={t("settings.ssh.sftp.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label={"Local Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers tunnel local ports to services reachable from this host."
|
||||
}
|
||||
label={t("settings.ssh.localForward.label")}
|
||||
helpText={t("settings.ssh.localForward.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRemotePortForwarding}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label={"Remote Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers expose ports on this host back to their own machine."
|
||||
}
|
||||
label={t("settings.ssh.remoteForward.label")}
|
||||
helpText={t("settings.ssh.remoteForward.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
|
||||
<SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
label={"Enable JWT Authentication"}
|
||||
helpText={
|
||||
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
|
||||
}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -100,11 +91,9 @@ export function SettingsSSH() {
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>JWT Cache TTL</Label>
|
||||
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
|
||||
<HelpText margin={false}>
|
||||
How long this client caches a JWT before prompting again on outgoing SSH
|
||||
connections. Set to 0 to disable caching and authenticate on every
|
||||
connection.
|
||||
{t("settings.ssh.jwtTtl.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
@@ -114,7 +103,7 @@ export function SettingsSSH() {
|
||||
value={jwtTtlInput}
|
||||
onChange={handleJwtTtlChange}
|
||||
onBlur={handleJwtTtlBlur}
|
||||
customSuffix={"Second(s)"}
|
||||
customSuffix={t("settings.ssh.jwtTtl.suffix")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Firewall"}>
|
||||
<SectionGroup title={t("settings.security.section.firewall")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockInbound}
|
||||
onChange={(v) => setField("blockInbound", v)}
|
||||
label={"Block Inbound Traffic"}
|
||||
helpText={
|
||||
"Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected."
|
||||
}
|
||||
label={t("settings.security.blockInbound.label")}
|
||||
helpText={t("settings.security.blockInbound.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockLanAccess}
|
||||
onChange={(v) => setField("blockLanAccess", v)}
|
||||
label={"Block LAN Access"}
|
||||
helpText={
|
||||
"Prevent peers from reaching your local network or its devices when this device routes their traffic."
|
||||
}
|
||||
label={t("settings.security.blockLan.label")}
|
||||
helpText={t("settings.security.blockLan.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Encryption"}>
|
||||
<SectionGroup title={t("settings.security.section.encryption")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassEnabled}
|
||||
onChange={(v) => {
|
||||
setField("rosenpassEnabled", v);
|
||||
if (!v) setField("rosenpassPermissive", false);
|
||||
}}
|
||||
label={"Enable Quantum-Resistance"}
|
||||
helpText={
|
||||
"Add a post-quantum key exchange via Rosenpass on top of WireGuard®."
|
||||
}
|
||||
label={t("settings.security.rosenpass.label")}
|
||||
helpText={t("settings.security.rosenpass.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassPermissive}
|
||||
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||
label={"Enable Permissive Mode"}
|
||||
helpText={
|
||||
"Allow connections to peers without quantum-resistance support."
|
||||
}
|
||||
label={t("settings.security.rosenpassPermissive.label")}
|
||||
helpText={t("settings.security.rosenpassPermissive.help")}
|
||||
disabled={!config.rosenpassEnabled}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
@@ -14,6 +15,7 @@ import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleCont
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
@@ -45,39 +47,34 @@ export function SettingsTroubleshooting() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionGroup title={"Debug bundle"}>
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<HelpText className={"-mt-2 mb-2"}>
|
||||
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
|
||||
.zip file with logs, system details and debug information from your device.
|
||||
<Trans i18nKey={"settings.troubleshooting.intro"} components={{ br: <br /> }} />
|
||||
</HelpText>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={"Anonymize Sensitive Information"}
|
||||
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
|
||||
label={t("settings.troubleshooting.anonymize.label")}
|
||||
helpText={t("settings.troubleshooting.anonymize.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label={"Include System Information"}
|
||||
helpText={"Include OS, kernel, network interfaces, and routing tables."}
|
||||
label={t("settings.troubleshooting.systemInfo.label")}
|
||||
helpText={t("settings.troubleshooting.systemInfo.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={upload}
|
||||
onChange={setUpload}
|
||||
label={"Upload Bundle to NetBird Servers"}
|
||||
helpText={
|
||||
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
|
||||
}
|
||||
label={t("settings.troubleshooting.upload.label")}
|
||||
helpText={t("settings.troubleshooting.upload.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={trace}
|
||||
onChange={setTrace}
|
||||
label={"Capture Trace Logs"}
|
||||
helpText={
|
||||
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
|
||||
}
|
||||
label={t("settings.troubleshooting.trace.label")}
|
||||
helpText={t("settings.troubleshooting.trace.help")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -86,9 +83,9 @@ export function SettingsTroubleshooting() {
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>Capture Duration</Label>
|
||||
<Label as={"div"}>{t("settings.troubleshooting.duration.label")}</Label>
|
||||
<HelpText margin={false}>
|
||||
How long to capture trace logs before generating the bundle.
|
||||
{t("settings.troubleshooting.duration.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
@@ -100,7 +97,7 @@ export function SettingsTroubleshooting() {
|
||||
onChange={(e) =>
|
||||
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
|
||||
}
|
||||
customSuffix={"Minute(s)"}
|
||||
customSuffix={t("settings.troubleshooting.duration.suffix")}
|
||||
disabled={!trace}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,7 +105,7 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
<BottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={run}>
|
||||
Create Bundle
|
||||
{t("settings.troubleshooting.create")}
|
||||
</Button>
|
||||
</BottomBar>
|
||||
</SectionGroup>
|
||||
@@ -116,17 +113,18 @@ export function SettingsTroubleshooting() {
|
||||
}
|
||||
|
||||
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cancelling = stage.kind === "cancelling";
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"loading"}
|
||||
title={stageLabel(stage)}
|
||||
description={
|
||||
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
|
||||
}
|
||||
title={stageLabel(stage, t)}
|
||||
description={t("settings.troubleshooting.progress.description")}
|
||||
actions={
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Cancel"}
|
||||
{cancelling
|
||||
? t("settings.troubleshooting.cancelling")
|
||||
: t("common.cancel")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -142,6 +140,7 @@ function DoneResult({
|
||||
uploaded: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showKey = uploaded && Boolean(result.uploadedKey);
|
||||
const uploadFailed = uploaded && !result.uploadedKey;
|
||||
const onRevealPath = () => {
|
||||
@@ -151,26 +150,30 @@ function DoneResult({
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"success"}
|
||||
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
|
||||
title={
|
||||
showKey
|
||||
? t("settings.troubleshooting.done.uploadedTitle")
|
||||
: t("settings.troubleshooting.done.savedTitle")
|
||||
}
|
||||
description={
|
||||
showKey
|
||||
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
|
||||
: "Your debug bundle has been saved locally."
|
||||
? t("settings.troubleshooting.done.uploadedDescription")
|
||||
: t("settings.troubleshooting.done.savedDescription")
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||
Close
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
{showKey ? (
|
||||
<Button variant={"primary"} size={"xs"} copy={result.uploadedKey}>
|
||||
Copy Key
|
||||
{t("settings.troubleshooting.done.copyKey")}
|
||||
</Button>
|
||||
) : (
|
||||
result.path && (
|
||||
<Button variant={"primary"} size={"xs"} onClick={onRevealPath}>
|
||||
<FolderOpen size={12} />
|
||||
Open Folder
|
||||
{t("settings.troubleshooting.done.openFolder")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -189,7 +192,7 @@ function DoneResult({
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto hover:text-white transition-all"}
|
||||
aria-label={"Open file location"}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
@@ -203,9 +206,11 @@ function DoneResult({
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
}
|
||||
>
|
||||
Upload failed
|
||||
{result.uploadFailureReason ? `: ${result.uploadFailureReason}` : "."} The
|
||||
bundle is still saved locally.
|
||||
{result.uploadFailureReason
|
||||
? t("settings.troubleshooting.uploadFailedWithReason", {
|
||||
reason: result.uploadFailureReason,
|
||||
})
|
||||
: t("settings.troubleshooting.uploadFailed")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -227,26 +232,27 @@ function BottomBar({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabel = (stage: DebugStage): string => {
|
||||
const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return "Switching to trace logging…";
|
||||
return t("settings.troubleshooting.stage.preparingTrace");
|
||||
case "reconnecting":
|
||||
return "Reconnecting NetBird…";
|
||||
return t("settings.troubleshooting.stage.reconnecting");
|
||||
case "capturing": {
|
||||
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return `Capturing logs — ${fmt(
|
||||
stage.totalSec - stage.remainingSec,
|
||||
)} / ${fmt(stage.totalSec)}`;
|
||||
return t("settings.troubleshooting.stage.capturing", {
|
||||
elapsed: fmt(stage.totalSec - stage.remainingSec),
|
||||
total: fmt(stage.totalSec),
|
||||
});
|
||||
}
|
||||
case "restoring-level":
|
||||
return "Restoring previous log level…";
|
||||
return t("settings.troubleshooting.stage.restoring");
|
||||
case "bundling":
|
||||
return "Generating debug bundle…";
|
||||
return t("settings.troubleshooting.stage.bundling");
|
||||
case "uploading":
|
||||
return "Uploading to NetBird…";
|
||||
return t("settings.troubleshooting.stage.uploading");
|
||||
case "cancelling":
|
||||
return "Cancelling…";
|
||||
return t("settings.troubleshooting.stage.cancelling");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
@@ -60,16 +61,17 @@ export function useManagementUrl() {
|
||||
// Switching from a self-hosted management server to NetBird Cloud
|
||||
// re-points the client at a different deployment and forces a
|
||||
// reconnect/re-login. Confirm before applying.
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
|
||||
void Dialogs.Warning({
|
||||
Title: "Switch to NetBird Cloud?",
|
||||
Message:
|
||||
"This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
|
||||
Title: i18next.t("settings.general.management.switchCloudTitle"),
|
||||
Message: i18next.t("settings.general.management.switchCloudMessage"),
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true, IsDefault: true },
|
||||
{ Label: "Switch to Cloud" },
|
||||
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
|
||||
{ Label: confirmLabel },
|
||||
],
|
||||
}).then((result) => {
|
||||
if (result !== "Switch to Cloud") return;
|
||||
if (result !== confirmLabel) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -9,6 +10,7 @@ import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
const EVENT_CANCEL = "browser-login:cancel";
|
||||
|
||||
export default function BrowserLogin() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const uri = params.get("uri") ?? "";
|
||||
|
||||
@@ -24,30 +26,25 @@ export default function BrowserLogin() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<img src={netbirdFull} alt="NetBird" className="mb-2 h-9" />
|
||||
<h1 className="text-lg font-semibold text-white">
|
||||
Continue in your browser to complete the login
|
||||
</h1>
|
||||
<p className="max-w-sm text-sm text-nb-gray-400">
|
||||
Please complete the account authentication process in the browser tab
|
||||
and continue from there.
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-white">{t("browserLogin.title")}</h1>
|
||||
<p className="max-w-sm text-sm text-nb-gray-400">{t("browserLogin.description")}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-nb-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
||||
Waiting for sign-in…
|
||||
{t("browserLogin.waiting")}
|
||||
</div>
|
||||
<p className="text-sm text-nb-gray-400">
|
||||
Not seeing the browser tab?{" "}
|
||||
{t("browserLogin.notSeeing")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={tryAgain}
|
||||
disabled={!uri}
|
||||
className="text-netbird hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Try again
|
||||
{t("browserLogin.tryAgain")}
|
||||
</button>
|
||||
</p>
|
||||
<Button variant="secondary" onClick={cancel} className="mt-2">
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlertIcon } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
export default function SessionExpired() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -15,16 +17,18 @@ export default function SessionExpired() {
|
||||
>
|
||||
<ShieldAlertIcon size={22} />
|
||||
</div>
|
||||
<h1 className={"text-base font-semibold text-nb-gray-100"}>Session expired</h1>
|
||||
<h1 className={"text-base font-semibold text-nb-gray-100"}>
|
||||
{t("sessionExpired.title")}
|
||||
</h1>
|
||||
<p className={"text-xs text-nb-gray-400 mt-1.5 max-w-[20rem] leading-snug"}>
|
||||
Your NetBird session has expired. Sign in again to keep your devices connected.
|
||||
{t("sessionExpired.description")}
|
||||
</p>
|
||||
<div className={"flex gap-2 mt-5 w-full max-w-[18rem]"}>
|
||||
<Button variant={"secondary"} size={"xs"} className={"flex-1"}>
|
||||
Later
|
||||
{t("sessionExpired.later")}
|
||||
</Button>
|
||||
<Button variant={"primary"} size={"xs"} className={"flex-1"}>
|
||||
Sign in
|
||||
{t("sessionExpired.signIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
const showError = (message: string) =>
|
||||
Dialogs.Error({ Title: "Update Failed", Message: message });
|
||||
Dialogs.Error({ Title: i18next.t("update.page.failedTitle"), Message: message });
|
||||
|
||||
export default function Update() {
|
||||
const { t } = useTranslation();
|
||||
const [done, setDone] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -25,7 +28,7 @@ export default function Update() {
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
clearInterval(timer);
|
||||
setFailed(true);
|
||||
void showError("Update timed out.");
|
||||
void showError(i18next.t("update.page.timeoutMessage"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -53,15 +56,19 @@ export default function Update() {
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
{done ? (
|
||||
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
|
||||
<h1 className="text-xl font-semibold text-green-500">
|
||||
{t("update.page.complete")}
|
||||
</h1>
|
||||
) : failed ? (
|
||||
<h1 className="text-xl font-semibold text-red-500">Update failed</h1>
|
||||
<h1 className="text-xl font-semibold text-red-500">
|
||||
{t("update.page.failed")}
|
||||
</h1>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold">Updating…</h1>
|
||||
<h1 className="text-xl font-semibold">{t("update.page.updating")}</h1>
|
||||
<p className="mt-1 text-sm text-nb-gray-500">
|
||||
Please don't close this window.
|
||||
{t("update.page.dontClose")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user