From aaa5dbb606576fa44894256c26eebd1ede4a9fa8 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 9 Jun 2026 10:55:56 +0200 Subject: [PATCH 1/7] forward ui browser logs to go --- client/ui/frontend/src/app.tsx | 5 ++ client/ui/frontend/src/lib/logs.ts | 80 ++++++++++++++++++++++++++++++ client/ui/main.go | 1 + client/ui/services/uilog.go | 38 ++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 client/ui/frontend/src/lib/logs.ts create mode 100644 client/ui/services/uilog.go diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 1f143fbd6..e3aa72735 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -15,6 +15,11 @@ import { welcome } from "@/lib/welcome"; import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; +import { initLogForwarding } from "@/lib/logs"; + +// Install console.* + uncaught-error forwarding before anything else runs +// so even init-time logs reach the Go log pipeline. +initLogForwarding(); welcome(); diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts new file mode 100644 index 000000000..5a0a0a322 --- /dev/null +++ b/client/ui/frontend/src/lib/logs.ts @@ -0,0 +1,80 @@ +import { UILog } from "@bindings/services"; + +// Forwards browser console output and uncaught errors into the Go logrus +// pipeline. Originals still fire, so DevTools is unchanged; the Go +// --log-level does the gating. + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +const METHOD_LEVELS: Record = { + trace: "trace", + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +// Sources whose output is noise and shouldn't be forwarded. +const IGNORED_SOURCES = new Set(["welcome.ts"]); + +let installed = false; + +function format(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return a.stack || a.message; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +// First stack frame outside this module as ":" (best-effort; +// minified prod stacks degrade to chunk names). +function callerSource(): string { + const stack = new Error().stack; + if (!stack) return ""; + for (const line of stack.split("\n").slice(1)) { + if (line.includes("/logs.ts")) continue; + const m = line.match(/([^/\\() ]+\.[a-z]+):(\d+):\d+/i); + if (m) return `${m[1]}:${m[2]}`; + } + return ""; +} + +function forward(level: Level, args: unknown[]) { + try { + const source = callerSource(); + if (IGNORED_SOURCES.has(source.split(":")[0])) return; + // Fire-and-forget; don't touch console here (would recurse). + void UILog.Log(level, source, format(args)); + } catch { + // swallow + } +} + +export function initLogForwarding() { + if (installed) return; + installed = true; + + const c = console as unknown as Record void>; + for (const [method, level] of Object.entries(METHOD_LEVELS)) { + const original = c[method]?.bind(console); + c[method] = (...args: unknown[]) => { + original?.(...args); + forward(level, args); + }; + } + + window.addEventListener("error", (e) => { + forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); + }); + window.addEventListener("unhandledrejection", (e) => { + forward("error", ["unhandled promise rejection:", e.reason]); + }); +} diff --git a/client/ui/main.go b/client/ui/main.go index e4eabc17d..c91f363ac 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -316,6 +316,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) app.RegisterService(application.NewService(services.NewVersion())) + app.RegisterService(application.NewService(services.NewUILog())) } // newMainWindow creates the hidden main window, sized to the user's last view diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go new file mode 100644 index 000000000..0ce3c35c1 --- /dev/null +++ b/client/ui/services/uilog.go @@ -0,0 +1,38 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +// UILog lets the frontend forward console output into the Go logrus +// pipeline. The JS origin rides in the message ("[ui ] ...") +// because logrus's ReportCaller always pins the source to this file. +type UILog struct{} + +func NewUILog() *UILog { return &UILog{} } + +// Log forwards one frontend console entry. level is trace/debug/info/warn/ +// error (anything else → info); source is the JS origin (may be empty). +func (s *UILog) Log(_ context.Context, level, source, msg string) { + if source != "" { + msg = "[ui " + source + "] " + msg + } else { + msg = "[ui] " + msg + } + switch level { + case "trace": + logrus.Trace(msg) + case "debug": + logrus.Debug(msg) + case "warn", "warning": + logrus.Warn(msg) + case "error": + logrus.Error(msg) + default: + logrus.Info(msg) + } +} From 32be58cb247f9b4636ba4b148e59a20c267abfbb Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 9 Jun 2026 11:33:54 +0200 Subject: [PATCH 2/7] add description to translations --- client/ui/CLAUDE.md | 2 + client/ui/frontend/CLAUDE.md | 2 +- client/ui/frontend/src/lib/i18n.ts | 15 +- client/ui/i18n/bundle.go | 22 +- client/ui/i18n/bundle_test.go | 14 +- client/ui/i18n/locales/de/common.json | 1712 ++++++++++++++------ client/ui/i18n/locales/en/common.json | 2134 +++++++++++++++++++------ client/ui/i18n/locales/hu/common.json | 1712 ++++++++++++++------ 8 files changed, 4243 insertions(+), 1370 deletions(-) diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 62b9d6eb7..33eb8f8a0 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -110,6 +110,8 @@ The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `/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: "}" }`. +**Bundle shape is Chrome-extension JSON**: each key maps to `{ "message": "...", "description": "..." }`, not a bare string. `description` is **translator context for Crowdin** (which reads it natively from the source file) and is ignored at runtime — only `en/common.json` needs descriptions; target bundles carry just `message`. Both loaders strip back to a flat `key→message` map: Go's `loadBundle` (`bundle.go`) unmarshals into `map[string]bundleEntry` and flattens (so `BundleFor`/`Translate` signatures are unchanged); `frontend/src/lib/i18n.ts` maps each entry's `message` into the i18next `resources`. When editing a string, edit `message`; when a key's purpose isn't obvious from its name, add/update its `description` so translators (and screenshots auto-tagging) have context. + Adding a language: drop a `/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`. Package layout: diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 8dbffd5bb..1d89f8f78 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -151,7 +151,7 @@ if (result !== confirmLabel) return; Compare against the variable, never against an English literal. -**Bundle files.** Keys live in `client/ui/i18n/locales//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. +**Bundle files.** Keys live in `client/ui/i18n/locales//common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "General", "description": "..." }` (not a bare string). `description` is translator context for Crowdin (read natively from the source file, ignored at runtime); only `en/common.json` carries descriptions, target bundles carry just `message`. `lib/i18n.ts` strips each entry down to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Adding a key: add `{ "message": "…", "description": "…" }` to `en/common.json` first (the fallback), then `{ "message": "…" }` to 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 `client/ui/i18n/locales//common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship** — `LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), 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. diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index 6756a52be..d1fbb0e19 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -15,7 +15,13 @@ import { LanguageCode } from "@bindings/i18n/models.js"; // empty match in some Vite dev-mode setups. `server.fs.allow` in // `vite.config.ts` whitelists the parent directory so the dev server // serves the JSON. -const bundleModules = import.meta.glob>( +// +// Each bundle is Chrome-extension JSON: every key maps to +// `{ message, description? }`. `description` exists only so Crowdin can +// show translator context — it's stripped here and i18next sees a flat +// key->message map exactly as before. +type BundleEntry = { message: string; description?: string }; +const bundleModules = import.meta.glob>( "../../../i18n/locales/*/common.json", { eager: true, import: "default" }, ); @@ -24,7 +30,12 @@ const resources: Record }> = {}; for (const path in bundleModules) { const match = path.match(/locales\/([^/]+)\/common\.json$/); if (match) { - resources[match[1]] = { common: bundleModules[path] }; + const entries = bundleModules[path]; + const messages: Record = {}; + for (const key in entries) { + messages[key] = entries[key].message; + } + resources[match[1]] = { common: messages }; } } diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go index 047cddc96..42d6369ef 100644 --- a/client/ui/i18n/bundle.go +++ b/client/ui/i18n/bundle.go @@ -33,6 +33,12 @@ const ( // commonBundleFile is the per-language translation bundle. Single // namespace for now ("common") — split later if the key set grows // enough to warrant per-screen bundles. + // + // Shape is Chrome-extension JSON (each key maps to an object with a + // "message" and an optional "description") so Crowdin reads the + // description as translator context straight from the source file. + // Only the source bundle (en) needs descriptions; target bundles carry + // just "message". loadBundle flattens both back to key->message. commonBundleFile = "common.json" ) @@ -195,15 +201,27 @@ func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { return &idx, nil } +// bundleEntry is the on-disk shape of one translation key: a Chrome-JSON +// object carrying the translatable "message" plus an optional translator +// "description" (consumed by Crowdin, ignored at runtime). +type bundleEntry struct { + Message string `json:"message"` + Description string `json:"description,omitempty"` +} + func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) { p := path.Join(string(code), commonBundleFile) data, err := fs.ReadFile(localesFS, p) if err != nil { return nil, err } - var bundle map[string]string - if err := json.Unmarshal(data, &bundle); err != nil { + var entries map[string]bundleEntry + if err := json.Unmarshal(data, &entries); err != nil { return nil, fmt.Errorf("parse %s: %w", p, err) } + bundle := make(map[string]string, len(entries)) + for k, e := range entries { + bundle[k] = e.Message + } return bundle, nil } diff --git a/client/ui/i18n/bundle_test.go b/client/ui/i18n/bundle_test.go index 0c3e5f612..d0b0d24d7 100644 --- a/client/ui/i18n/bundle_test.go +++ b/client/ui/i18n/bundle_test.go @@ -23,13 +23,13 @@ func fakeLocales() fstest.MapFS { ] }`)}, "en/common.json": {Data: []byte(`{ - "tray.menu.connect": "Connect", - "tray.menu.installVersion": "Install version {version}", - "notify.update.body": "NetBird {version} is available." + "tray.menu.connect": {"message": "Connect", "description": "Tray menu item"}, + "tray.menu.installVersion": {"message": "Install version {version}"}, + "notify.update.body": {"message": "NetBird {version} is available."} }`)}, "hu/common.json": {Data: []byte(`{ - "tray.menu.connect": "Csatlakozás", - "tray.menu.installVersion": "{version} telepítése" + "tray.menu.connect": {"message": "Csatlakozás"}, + "tray.menu.installVersion": {"message": "{version} telepítése"} }`)}, } } @@ -118,7 +118,7 @@ func TestBundle_MissingDefaultBundleFails(t *testing.T) { // English locale. fs := fstest.MapFS{ "_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)}, - "hu/common.json": {Data: []byte(`{"k":"v"}`)}, + "hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, } _, err := NewBundle(fs) require.Error(t, err) @@ -134,7 +134,7 @@ func TestBundle_MissingBundleSkipsLanguage(t *testing.T) { {"code":"en","displayName":"English","englishName":"English"}, {"code":"de","displayName":"Deutsch","englishName":"German"} ]}`)}, - "en/common.json": {Data: []byte(`{"k":"v"}`)}, + "en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, } b, err := NewBundle(fs) require.NoError(t, err) diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index d37f0f865..4cf31358e 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1,454 +1,1262 @@ { - "tray.tooltip": "NetBird", - "tray.status.disconnected": "Getrennt", - "tray.status.daemonUnavailable": "Nicht aktiv", - "tray.status.error": "Fehler", - "tray.status.connected": "Verbunden", - "tray.status.connecting": "Verbinde", - "tray.status.needsLogin": "Anmeldung erforderlich", - "tray.status.loginFailed": "Anmeldung fehlgeschlagen", - "tray.status.sessionExpired": "Sitzung abgelaufen", - "tray.session.expiresIn": "Sitzung läuft ab in {remaining}", - "tray.session.unit.lessThanMinute": "weniger als einer Minute", - "tray.session.unit.minute": "1 Minute", - "tray.session.unit.minutes": "{count} Minuten", - "tray.session.unit.hour": "1 Stunde", - "tray.session.unit.hours": "{count} Stunden", - "tray.session.unit.day": "1 Tag", - "tray.session.unit.days": "{count} Tagen", - - "tray.menu.open": "NetBird öffnen", - "tray.menu.connect": "Verbinden", - "tray.menu.disconnect": "Trennen", - "tray.menu.exitNode": "Exit-Node", - "tray.menu.networks": "Ressourcen", - "tray.menu.profiles": "Profile", - "tray.menu.manageProfiles": "Profile verwalten", - "tray.menu.settings": "Einstellungen...", - "tray.menu.debugBundle": "Debug-Paket erstellen", - "tray.menu.about": "Hilfe & Support", - "tray.menu.github": "GitHub", - "tray.menu.documentation": "Dokumentation", - "tray.menu.troubleshoot": "Fehlerbehebung", - "tray.menu.downloadLatest": "Neueste Version herunterladen", - "tray.menu.installVersion": "Version {version} installieren", - "tray.menu.guiVersion": "Oberfläche: {version}", - "tray.menu.daemonVersion": "Daemon: {version}", - "tray.menu.versionUnknown": "—", - "tray.menu.quit": "NetBird beenden", - - "notify.update.title": "NetBird-Update verfügbar", - "notify.update.body": "NetBird {version} ist verfügbar.", - "notify.update.enforcedSuffix": " Ihr Administrator verlangt dieses Update.", - "notify.error.title": "Fehler", - "notify.error.connect": "Verbindung fehlgeschlagen", - "notify.error.disconnect": "Trennen fehlgeschlagen", - "notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen", - "notify.error.exitNode": "Exit-Node {name} konnte nicht aktualisiert werden", - "notify.sessionExpired.title": "NetBird-Sitzung abgelaufen", - "notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", - "notify.sessionWarning.title": "Sitzung läuft bald ab", - "notify.sessionWarning.body": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.", - "notify.sessionWarning.bodyGeneric": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.", - "notify.sessionWarning.extend": "Jetzt verlängern", - "notify.sessionWarning.dismiss": "Verwerfen", - "notify.sessionWarning.failed": "NetBird-Sitzung konnte nicht verlängert werden", - "notify.sessionWarning.successTitle": "NetBird-Sitzung verlängert", - "notify.sessionWarning.successBody": "Ihre Sitzung wurde erneuert.", - "notify.sessionDeadlineRejected.title": "Sitzungsfrist abgelehnt", - "notify.sessionDeadlineRejected.body": "Der Server hat eine ungültige Sitzungsfrist übermittelt. Bitte melden Sie sich erneut an.", - - "common.cancel": "Abbrechen", - "common.save": "Speichern", - "common.saveChanges": "Änderungen speichern", - "common.saving": "Speichert…", - "common.close": "Schließen", - "common.copy": "Kopieren", - "common.togglePasswordVisibility": "Passwortsichtbarkeit umschalten", - "common.increase": "Erhöhen", - "common.decrease": "Verringern", - "common.delete": "Löschen", - "common.create": "Erstellen", - "common.add": "Hinzufügen", - "common.remove": "Entfernen", - "common.refresh": "Aktualisieren", - "common.loading": "Lädt…", - "common.netbird": "NetBird", - "common.noResults.title": "Keine Ergebnisse gefunden", - "common.noResults.description": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter.", - "notConnected.title": "Getrennt", - "notConnected.description": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen.", - - "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": "{connected} von {total} verbunden", - "nav.resources.title": "Ressourcen", - "nav.resources.description": "{active} von {total} aktiv", - "nav.exitNode.title": "Exit-Nodes", - "nav.exitNode.none": "Nicht aktiv", - "nav.exitNode.using": "Über {name}", - - "header.openSettings": "Einstellungen öffnen", - "header.togglePanel": "Seitenleiste umschalten", - "header.menu.settings": "Einstellungen …", - "header.menu.defaultView": "Standardansicht", - "header.menu.advancedView": "Erweiterte Ansicht", - "header.menu.updateAvailable": "Update verfügbar", - - "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.selector.switchTo": "Zu diesem Profil wechseln", - "profile.dropdown.activeProfile": "Aktives Profil", - "profile.dropdown.switchProfile": "Profil wechseln", - "profile.dropdown.noEmail": "Andere", - "profile.dropdown.addProfile": "Profil hinzufügen", - "profile.dropdown.manageProfiles": "Profile verwalten", - "profile.dropdown.settings": "Einstellungen", - - "profile.dialog.title": "Neues Profil", - "profile.dialog.nameLabel": "Profilname", - "profile.dialog.description": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest.", - "profile.dialog.placeholder": "z. B. Arbeit", - "profile.dialog.required": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat", - "profile.dialog.submit": "Profil hinzufügen", - "profile.dialog.managementHelp": "NetBird Cloud oder Ihr eigener Server.", - "profile.dialog.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist.", - - "profile.switch.title": "Zu Profil \"{name}\" wechseln?", - "profile.switch.message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt.", - "profile.switch.confirm": "Bestätigen", - "profile.deregister.title": "Profil \"{name}\" abmelden?", - "profile.deregister.message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen.", - "profile.deregister.confirm": "Abmelden", - "profile.delete.title": "Profil \"{name}\" löschen?", - "profile.delete.message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden.", - "profile.delete.disabledActive": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen.", - "profile.delete.disabledDefault": "Das Standardprofil kann nicht gelöscht 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.profiles": "Profile", - "settings.tabs.advanced": "Erweitert", - "settings.tabs.troubleshooting": "Fehlerbehebung", - "settings.tabs.about": "Über", - "settings.tabs.updateAvailable": "Update verfügbar", - - "settings.profiles.section.profiles": "Profile", - "settings.profiles.intro": "Halten Sie separate NetBird-Identitäten nebeneinander, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie.", - "settings.profiles.addProfile": "Profil hinzufügen", - "settings.profiles.active": "Aktiv", - "settings.profiles.emptyTitle": "Keine Profile", - "settings.profiles.emptyDescription": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden.", - - "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.autostart.label": "NetBird-UI beim Anmelden starten", - "settings.general.autostart.help": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst.", - "settings.general.autostart.errorTitle": "Ändern des Autostarts fehlgeschlagen", - "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.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist.", - "settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?", - "settings.general.management.switchCloudMessage": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMö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.network.ipv6.label": "IPv6 aktivieren", - "settings.network.ipv6.help": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden.", - - "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.interfaceName.error": "Verwende 1–15 Buchstaben, Ziffern, Punkt, Bindestrich oder Unterstrich.", - "settings.advanced.interfaceName.errorMac": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100).", - "settings.advanced.port.label": "Port", - "settings.advanced.port.error": "Gib einen Port zwischen {min} und {max} ein.", - "settings.advanced.port.help": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet.", - "settings.advanced.mtu.label": "MTU", - "settings.advanced.mtu.error": "Gib eine MTU zwischen {min} und {max} ein.", - "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.
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.clientName": "NetBird Client", - "settings.about.development": "[Entwicklung]", - "settings.about.gui": "Oberfläche v{version}", - "settings.about.guiName": "Oberfläche", - "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", - "settings.about.community.github": "GitHub", - "settings.about.community.slack": "Slack", - "settings.about.community.forum": "Forum", - "settings.about.community.documentation": "Dokumentation", - "settings.about.community.feedback": "Feedback", - - "update.banner.message": "NetBird {version} ist installationsbereit.", - "update.banner.later": "Später", - "update.banner.installNow": "Jetzt installieren", - "update.card.versionAvailableDownload": "Version {version} ist zum Herunterladen verfügbar.", - "update.card.versionAvailableInstall": "Version {version} ist zur Installation verfügbar.", - "update.card.whatsNew": "Was ist neu?", - "update.card.installNow": "Jetzt installieren", - "update.card.getInstaller": "Herunterladen", - "update.card.autoCheckInterval": "NetBird sucht im Hintergrund nach Updates.", - "update.card.changelog": "Änderungsprotokoll", - "update.card.onLatestVersion": "Du verwendest die neueste Version", - "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.error.loadStateTitle": "Laden des Update-Status fehlgeschlagen", - "update.error.triggerTitle": "Update-Start fehlgeschlagen", - - "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", - - "window.title.settings": "Einstellungen", - "window.title.signIn": "Anmeldung", - "window.title.sessionExpiration": "Sitzung läuft ab", - "window.title.updating": "Aktualisierung", - "window.title.welcome": "Willkommen bei NetBird", - "window.title.error": "Fehler", - - "welcome.title": "Suchen Sie NetBird in der Taskleiste", - "welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.", - "welcome.continue": "Weiter", - "welcome.back": "Zurück", - "welcome.management.title": "NetBird einrichten", - "welcome.management.description": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben.", - "welcome.management.cloud.title": "NetBird Cloud", - "welcome.management.cloud.description": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig.", - "welcome.management.selfHosted.title": "Selbst gehostet", - "welcome.management.selfHosted.description": "Verbindung zu Ihrem eigenen Management-Server.", - "welcome.management.urlLabel": "URL des Management-Servers", - "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", - "welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443", - "welcome.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist.", - "welcome.management.checking": "Wird geprüft …", - - "browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort", - "browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?", - "browserLogin.tryAgain": "Erneut versuchen", - "browserLogin.openFailedTitle": "Browser konnte nicht geöffnet werden", - - "sessionExpiration.title": "Sitzung läuft bald ab", - "sessionExpiration.titleLater": "Ihre Sitzung läuft ab", - "sessionExpiration.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.", - "sessionExpiration.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.", - "sessionExpiration.stay": "Sitzung erneuern", - "sessionExpiration.authenticate": "Anmelden", - "sessionExpiration.logout": "Abmelden", - "sessionExpiration.expired": "Sitzung abgelaufen", - "sessionExpiration.expiredDescription": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden.", - "sessionExpiration.close": "Schließen", - "sessionExpiration.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen", - "sessionExpiration.logoutFailedTitle": "Abmeldung fehlgeschlagen", - - "peers.search.placeholder": "Nach Name oder IP suchen", - "peers.filter.all": "Alle", - "peers.filter.online": "Online", - "peers.filter.offline": "Offline", - "peers.empty.title": "Keine Peers verfügbar", - "peers.empty.description": "Sie haben entweder keine Peers verfügbar oder keinen Zugriff auf einen davon.", - "peers.details.domain": "Domain", - "peers.details.netbirdIp": "NetBird-IP", - "peers.details.netbirdIpv6": "NetBird-IPv6", - "peers.details.publicKey": "Öffentlicher Schlüssel", - "peers.details.connection": "Verbindung", - "peers.details.latency": "Latenz", - "peers.details.lastHandshake": "Letzter Handshake", - "peers.details.statusSince": "Letzte Verbindungsaktualisierung", - "peers.details.bytes": "Bytes", - "peers.details.bytesSent": "Gesendet", - "peers.details.bytesReceived": "Empfangen", - "peers.details.localIce": "Lokales ICE", - "peers.details.remoteIce": "Remote ICE", - "peers.details.never": "Nie", - "peers.details.justNow": "Gerade eben", - "peers.details.refresh": "Aktualisieren", - "peers.status.connected": "Verbunden", - "peers.status.connecting": "Verbinde", - "peers.status.disconnected": "Getrennt", - "peers.details.relayAddress": "Relay", - "peers.details.networks": "Ressourcen", - "peers.details.relayed": "Relayed", - "peers.details.p2p": "P2P", - "peers.details.rosenpass": "Rosenpass aktiviert", - - "networks.search.placeholder": "Nach Netzwerk oder Domain suchen", - "networks.filter.all": "Alle", - "networks.filter.active": "Aktiv", - "networks.filter.overlapping": "Überlappend", - "networks.empty.title": "Keine Ressourcen verfügbar", - "networks.empty.description": "Sie haben entweder keine Netzwerkressourcen verfügbar oder keinen Zugriff auf eine davon.", - "networks.selected": "Ausgewählt", - "networks.unselected": "Nicht ausgewählt", - "networks.ips.heading": "Aufgelöste IPs", - "networks.bulk.selectionCount": "{selected} von {total} aktiv", - "networks.bulk.enableAll": "Alle aktivieren", - "networks.bulk.disableAll": "Alle deaktivieren", - - "exitNodes.search.placeholder": "Exit Nodes suchen", - "exitNodes.none": "Keiner", - "exitNodes.empty.title": "Keine Exit Nodes verfügbar", - "exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.", - "exitNodes.card.title": "Exit Node", - "exitNodes.card.statusActive": "Aktiv", - "exitNodes.card.statusInactive": "Inaktiv", - "exitNodes.dropdown.noneTitle": "Keiner", - "exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node", - - "quickActions.connect": "Verbinden", - "quickActions.disconnect": "Trennen", - - "daemon.unavailable.title": "NetBird-Dienst läuft nicht", - "daemon.unavailable.description": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft.", - "daemon.unavailable.docsLink": "Dokumentation", - - "error.jwt_clock_skew": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut.", - "error.jwt_expired": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an.", - "error.jwt_signature_invalid": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator.", - "error.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", - "error.invalid_setup_key": "Der Setup-Schlüssel fehlt oder ist ungültig.", - "error.permission_denied": "Die Anmeldung wurde vom Server abgelehnt.", - "error.daemon_unreachable": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft.", - "error.unknown": "Vorgang fehlgeschlagen." + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Getrennt" + }, + "tray.status.daemonUnavailable": { + "message": "Nicht aktiv" + }, + "tray.status.error": { + "message": "Fehler" + }, + "tray.status.connected": { + "message": "Verbunden" + }, + "tray.status.connecting": { + "message": "Verbinde" + }, + "tray.status.needsLogin": { + "message": "Anmeldung erforderlich" + }, + "tray.status.loginFailed": { + "message": "Anmeldung fehlgeschlagen" + }, + "tray.status.sessionExpired": { + "message": "Sitzung abgelaufen" + }, + "tray.session.expiresIn": { + "message": "Sitzung läuft ab in {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "weniger als einer Minute" + }, + "tray.session.unit.minute": { + "message": "1 Minute" + }, + "tray.session.unit.minutes": { + "message": "{count} Minuten" + }, + "tray.session.unit.hour": { + "message": "1 Stunde" + }, + "tray.session.unit.hours": { + "message": "{count} Stunden" + }, + "tray.session.unit.day": { + "message": "1 Tag" + }, + "tray.session.unit.days": { + "message": "{count} Tagen" + }, + "tray.menu.open": { + "message": "NetBird öffnen" + }, + "tray.menu.connect": { + "message": "Verbinden" + }, + "tray.menu.disconnect": { + "message": "Trennen" + }, + "tray.menu.exitNode": { + "message": "Exit-Node" + }, + "tray.menu.networks": { + "message": "Ressourcen" + }, + "tray.menu.profiles": { + "message": "Profile" + }, + "tray.menu.manageProfiles": { + "message": "Profile verwalten" + }, + "tray.menu.settings": { + "message": "Einstellungen..." + }, + "tray.menu.debugBundle": { + "message": "Debug-Paket erstellen" + }, + "tray.menu.about": { + "message": "Hilfe & Support" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentation" + }, + "tray.menu.troubleshoot": { + "message": "Fehlerbehebung" + }, + "tray.menu.downloadLatest": { + "message": "Neueste Version herunterladen" + }, + "tray.menu.installVersion": { + "message": "Version {version} installieren" + }, + "tray.menu.guiVersion": { + "message": "Oberfläche: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird beenden" + }, + "notify.update.title": { + "message": "NetBird-Update verfügbar" + }, + "notify.update.body": { + "message": "NetBird {version} ist verfügbar." + }, + "notify.update.enforcedSuffix": { + "message": " Ihr Administrator verlangt dieses Update." + }, + "notify.error.title": { + "message": "Fehler" + }, + "notify.error.connect": { + "message": "Verbindung fehlgeschlagen" + }, + "notify.error.disconnect": { + "message": "Trennen fehlgeschlagen" + }, + "notify.error.switchProfile": { + "message": "Wechsel zu {profile} fehlgeschlagen" + }, + "notify.error.exitNode": { + "message": "Exit-Node {name} konnte nicht aktualisiert werden" + }, + "notify.sessionExpired.title": { + "message": "NetBird-Sitzung abgelaufen" + }, + "notify.sessionExpired.body": { + "message": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "notify.sessionWarning.title": { + "message": "Sitzung läuft bald ab" + }, + "notify.sessionWarning.body": { + "message": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.extend": { + "message": "Jetzt verlängern" + }, + "notify.sessionWarning.dismiss": { + "message": "Verwerfen" + }, + "notify.sessionWarning.failed": { + "message": "NetBird-Sitzung konnte nicht verlängert werden" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird-Sitzung verlängert" + }, + "notify.sessionWarning.successBody": { + "message": "Ihre Sitzung wurde erneuert." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Sitzungsfrist abgelehnt" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Der Server hat eine ungültige Sitzungsfrist übermittelt. Bitte melden Sie sich erneut an." + }, + "common.cancel": { + "message": "Abbrechen" + }, + "common.save": { + "message": "Speichern" + }, + "common.saveChanges": { + "message": "Änderungen speichern" + }, + "common.saving": { + "message": "Speichert…" + }, + "common.close": { + "message": "Schließen" + }, + "common.copy": { + "message": "Kopieren" + }, + "common.togglePasswordVisibility": { + "message": "Passwortsichtbarkeit umschalten" + }, + "common.increase": { + "message": "Erhöhen" + }, + "common.decrease": { + "message": "Verringern" + }, + "common.delete": { + "message": "Löschen" + }, + "common.create": { + "message": "Erstellen" + }, + "common.add": { + "message": "Hinzufügen" + }, + "common.remove": { + "message": "Entfernen" + }, + "common.refresh": { + "message": "Aktualisieren" + }, + "common.loading": { + "message": "Lädt…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Keine Ergebnisse gefunden" + }, + "common.noResults.description": { + "message": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter." + }, + "notConnected.title": { + "message": "Getrennt" + }, + "notConnected.description": { + "message": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen." + }, + "connect.status.disconnected": { + "message": "Getrennt" + }, + "connect.status.connecting": { + "message": "Verbindet…" + }, + "connect.status.connected": { + "message": "Verbunden" + }, + "connect.status.disconnecting": { + "message": "Trennt…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nicht verfügbar" + }, + "connect.status.loginRequired": { + "message": "Anmeldung erforderlich" + }, + "connect.error.loginTitle": { + "message": "Anmeldung fehlgeschlagen" + }, + "connect.error.connectTitle": { + "message": "Verbindung fehlgeschlagen" + }, + "connect.error.disconnectTitle": { + "message": "Trennen fehlgeschlagen" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} von {total} verbunden" + }, + "nav.resources.title": { + "message": "Ressourcen" + }, + "nav.resources.description": { + "message": "{active} von {total} aktiv" + }, + "nav.exitNode.title": { + "message": "Exit-Nodes" + }, + "nav.exitNode.none": { + "message": "Nicht aktiv" + }, + "nav.exitNode.using": { + "message": "Über {name}" + }, + "header.openSettings": { + "message": "Einstellungen öffnen" + }, + "header.togglePanel": { + "message": "Seitenleiste umschalten" + }, + "header.menu.settings": { + "message": "Einstellungen …" + }, + "header.menu.defaultView": { + "message": "Standardansicht" + }, + "header.menu.advancedView": { + "message": "Erweiterte Ansicht" + }, + "header.menu.updateAvailable": { + "message": "Update verfügbar" + }, + "profile.selector.loading": { + "message": "Lädt…" + }, + "profile.selector.noProfile": { + "message": "Kein Profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil nach Namen suchen…" + }, + "profile.selector.emptyTitle": { + "message": "Keine Profile gefunden" + }, + "profile.selector.emptyDescription": { + "message": "Versuchen Sie einen anderen Suchbegriff oder erstellen Sie ein neues Profil." + }, + "profile.selector.newProfile": { + "message": "Neues Profil" + }, + "profile.selector.moreOptions": { + "message": "Weitere Optionen" + }, + "profile.selector.deregister": { + "message": "Abmelden" + }, + "profile.selector.delete": { + "message": "Profil löschen" + }, + "profile.selector.switchTo": { + "message": "Zu diesem Profil wechseln" + }, + "profile.dropdown.activeProfile": { + "message": "Aktives Profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profil wechseln" + }, + "profile.dropdown.noEmail": { + "message": "Andere" + }, + "profile.dropdown.addProfile": { + "message": "Profil hinzufügen" + }, + "profile.dropdown.manageProfiles": { + "message": "Profile verwalten" + }, + "profile.dropdown.settings": { + "message": "Einstellungen" + }, + "profile.dialog.title": { + "message": "Neues Profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilname" + }, + "profile.dialog.description": { + "message": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest." + }, + "profile.dialog.placeholder": { + "message": "z. B. Arbeit" + }, + "profile.dialog.required": { + "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" + }, + "profile.dialog.submit": { + "message": "Profil hinzufügen" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud oder Ihr eigener Server." + }, + "profile.dialog.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist." + }, + "profile.switch.title": { + "message": "Zu Profil \"{name}\" wechseln?" + }, + "profile.switch.message": { + "message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt." + }, + "profile.switch.confirm": { + "message": "Bestätigen" + }, + "profile.deregister.title": { + "message": "Profil \"{name}\" abmelden?" + }, + "profile.deregister.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen." + }, + "profile.deregister.confirm": { + "message": "Abmelden" + }, + "profile.delete.title": { + "message": "Profil \"{name}\" löschen?" + }, + "profile.delete.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden." + }, + "profile.delete.disabledActive": { + "message": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen." + }, + "profile.delete.disabledDefault": { + "message": "Das Standardprofil kann nicht gelöscht werden." + }, + "profile.error.switchTitle": { + "message": "Profilwechsel fehlgeschlagen" + }, + "profile.error.deregisterTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "profile.error.deleteTitle": { + "message": "Löschen des Profils fehlgeschlagen" + }, + "profile.error.createTitle": { + "message": "Erstellen des Profils fehlgeschlagen" + }, + "profile.error.loadTitle": { + "message": "Laden der Profile fehlgeschlagen" + }, + "settings.error.loadTitle": { + "message": "Laden der Einstellungen fehlgeschlagen" + }, + "settings.error.saveTitle": { + "message": "Speichern der Einstellungen fehlgeschlagen" + }, + "settings.error.debugBundleTitle": { + "message": "Debug-Paket fehlgeschlagen" + }, + "settings.tabs.general": { + "message": "Allgemein" + }, + "settings.tabs.network": { + "message": "Netzwerk" + }, + "settings.tabs.security": { + "message": "Sicherheit" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.profiles": { + "message": "Profile" + }, + "settings.tabs.advanced": { + "message": "Erweitert" + }, + "settings.tabs.troubleshooting": { + "message": "Fehlerbehebung" + }, + "settings.tabs.about": { + "message": "Über" + }, + "settings.tabs.updateAvailable": { + "message": "Update verfügbar" + }, + "settings.profiles.section.profiles": { + "message": "Profile" + }, + "settings.profiles.intro": { + "message": "Halten Sie separate NetBird-Identitäten nebeneinander, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." + }, + "settings.profiles.addProfile": { + "message": "Profil hinzufügen" + }, + "settings.profiles.active": { + "message": "Aktiv" + }, + "settings.profiles.emptyTitle": { + "message": "Keine Profile" + }, + "settings.profiles.emptyDescription": { + "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." + }, + "settings.general.section.general": { + "message": "Allgemein" + }, + "settings.general.section.connection": { + "message": "Verbindung" + }, + "settings.general.connectOnStartup.label": { + "message": "Beim Start verbinden" + }, + "settings.general.connectOnStartup.help": { + "message": "Beim Start des Dienstes automatisch eine Verbindung herstellen." + }, + "settings.general.notifications.label": { + "message": "Desktop-Benachrichtigungen" + }, + "settings.general.notifications.help": { + "message": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen." + }, + "settings.general.autostart.label": { + "message": "NetBird-UI beim Anmelden starten" + }, + "settings.general.autostart.help": { + "message": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst." + }, + "settings.general.autostart.errorTitle": { + "message": "Ändern des Autostarts fehlgeschlagen" + }, + "settings.general.language.label": { + "message": "Anzeigesprache" + }, + "settings.general.language.help": { + "message": "Wählen Sie die Sprache der NetBird-Oberfläche." + }, + "settings.general.language.search": { + "message": "Sprache suchen…" + }, + "settings.general.language.empty": { + "message": "Keine Sprachen gefunden." + }, + "settings.general.management.label": { + "message": "Management-Server" + }, + "settings.general.management.help": { + "message": "Mit NetBird Cloud oder Ihrem eigenen self-hosted Management-Server verbinden. Änderungen lösen eine Neuverbindung des Clients aus." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist." + }, + "settings.general.management.switchCloudTitle": { + "message": "Zu NetBird Cloud wechseln?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Zu Cloud wechseln" + }, + "settings.network.section.connectivity": { + "message": "Konnektivität" + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS" + }, + "settings.network.lazy.label": { + "message": "Verzögerte Verbindungen" + }, + "settings.network.lazy.help": { + "message": "Statt durchgehend aktive Verbindungen zu halten, aktiviert NetBird sie bei Bedarf anhand von Aktivität oder Signalisierung." + }, + "settings.network.monitor.label": { + "message": "Bei Netzwerkwechsel neu verbinden" + }, + "settings.network.monitor.help": { + "message": "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": { + "message": "DNS aktivieren" + }, + "settings.network.dns.help": { + "message": "NetBird-verwaltete DNS-Einstellungen auf den Host-Resolver anwenden." + }, + "settings.network.clientRoutes.label": { + "message": "Client-Routen aktivieren" + }, + "settings.network.clientRoutes.help": { + "message": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen." + }, + "settings.network.serverRoutes.label": { + "message": "Server-Routen aktivieren" + }, + "settings.network.serverRoutes.help": { + "message": "Lokale Routen dieses Hosts an andere Peers ankündigen." + }, + "settings.network.ipv6.label": { + "message": "IPv6 aktivieren" + }, + "settings.network.ipv6.help": { + "message": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Verschlüsselung" + }, + "settings.security.blockInbound.label": { + "message": "Eingehenden Verkehr blockieren" + }, + "settings.security.blockInbound.help": { + "message": "Unaufgeforderte Verbindungen von Peers zu diesem Gerät und den von ihm gerouteten Netzwerken ablehnen. Ausgehender Verkehr ist nicht betroffen." + }, + "settings.security.blockLan.label": { + "message": "LAN-Zugriff blockieren" + }, + "settings.security.blockLan.help": { + "message": "Verhindert, dass Peers Ihr lokales Netzwerk oder dessen Geräte erreichen, wenn dieses Gerät deren Verkehr routet." + }, + "settings.security.rosenpass.label": { + "message": "Quantenresistenz aktivieren" + }, + "settings.security.rosenpass.help": { + "message": "Einen post-quanten Schlüsselaustausch via Rosenpass zusätzlich zu WireGuard® hinzufügen." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Permissiven Modus aktivieren" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Verbindungen zu Peers ohne Quantenresistenz-Unterstützung erlauben." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funktionen" + }, + "settings.ssh.section.authentication": { + "message": "Authentifizierung" + }, + "settings.ssh.server.label": { + "message": "SSH-Server aktivieren" + }, + "settings.ssh.server.help": { + "message": "Den NetBird SSH-Server auf diesem Host ausführen, damit andere Peers sich verbinden können." + }, + "settings.ssh.root.label": { + "message": "Root-Login erlauben" + }, + "settings.ssh.root.help": { + "message": "Peers dürfen sich als root anmelden. Deaktivieren, um ein nicht-privilegiertes Konto zu erfordern." + }, + "settings.ssh.sftp.label": { + "message": "SFTP erlauben" + }, + "settings.ssh.sftp.help": { + "message": "Dateien sicher über native SFTP- oder SCP-Clients übertragen." + }, + "settings.ssh.localForward.label": { + "message": "Lokale Portweiterleitung" + }, + "settings.ssh.localForward.help": { + "message": "Verbundene Peers können lokale Ports zu von diesem Host erreichbaren Diensten tunneln." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote-Portweiterleitung" + }, + "settings.ssh.remoteForward.help": { + "message": "Verbundene Peers können Ports dieses Hosts an ihren eigenen Rechner weitergeben." + }, + "settings.ssh.jwt.label": { + "message": "JWT-Authentifizierung aktivieren" + }, + "settings.ssh.jwt.help": { + "message": "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": { + "message": "JWT-Cache-TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "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": { + "message": "Sekunde(n)" + }, + "settings.advanced.section.interface": { + "message": "Schnittstelle" + }, + "settings.advanced.section.security": { + "message": "Sicherheit" + }, + "settings.advanced.interfaceName.label": { + "message": "Name" + }, + "settings.advanced.interfaceName.error": { + "message": "Verwende 1–15 Buchstaben, Ziffern, Punkt, Bindestrich oder Unterstrich." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Gib einen Port zwischen {min} und {max} ein." + }, + "settings.advanced.port.help": { + "message": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Gib eine MTU zwischen {min} und {max} ein." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key" + }, + "settings.advanced.psk.help": { + "message": "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": { + "message": "Debug-Paket" + }, + "settings.troubleshooting.intro": { + "message": "Ein Debug-Paket hilft dem NetBird-Support bei der Untersuchung von Verbindungsproblemen.
Es ist eine .zip-Datei mit Logs, Systemdetails und Debug-Informationen Ihres Geräts." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Sensible Informationen anonymisieren" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Systeminformationen einschließen" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS, Kernel, Netzwerkschnittstellen und Routing-Tabellen einschließen." + }, + "settings.troubleshooting.upload.label": { + "message": "Paket an NetBird-Server hochladen" + }, + "settings.troubleshooting.upload.help": { + "message": "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": { + "message": "Trace-Logs erfassen" + }, + "settings.troubleshooting.trace.help": { + "message": "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": { + "message": "Aufzeichnungsdauer" + }, + "settings.troubleshooting.duration.help": { + "message": "Wie lange Trace-Logs vor der Paketerstellung erfasst werden sollen." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(n)" + }, + "settings.troubleshooting.create": { + "message": "Paket erstellen" + }, + "settings.troubleshooting.progress.description": { + "message": "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": { + "message": "Wird abgebrochen…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug-Paket erfolgreich hochgeladen!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paket gespeichert" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "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": { + "message": "Ihr Debug-Paket wurde lokal gespeichert." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Schlüssel kopieren" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ordner öffnen" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Speicherort öffnen" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload fehlgeschlagen: {reason} Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload fehlgeschlagen. Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Wechsel zu Trace-Logging…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird wird neu verbunden…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Logs werden erfasst — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Vorheriges Log-Level wird wiederhergestellt…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Debug-Paket wird erstellt…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Wird zu NetBird hochgeladen…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Entwicklung]" + }, + "settings.about.gui": { + "message": "Oberfläche v{version}" + }, + "settings.about.guiName": { + "message": "Oberfläche" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Alle Rechte vorbehalten." + }, + "settings.about.links.imprint": { + "message": "Impressum" + }, + "settings.about.links.privacy": { + "message": "Datenschutz" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Nutzungsbedingungen" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Dokumentation" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} ist installationsbereit." + }, + "update.banner.later": { + "message": "Später" + }, + "update.banner.installNow": { + "message": "Jetzt installieren" + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} ist zum Herunterladen verfügbar." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} ist zur Installation verfügbar." + }, + "update.card.whatsNew": { + "message": "Was ist neu?" + }, + "update.card.installNow": { + "message": "Jetzt installieren" + }, + "update.card.getInstaller": { + "message": "Herunterladen" + }, + "update.card.autoCheckInterval": { + "message": "NetBird sucht im Hintergrund nach Updates." + }, + "update.card.changelog": { + "message": "Änderungsprotokoll" + }, + "update.card.onLatestVersion": { + "message": "Du verwendest die neueste Version" + }, + "update.header.tooltip": { + "message": "Update verfügbar" + }, + "update.overlay.updatingVersion": { + "message": "NetBird wird auf v{version} aktualisiert" + }, + "update.overlay.updating": { + "message": "NetBird wird aktualisiert" + }, + "update.overlay.description": { + "message": "Eine neuere Version ist verfügbar und wird installiert. NetBird startet nach Abschluss des Updates automatisch neu." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update dauert zu lange" + }, + "update.overlay.error.timeoutDescription": { + "message": "Die Installation von {target} hat zu lange gedauert und wurde nicht abgeschlossen." + }, + "update.overlay.error.canceledTitle": { + "message": "Update wurde abgebrochen" + }, + "update.overlay.error.canceledDescription": { + "message": "Das Update auf {target} wurde vor dem Abschluss abgebrochen." + }, + "update.overlay.error.failTitle": { + "message": "Update konnte nicht installiert werden" + }, + "update.overlay.error.failDescription": { + "message": "{target} konnte nicht installiert werden." + }, + "update.overlay.error.unknownMessage": { + "message": "unbekannter Fehler" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "die neue Version" + }, + "update.error.loadStateTitle": { + "message": "Laden des Update-Status fehlgeschlagen" + }, + "update.error.triggerTitle": { + "message": "Update-Start fehlgeschlagen" + }, + "update.page.versionLine": { + "message": "Client wird aktualisiert auf: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Client wird aktualisiert." + }, + "update.page.outdated": { + "message": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version." + }, + "update.page.status.running": { + "message": "Aktualisiert" + }, + "update.page.status.timeout": { + "message": "Zeitüberschreitung beim Update. Bitte erneut versuchen." + }, + "update.page.status.canceled": { + "message": "Update abgebrochen." + }, + "update.page.status.failed": { + "message": "Update fehlgeschlagen: {message}" + }, + "update.page.status.unknownError": { + "message": "unbekannter Update-Fehler" + }, + "update.page.failedTitle": { + "message": "Update fehlgeschlagen" + }, + "update.page.timeoutMessage": { + "message": "Zeitüberschreitung beim Update." + }, + "update.page.dontClose": { + "message": "Bitte schließen Sie dieses Fenster nicht." + }, + "update.page.updating": { + "message": "Aktualisiert…" + }, + "update.page.complete": { + "message": "Update abgeschlossen" + }, + "update.page.failed": { + "message": "Update fehlgeschlagen" + }, + "window.title.settings": { + "message": "Einstellungen" + }, + "window.title.signIn": { + "message": "Anmeldung" + }, + "window.title.sessionExpiration": { + "message": "Sitzung läuft ab" + }, + "window.title.updating": { + "message": "Aktualisierung" + }, + "window.title.welcome": { + "message": "Willkommen bei NetBird" + }, + "window.title.error": { + "message": "Fehler" + }, + "welcome.title": { + "message": "Suchen Sie NetBird in der Taskleiste" + }, + "welcome.description": { + "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.continue": { + "message": "Weiter" + }, + "welcome.back": { + "message": "Zurück" + }, + "welcome.management.title": { + "message": "NetBird einrichten" + }, + "welcome.management.description": { + "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig." + }, + "welcome.management.selfHosted.title": { + "message": "Selbst gehostet" + }, + "welcome.management.selfHosted.description": { + "message": "Verbindung zu Ihrem eigenen Management-Server." + }, + "welcome.management.urlLabel": { + "message": "URL des Management-Servers" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist." + }, + "welcome.management.checking": { + "message": "Wird geprüft …" + }, + "browserLogin.title": { + "message": "Setzen Sie den Anmeldevorgang im Browser fort" + }, + "browserLogin.notSeeing": { + "message": "Sehen Sie den Browser-Tab nicht?" + }, + "browserLogin.tryAgain": { + "message": "Erneut versuchen" + }, + "browserLogin.openFailedTitle": { + "message": "Browser konnte nicht geöffnet werden" + }, + "sessionExpiration.title": { + "message": "Sitzung läuft bald ab" + }, + "sessionExpiration.titleLater": { + "message": "Ihre Sitzung läuft ab" + }, + "sessionExpiration.description": { + "message": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich." + }, + "sessionExpiration.descriptionLater": { + "message": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden." + }, + "sessionExpiration.stay": { + "message": "Sitzung erneuern" + }, + "sessionExpiration.authenticate": { + "message": "Anmelden" + }, + "sessionExpiration.logout": { + "message": "Abmelden" + }, + "sessionExpiration.expired": { + "message": "Sitzung abgelaufen" + }, + "sessionExpiration.expiredDescription": { + "message": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden." + }, + "sessionExpiration.close": { + "message": "Schließen" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Sitzungsverlängerung fehlgeschlagen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "peers.search.placeholder": { + "message": "Nach Name oder IP suchen" + }, + "peers.filter.all": { + "message": "Alle" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Keine Peers verfügbar" + }, + "peers.empty.description": { + "message": "Sie haben entweder keine Peers verfügbar oder keinen Zugriff auf einen davon." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird-IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird-IPv6" + }, + "peers.details.publicKey": { + "message": "Öffentlicher Schlüssel" + }, + "peers.details.connection": { + "message": "Verbindung" + }, + "peers.details.latency": { + "message": "Latenz" + }, + "peers.details.lastHandshake": { + "message": "Letzter Handshake" + }, + "peers.details.statusSince": { + "message": "Letzte Verbindungsaktualisierung" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Gesendet" + }, + "peers.details.bytesReceived": { + "message": "Empfangen" + }, + "peers.details.localIce": { + "message": "Lokales ICE" + }, + "peers.details.remoteIce": { + "message": "Remote ICE" + }, + "peers.details.never": { + "message": "Nie" + }, + "peers.details.justNow": { + "message": "Gerade eben" + }, + "peers.details.refresh": { + "message": "Aktualisieren" + }, + "peers.status.connected": { + "message": "Verbunden" + }, + "peers.status.connecting": { + "message": "Verbinde" + }, + "peers.status.disconnected": { + "message": "Getrennt" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Ressourcen" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass aktiviert" + }, + "networks.search.placeholder": { + "message": "Nach Netzwerk oder Domain suchen" + }, + "networks.filter.all": { + "message": "Alle" + }, + "networks.filter.active": { + "message": "Aktiv" + }, + "networks.filter.overlapping": { + "message": "Überlappend" + }, + "networks.empty.title": { + "message": "Keine Ressourcen verfügbar" + }, + "networks.empty.description": { + "message": "Sie haben entweder keine Netzwerkressourcen verfügbar oder keinen Zugriff auf eine davon." + }, + "networks.selected": { + "message": "Ausgewählt" + }, + "networks.unselected": { + "message": "Nicht ausgewählt" + }, + "networks.ips.heading": { + "message": "Aufgelöste IPs" + }, + "networks.bulk.selectionCount": { + "message": "{selected} von {total} aktiv" + }, + "networks.bulk.enableAll": { + "message": "Alle aktivieren" + }, + "networks.bulk.disableAll": { + "message": "Alle deaktivieren" + }, + "exitNodes.search.placeholder": { + "message": "Exit Nodes suchen" + }, + "exitNodes.none": { + "message": "Keiner" + }, + "exitNodes.empty.title": { + "message": "Keine Exit Nodes verfügbar" + }, + "exitNodes.empty.description": { + "message": "Für diesen Peer wurden keine Exit Nodes freigegeben." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktiv" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktiv" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Keiner" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direkte Verbindung ohne Exit Node" + }, + "quickActions.connect": { + "message": "Verbinden" + }, + "quickActions.disconnect": { + "message": "Trennen" + }, + "daemon.unavailable.title": { + "message": "NetBird-Dienst läuft nicht" + }, + "daemon.unavailable.description": { + "message": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentation" + }, + "error.jwt_clock_skew": { + "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." + }, + "error.jwt_expired": { + "message": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.jwt_signature_invalid": { + "message": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator." + }, + "error.session_expired": { + "message": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.invalid_setup_key": { + "message": "Der Setup-Schlüssel fehlt oder ist ungültig." + }, + "error.permission_denied": { + "message": "Die Anmeldung wurde vom Server abgelehnt." + }, + "error.daemon_unreachable": { + "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." + }, + "error.unknown": { + "message": "Vorgang fehlgeschlagen." + } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 0f146bc7b..e85016873 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1,456 +1,1682 @@ { - "tray.tooltip": "NetBird", - "tray.status.disconnected": "Disconnected", - "tray.status.daemonUnavailable": "Not running", - "tray.status.error": "Error", - "tray.status.connected": "Connected", - "tray.status.connecting": "Connecting", - "tray.status.needsLogin": "Login required", - "tray.status.loginFailed": "Login failed", - "tray.status.sessionExpired": "Session expired", - "tray.session.expiresIn": "Session expires in {remaining}", - "tray.session.unit.lessThanMinute": "less than a minute", - "tray.session.unit.minute": "1 minute", - "tray.session.unit.minutes": "{count} minutes", - "tray.session.unit.hour": "1 hour", - "tray.session.unit.hours": "{count} hours", - "tray.session.unit.day": "1 day", - "tray.session.unit.days": "{count} days", - - "tray.menu.open": "Open NetBird", - "tray.menu.connect": "Connect", - "tray.menu.disconnect": "Disconnect", - "tray.menu.exitNode": "Exit Node", - "tray.menu.networks": "Resources", - "tray.menu.profiles": "Profiles", - "tray.menu.manageProfiles": "Manage Profiles", - "tray.menu.settings": "Settings...", - "tray.menu.debugBundle": "Create Debug Bundle", - "tray.menu.about": "Help & Support", - "tray.menu.github": "GitHub", - "tray.menu.documentation": "Documentation", - "tray.menu.troubleshoot": "Troubleshoot", - "tray.menu.downloadLatest": "Download latest version", - "tray.menu.installVersion": "Install version {version}", - "tray.menu.guiVersion": "GUI: {version}", - "tray.menu.daemonVersion": "Daemon: {version}", - "tray.menu.versionUnknown": "—", - "tray.menu.quit": "Quit NetBird", - - "notify.update.title": "NetBird update available", - "notify.update.body": "NetBird {version} is available.", - "notify.update.enforcedSuffix": " Your administrator requires this update.", - "notify.error.title": "Error", - "notify.error.connect": "Failed to connect", - "notify.error.disconnect": "Failed to disconnect", - "notify.error.switchProfile": "Failed to switch to {profile}", - "notify.error.exitNode": "Failed to update exit node {name}", - "notify.sessionExpired.title": "NetBird session expired", - "notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.", - "notify.sessionWarning.title": "Session expires soon", - "notify.sessionWarning.body": "Your NetBird session expires in {remaining}. Click Extend now to renew.", - "notify.sessionWarning.bodyGeneric": "Your NetBird session is about to expire. Click Extend now to renew.", - "notify.sessionWarning.extend": "Extend now", - "notify.sessionWarning.dismiss": "Dismiss", - "notify.sessionWarning.failed": "Failed to extend NetBird session", - "notify.sessionWarning.successTitle": "NetBird session extended", - "notify.sessionWarning.successBody": "Your session has been refreshed.", - "notify.sessionDeadlineRejected.title": "Session deadline rejected", - "notify.sessionDeadlineRejected.body": "The server sent an invalid session deadline. Please sign in again.", - - "common.cancel": "Cancel", - "common.save": "Save", - "common.saveChanges": "Save Changes", - "common.saving": "Saving…", - "common.close": "Close", - "common.copy": "Copy", - "common.togglePasswordVisibility": "Toggle password visibility", - "common.increase": "Increase", - "common.decrease": "Decrease", - "common.delete": "Delete", - "common.create": "Create", - "common.add": "Add", - "common.remove": "Remove", - "common.refresh": "Refresh", - "common.loading": "Loading…", - "common.netbird": "NetBird", - "common.noResults.title": "Could not find any results", - "common.noResults.description": "We couldn't find any results. Please try a different search term or change your filters.", - "notConnected.title": "Disconnected", - "notConnected.description": "Connect to NetBird first to view detailed information about your peers, network resources, and exit nodes.", - - "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": "{connected} of {total} connected", - "nav.resources.title": "Resources", - "nav.resources.description": "{active} of {total} active", - "nav.exitNode.title": "Exit Nodes", - "nav.exitNode.none": "Not active", - "nav.exitNode.using": "Via {name}", - - "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.selector.switchTo": "Switch to this profile", - - "profile.dialog.title": "Enter Profile Name", - "profile.dialog.nameLabel": "Profile Name", - "profile.dialog.description": "Set an easily identifiable name for your profile.", - "profile.dialog.placeholder": "e.g. work", - "profile.dialog.submit": "Add Profile", - "profile.dialog.required": "Please enter a profile name, e.g. work, home", - "profile.dialog.managementHelp": "Use NetBird Cloud or your own server.", - "profile.dialog.urlUnreachable": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.", - - "header.menu.settings": "Settings...", - "header.menu.defaultView": "Default View", - "header.menu.advancedView": "Advanced View", - "header.menu.updateAvailable": "Update Available", - - "profile.switch.title": "Switch Profile to \"{name}\"?", - "profile.switch.message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.", - "profile.switch.confirm": "Confirm", - "profile.deregister.title": "Deregister Profile \"{name}\"?", - "profile.deregister.message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.", - "profile.deregister.confirm": "Deregister", - "profile.delete.title": "Delete Profile \"{name}\"?", - "profile.delete.message": "Are you sure you want to delete this profile?\nThis action cannot be undone.", - "profile.delete.disabledActive": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.", - "profile.delete.disabledDefault": "The default profile cannot be deleted.", - "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", - - "profile.dropdown.activeProfile": "Active profile", - "profile.dropdown.switchProfile": "Switch Profile", - "profile.dropdown.noEmail": "Other", - "profile.dropdown.addProfile": "Add Profile", - "profile.dropdown.manageProfiles": "Manage Profiles", - "profile.dropdown.settings": "Settings", - - "settings.profiles.section.profiles": "Profiles", - "settings.profiles.intro": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", - "settings.profiles.addProfile": "Add Profile", - "settings.profiles.active": "Active", - "settings.profiles.emptyTitle": "No Profiles", - "settings.profiles.emptyDescription": "Create a profile to connect to a NetBird management server.", - - "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.profiles": "Profiles", - "settings.tabs.ssh": "SSH", - "settings.tabs.advanced": "Advanced", - "settings.tabs.troubleshooting": "Troubleshoot", - "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.autostart.label": "Launch NetBird UI at Login", - "settings.general.autostart.help": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", - "settings.general.autostart.errorTitle": "Autostart Change Failed", - "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.urlUnreachable": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.", - "settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?", - "settings.general.management.switchCloudMessage": "This disconnects your self-hosted server.\nYou 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.network.ipv6.label": "Enable IPv6", - "settings.network.ipv6.help": "Use IPv6 addressing for the NetBird overlay network.", - - "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.interfaceName.error": "Use 1-15 letters, digits, dots, hyphens, or underscores.", - "settings.advanced.interfaceName.errorMac": "Must start with \"utun\" followed by a number (e.g. utun100).", - "settings.advanced.port.label": "Port", - "settings.advanced.port.error": "Enter a port between {min} and {max}.", - "settings.advanced.port.help": "If set to 0, a random free port will be used.", - "settings.advanced.mtu.label": "MTU", - "settings.advanced.mtu.error": "Enter an MTU value between {min} and {max}.", - "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.
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": "Canceling…", - "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": "Canceling…", - - "settings.about.client": "NetBird Client v{version}", - "settings.about.clientName": "NetBird Client", - "settings.about.development": "[Development]", - "settings.about.gui": "GUI v{version}", - "settings.about.guiName": "GUI", - "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", - "settings.about.community.github": "GitHub", - "settings.about.community.slack": "Slack", - "settings.about.community.forum": "Forum", - "settings.about.community.documentation": "Documentation", - "settings.about.community.feedback": "Feedback", - - "update.banner.message": "NetBird {version} is ready to install.", - "update.banner.later": "Later", - "update.banner.installNow": "Install now", - "update.card.versionAvailableDownload": "Version {version} is available for download.", - "update.card.versionAvailableInstall": "Version {version} is available for install.", - "update.card.whatsNew": "What's new?", - "update.card.installNow": "Install Now", - "update.card.getInstaller": "Download", - "update.card.autoCheckInterval": "NetBird checks for updates in the background.", - "update.card.changelog": "Changelog", - "update.card.onLatestVersion": "You're on the latest version", - "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.error.loadStateTitle": "Load Update State Failed", - "update.error.triggerTitle": "Start Update Failed", - - "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", - - "window.title.settings": "Settings", - "window.title.signIn": "Sign-in", - "window.title.sessionExpiration": "Session Expiring", - "window.title.updating": "Updating", - "window.title.welcome": "Welcome to NetBird", - "window.title.error": "Error", - - "welcome.title": "Look for NetBird in your tray", - "welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", - "welcome.continue": "Continue", - "welcome.back": "Back", - "welcome.management.title": "Set up NetBird", - "welcome.management.description": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", - "welcome.management.cloud.title": "NetBird Cloud", - "welcome.management.cloud.description": "Use our hosted service. No setup required.", - "welcome.management.selfHosted.title": "Self-hosted", - "welcome.management.selfHosted.description": "Connect to your own management server.", - "welcome.management.urlLabel": "Management server URL", - "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", - "welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", - "welcome.management.urlUnreachable": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.", - "welcome.management.checking": "Checking…", - - "browserLogin.title": "Continue in your browser to complete the login", - "browserLogin.notSeeing": "Not seeing the browser tab?", - "browserLogin.tryAgain": "Try again", - "browserLogin.openFailedTitle": "Open Browser Failed", - - "sessionExpiration.title": "Session expiring soon", - "sessionExpiration.titleLater": "Your session will expire", - "sessionExpiration.description": "This device will disconnect soon. Renew with a browser sign-in.", - "sessionExpiration.descriptionLater": "A browser sign-in keeps this device connected to your network.", - "sessionExpiration.stay": "Renew session", - "sessionExpiration.authenticate": "Authenticate", - "sessionExpiration.logout": "Logout", - "sessionExpiration.expired": "Session expired", - "sessionExpiration.expiredDescription": "Device disconnected. Authenticate with a browser sign-in to reconnect.", - "sessionExpiration.close": "Close", - "sessionExpiration.extendFailedTitle": "Extend Session Failed", - "sessionExpiration.logoutFailedTitle": "Logout Failed", - - "peers.search.placeholder": "Search by name or IP", - "peers.filter.all": "All", - "peers.filter.online": "Online", - "peers.filter.offline": "Offline", - "peers.empty.title": "No peers available", - "peers.empty.description": "You either don't have any peers available or have no access to any of them.", - "peers.details.domain": "Domain", - "peers.details.netbirdIp": "NetBird IP", - "peers.details.netbirdIpv6": "NetBird IPv6", - "peers.details.publicKey": "Public key", - "peers.details.connection": "Connection", - "peers.details.latency": "Latency", - "peers.details.lastHandshake": "Last handshake", - "peers.details.statusSince": "Last connection update", - "peers.details.bytes": "Bytes", - "peers.details.bytesSent": "Sent", - "peers.details.bytesReceived": "Received", - "peers.details.localIce": "Local ICE", - "peers.details.remoteIce": "Remote ICE", - "peers.details.never": "Never", - "peers.details.justNow": "Just now", - "peers.details.refresh": "Refresh", - "peers.status.connected": "Connected", - "peers.status.connecting": "Connecting", - "peers.status.disconnected": "Disconnected", - "peers.details.relayAddress": "Relay", - "peers.details.networks": "Resources", - "peers.details.relayed": "Relayed", - "peers.details.p2p": "P2P", - "peers.details.rosenpass": "Rosenpass enabled", - - "networks.search.placeholder": "Search by network or domain", - "networks.filter.all": "All", - "networks.filter.active": "Active", - "networks.filter.overlapping": "Overlapping", - "networks.empty.title": "No resources available", - "networks.empty.description": "You either don't have any network resources available or have no access to any of them.", - "networks.selected": "Selected", - "networks.unselected": "Not selected", - "networks.ips.heading": "Resolved IPs", - "networks.bulk.selectionCount": "{selected} of {total} Active", - "networks.bulk.enableAll": "Enable all", - "networks.bulk.disableAll": "Disable all", - - "exitNodes.search.placeholder": "Search exit nodes", - "exitNodes.none": "None", - "exitNodes.empty.title": "No exit nodes available", - "exitNodes.empty.description": "No exit nodes have been shared with this peer.", - "exitNodes.card.title": "Exit Node", - "exitNodes.card.statusActive": "Active", - "exitNodes.card.statusInactive": "Inactive", - "exitNodes.dropdown.noneTitle": "None", - "exitNodes.dropdown.noneDescription": "Direct connection without an exit node", - - "quickActions.connect": "Connect", - "quickActions.disconnect": "Disconnect", - - "daemon.unavailable.title": "NetBird Service Is Not Running", - "daemon.unavailable.description": "The app will reconnect automatically once the service is running.", - "daemon.unavailable.docsLink": "Documentation", - - "error.jwt_clock_skew": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", - "error.jwt_expired": "Your sign-in token has expired. Please sign in again.", - "error.jwt_signature_invalid": "Sign-in failed: the token signature is invalid. Please contact your administrator.", - "error.session_expired": "Your session has expired. Please sign in again.", - "error.invalid_setup_key": "The setup key is missing or invalid.", - "error.permission_denied": "Sign-in was rejected by the server.", - "error.daemon_unreachable": "The NetBird daemon is not responding. Please check that the service is running.", - "error.unknown": "Operation failed." + "tray.tooltip": { + "message": "NetBird", + "description": "Hover tooltip on the system-tray / menu-bar icon. Brand name — do not translate." + }, + "tray.status.disconnected": { + "message": "Disconnected", + "description": "Connection status surfaced through the tray icon: the client is not connected to the network." + }, + "tray.status.daemonUnavailable": { + "message": "Not running", + "description": "Tray status: the background NetBird service (daemon) is not running." + }, + "tray.status.error": { + "message": "Error", + "description": "Tray status: the client is in an error state." + }, + "tray.status.connected": { + "message": "Connected", + "description": "Tray status: connected to the NetBird network." + }, + "tray.status.connecting": { + "message": "Connecting", + "description": "Tray status: a connection is being established." + }, + "tray.status.needsLogin": { + "message": "Login required", + "description": "Tray status: the user must sign in before connecting." + }, + "tray.status.loginFailed": { + "message": "Login failed", + "description": "Tray status: the last sign-in attempt failed." + }, + "tray.status.sessionExpired": { + "message": "Session expired", + "description": "Tray status: the authenticated session expired; the user must sign in again." + }, + "tray.session.expiresIn": { + "message": "Session expires in {remaining}", + "description": "Tray row showing time left before the session expires. {remaining} is a human-readable duration such as '5 minutes', built from the tray.session.unit.* strings. Keep {remaining} unchanged." + }, + "tray.session.unit.lessThanMinute": { + "message": "less than a minute", + "description": "Duration fragment substituted into {remaining} (see tray.session.expiresIn). Used when under one minute remains." + }, + "tray.session.unit.minute": { + "message": "1 minute", + "description": "Duration fragment for exactly one minute, substituted into {remaining}." + }, + "tray.session.unit.minutes": { + "message": "{count} minutes", + "description": "Duration fragment for several minutes, substituted into {remaining}. {count} is the number of minutes; keep {count}." + }, + "tray.session.unit.hour": { + "message": "1 hour", + "description": "Duration fragment for exactly one hour, substituted into {remaining}." + }, + "tray.session.unit.hours": { + "message": "{count} hours", + "description": "Duration fragment for several hours. {count} is the number of hours; keep {count}." + }, + "tray.session.unit.day": { + "message": "1 day", + "description": "Duration fragment for exactly one day, substituted into {remaining}." + }, + "tray.session.unit.days": { + "message": "{count} days", + "description": "Duration fragment for several days. {count} is the number of days; keep {count}." + }, + "tray.menu.open": { + "message": "Open NetBird", + "description": "Tray menu item that opens the main NetBird window. Keep short." + }, + "tray.menu.connect": { + "message": "Connect", + "description": "Tray menu item that connects to the network. Keep short." + }, + "tray.menu.disconnect": { + "message": "Disconnect", + "description": "Tray menu item that disconnects from the network. Keep short." + }, + "tray.menu.exitNode": { + "message": "Exit Node", + "description": "Tray submenu title for choosing an exit node (route all traffic through another peer)." + }, + "tray.menu.networks": { + "message": "Resources", + "description": "Tray submenu title listing network resources the user can reach. Labelled 'Resources' in the UI even though the key says networks." + }, + "tray.menu.profiles": { + "message": "Profiles", + "description": "Tray submenu title listing connection profiles." + }, + "tray.menu.manageProfiles": { + "message": "Manage Profiles", + "description": "Tray menu item that opens Settings → Profiles." + }, + "tray.menu.settings": { + "message": "Settings...", + "description": "Tray menu item that opens the Settings window. The trailing '...' signals that a window opens; keep it." + }, + "tray.menu.debugBundle": { + "message": "Create Debug Bundle", + "description": "Tray menu item that generates a diagnostic log bundle for support." + }, + "tray.menu.about": { + "message": "Help & Support", + "description": "Tray submenu title for help and support links." + }, + "tray.menu.github": { + "message": "GitHub", + "description": "Tray menu link to the GitHub repository. Brand name — do not translate." + }, + "tray.menu.documentation": { + "message": "Documentation", + "description": "Tray menu link to the online documentation." + }, + "tray.menu.troubleshoot": { + "message": "Troubleshoot", + "description": "Tray menu link to troubleshooting help." + }, + "tray.menu.downloadLatest": { + "message": "Download latest version", + "description": "Tray menu item to download the latest available version." + }, + "tray.menu.installVersion": { + "message": "Install version {version}", + "description": "Tray menu item to install a specific update. {version} is a version number like 0.30.1; keep {version}." + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}", + "description": "Tray menu row showing the installed UI version. 'GUI' = the graphical app. {version} is a version number; keep it." + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}", + "description": "Tray menu row showing the background-service version. 'Daemon' = the background NetBird service. {version} is a version number; keep it." + }, + "tray.menu.versionUnknown": { + "message": "—", + "description": "Placeholder shown in version rows when the version can't be determined. It is an em dash — leave as-is." + }, + "tray.menu.quit": { + "message": "Quit NetBird", + "description": "Tray menu item that fully exits the app and removes the tray icon. Keep short." + }, + "notify.update.title": { + "message": "NetBird update available", + "description": "Title of the OS desktop notification shown when an app update is available." + }, + "notify.update.body": { + "message": "NetBird {version} is available.", + "description": "Body of the update-available desktop notification. {version} is the new version number; keep it." + }, + "notify.update.enforcedSuffix": { + "message": " Your administrator requires this update.", + "description": "Sentence appended to the update notification body when the admin has made the update mandatory. Note the leading space — keep it." + }, + "notify.error.title": { + "message": "Error", + "description": "Title of a generic error desktop notification." + }, + "notify.error.connect": { + "message": "Failed to connect", + "description": "Error notification body shown when connecting failed." + }, + "notify.error.disconnect": { + "message": "Failed to disconnect", + "description": "Error notification body shown when disconnecting failed." + }, + "notify.error.switchProfile": { + "message": "Failed to switch to {profile}", + "description": "Error notification shown when switching profiles failed. {profile} is the target profile name; keep it." + }, + "notify.error.exitNode": { + "message": "Failed to update exit node {name}", + "description": "Error notification shown when updating the exit node failed. {name} is the exit node name; keep it." + }, + "notify.sessionExpired.title": { + "message": "NetBird session expired", + "description": "Title of the desktop notification shown when the session has expired." + }, + "notify.sessionExpired.body": { + "message": "Your NetBird session has expired. Please log in again.", + "description": "Body of the session-expired desktop notification." + }, + "notify.sessionWarning.title": { + "message": "Session expires soon", + "description": "Title of the desktop notification warning that the session will expire soon." + }, + "notify.sessionWarning.body": { + "message": "Your NetBird session expires in {remaining}. Click Extend now to renew.", + "description": "Body of the session-expiry warning notification. {remaining} is a human-readable duration (see tray.session.unit.*); keep it. 'Extend now' refers to the action button notify.sessionWarning.extend." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Your NetBird session is about to expire. Click Extend now to renew.", + "description": "Generic session-expiry warning body used when the exact remaining time isn't known." + }, + "notify.sessionWarning.extend": { + "message": "Extend now", + "description": "Action button on the session-expiry notification that renews the session. Keep short." + }, + "notify.sessionWarning.dismiss": { + "message": "Dismiss", + "description": "Action button on the session-expiry notification that dismisses it. Keep short." + }, + "notify.sessionWarning.failed": { + "message": "Failed to extend NetBird session", + "description": "Notification shown when renewing the session failed." + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird session extended", + "description": "Title of the notification confirming the session was renewed." + }, + "notify.sessionWarning.successBody": { + "message": "Your session has been refreshed.", + "description": "Body of the notification confirming the session was renewed." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Session deadline rejected", + "description": "Title of the notification shown when the server sent an invalid session deadline." + }, + "notify.sessionDeadlineRejected.body": { + "message": "The server sent an invalid session deadline. Please sign in again.", + "description": "Body explaining the server sent an invalid session deadline and the user must sign in again." + }, + "common.cancel": { + "message": "Cancel", + "description": "Generic Cancel button label, reused across dialogs. Keep short." + }, + "common.save": { + "message": "Save", + "description": "Generic Save button label. Keep short." + }, + "common.saveChanges": { + "message": "Save Changes", + "description": "Button label to save edited settings. Keep short." + }, + "common.saving": { + "message": "Saving…", + "description": "Button label / status shown while a save is in progress. Ends with an ellipsis." + }, + "common.close": { + "message": "Close", + "description": "Generic Close button label. Keep short." + }, + "common.copy": { + "message": "Copy", + "description": "Generic Copy button label (copy to clipboard). Keep short." + }, + "common.togglePasswordVisibility": { + "message": "Toggle password visibility", + "description": "Accessibility label for the show/hide button inside a password field." + }, + "common.increase": { + "message": "Increase", + "description": "Accessibility label for the increment (+) button on a numeric stepper input." + }, + "common.decrease": { + "message": "Decrease", + "description": "Accessibility label for the decrement (−) button on a numeric stepper input." + }, + "common.delete": { + "message": "Delete", + "description": "Generic Delete button label. Keep short." + }, + "common.create": { + "message": "Create", + "description": "Generic Create button label. Keep short." + }, + "common.add": { + "message": "Add", + "description": "Generic Add button label. Keep short." + }, + "common.remove": { + "message": "Remove", + "description": "Generic Remove button label. Keep short." + }, + "common.refresh": { + "message": "Refresh", + "description": "Generic Refresh button label. Keep short." + }, + "common.loading": { + "message": "Loading…", + "description": "Generic loading indicator text. Ends with an ellipsis." + }, + "common.netbird": { + "message": "NetBird", + "description": "The product name. Brand — do not translate." + }, + "common.noResults.title": { + "message": "Could not find any results", + "description": "Title of the empty state shown when a search or filter returns nothing." + }, + "common.noResults.description": { + "message": "We couldn't find any results. Please try a different search term or change your filters.", + "description": "Body of the no-results empty state, suggesting a different search term or filters." + }, + "notConnected.title": { + "message": "Disconnected", + "description": "Title of the placeholder shown on data screens while disconnected." + }, + "notConnected.description": { + "message": "Connect to NetBird first to view detailed information about your peers, network resources, and exit nodes.", + "description": "Body explaining the user must connect to NetBird first to see peer, resource, and exit-node details." + }, + "connect.status.disconnected": { + "message": "Disconnected", + "description": "Label on the main-window connection toggle: not connected." + }, + "connect.status.connecting": { + "message": "Connecting...", + "description": "Connection toggle label while connecting. Ends with an ellipsis." + }, + "connect.status.connected": { + "message": "Connected", + "description": "Connection toggle label when connected." + }, + "connect.status.disconnecting": { + "message": "Disconnecting...", + "description": "Connection toggle label while disconnecting. Ends with an ellipsis." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon unavailable", + "description": "Connection toggle label when the background service is unavailable. 'Daemon' = background service." + }, + "connect.status.loginRequired": { + "message": "Login required", + "description": "Connection toggle label when sign-in is required before connecting." + }, + "connect.error.loginTitle": { + "message": "Login Failed", + "description": "Error-dialog title shown when sign-in fails. The action-named '… Failed' style is intentional — keep it." + }, + "connect.error.connectTitle": { + "message": "Connect Failed", + "description": "Error-dialog title shown when connecting fails." + }, + "connect.error.disconnectTitle": { + "message": "Disconnect Failed", + "description": "Error-dialog title shown when disconnecting fails." + }, + "nav.peers.title": { + "message": "Peers", + "description": "Navigation label for the Peers section (other devices in the network)." + }, + "nav.peers.description": { + "message": "{connected} of {total} connected", + "description": "Sub-label under Peers showing how many are connected. {connected} and {total} are numbers; keep both." + }, + "nav.resources.title": { + "message": "Resources", + "description": "Navigation label for the Resources section (routed networks)." + }, + "nav.resources.description": { + "message": "{active} of {total} active", + "description": "Sub-label under Resources showing how many are active. {active} and {total} are numbers; keep both." + }, + "nav.exitNode.title": { + "message": "Exit Nodes", + "description": "Navigation label for the Exit Nodes section." + }, + "nav.exitNode.none": { + "message": "Not active", + "description": "Sub-label under Exit Nodes when no exit node is in use." + }, + "nav.exitNode.using": { + "message": "Via {name}", + "description": "Sub-label under Exit Nodes when one is active. {name} is the exit node's name; keep it." + }, + "header.openSettings": { + "message": "Open settings", + "description": "Accessibility label / tooltip for the gear icon that opens Settings." + }, + "header.togglePanel": { + "message": "Toggle side panel", + "description": "Accessibility label / tooltip for the button that shows or hides the side panel." + }, + "profile.selector.loading": { + "message": "Loading...", + "description": "Shown in the profile picker while profiles load. Ends with an ellipsis." + }, + "profile.selector.noProfile": { + "message": "No profile", + "description": "Shown in the profile picker when no profile is selected." + }, + "profile.selector.searchPlaceholder": { + "message": "Search profile by name...", + "description": "Placeholder text in the profile picker's search field." + }, + "profile.selector.emptyTitle": { + "message": "No Profiles Found", + "description": "Title shown in the profile picker when a search matches no profiles." + }, + "profile.selector.emptyDescription": { + "message": "Try a different search term or create a new profile.", + "description": "Body shown when no profiles match the search, suggesting a different term or creating one." + }, + "profile.selector.newProfile": { + "message": "New Profile", + "description": "Button in the profile picker to create a new profile. Keep short." + }, + "profile.selector.moreOptions": { + "message": "More options", + "description": "Accessibility label for the per-profile kebab (⋯) menu." + }, + "profile.selector.deregister": { + "message": "Deregister", + "description": "Per-profile menu action: deregister (sign out of the profile but keep it)." + }, + "profile.selector.delete": { + "message": "Delete", + "description": "Per-profile menu action: delete the profile." + }, + "profile.selector.switchTo": { + "message": "Switch to this profile", + "description": "Tooltip / label for the action that switches to a profile." + }, + "profile.dialog.title": { + "message": "Enter Profile Name", + "description": "Title of the dialog for naming a new profile." + }, + "profile.dialog.nameLabel": { + "message": "Profile Name", + "description": "Field label for the profile name." + }, + "profile.dialog.description": { + "message": "Set an easily identifiable name for your profile.", + "description": "Helper text under the profile-name field." + }, + "profile.dialog.placeholder": { + "message": "e.g. work", + "description": "Example placeholder shown in the profile-name field. 'work' is a sample value; translate it to a natural example." + }, + "profile.dialog.submit": { + "message": "Add Profile", + "description": "Submit button on the add-profile dialog. Keep short." + }, + "profile.dialog.required": { + "message": "Please enter a profile name, e.g. work, home", + "description": "Validation message shown when the profile name is empty. The examples (work, home) may be localized." + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud or your own server.", + "description": "Helper text noting the profile can use NetBird Cloud or a self-hosted server." + }, + "profile.dialog.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.", + "description": "Soft warning when the entered server URL couldn't be reached; the user may add the profile anyway." + }, + "header.menu.settings": { + "message": "Settings...", + "description": "'More' menu item in the header that opens Settings. The trailing '...' signals a window opens." + }, + "header.menu.defaultView": { + "message": "Default View", + "description": "'More' menu item that switches the main window to the compact default view." + }, + "header.menu.advancedView": { + "message": "Advanced View", + "description": "'More' menu item that switches the main window to the wider advanced view." + }, + "header.menu.updateAvailable": { + "message": "Update Available", + "description": "'More' menu item / badge shown when an update is available." + }, + "profile.switch.title": { + "message": "Switch Profile to \"{name}\"?", + "description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes." + }, + "profile.switch.message": { + "message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.", + "description": "Confirmation body for switching profiles. Contains a line break (\\n) — keep it." + }, + "profile.switch.confirm": { + "message": "Confirm", + "description": "Confirm button on the switch-profile dialog. Keep short." + }, + "profile.deregister.title": { + "message": "Deregister Profile \"{name}\"?", + "description": "Confirmation-dialog title for deregistering a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.deregister.message": { + "message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.", + "description": "Confirmation body for deregistering; warns the user must sign in again. Contains a line break (\\n) — keep it." + }, + "profile.deregister.confirm": { + "message": "Deregister", + "description": "Confirm button on the deregister dialog. Keep short." + }, + "profile.delete.title": { + "message": "Delete Profile \"{name}\"?", + "description": "Confirmation-dialog title for deleting a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.delete.message": { + "message": "Are you sure you want to delete this profile?\nThis action cannot be undone.", + "description": "Confirmation body for deleting; warns the action can't be undone. Contains a line break (\\n) — keep it." + }, + "profile.delete.disabledActive": { + "message": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.", + "description": "Tooltip explaining the active profile can't be deleted until the user switches away." + }, + "profile.delete.disabledDefault": { + "message": "The default profile cannot be deleted.", + "description": "Tooltip explaining the default profile can't be deleted." + }, + "profile.error.switchTitle": { + "message": "Switch Profile Failed", + "description": "Error-dialog title when switching profiles fails." + }, + "profile.error.deregisterTitle": { + "message": "Deregister Profile Failed", + "description": "Error-dialog title when deregistering a profile fails." + }, + "profile.error.deleteTitle": { + "message": "Delete Profile Failed", + "description": "Error-dialog title when deleting a profile fails." + }, + "profile.error.createTitle": { + "message": "Create Profile Failed", + "description": "Error-dialog title when creating a profile fails." + }, + "profile.error.loadTitle": { + "message": "Load Profiles Failed", + "description": "Error-dialog title when loading profiles fails." + }, + "profile.dropdown.activeProfile": { + "message": "Active profile", + "description": "Section heading in the header profile dropdown for the current profile." + }, + "profile.dropdown.switchProfile": { + "message": "Switch Profile", + "description": "Section heading / action in the profile dropdown to switch profiles." + }, + "profile.dropdown.noEmail": { + "message": "Other", + "description": "Label shown for a profile that has no associated email address." + }, + "profile.dropdown.addProfile": { + "message": "Add Profile", + "description": "Profile dropdown action to add a profile." + }, + "profile.dropdown.manageProfiles": { + "message": "Manage Profiles", + "description": "Profile dropdown action that opens Settings → Profiles." + }, + "profile.dropdown.settings": { + "message": "Settings", + "description": "Profile dropdown action that opens Settings." + }, + "settings.profiles.section.profiles": { + "message": "Profiles", + "description": "Section heading on the Profiles settings tab." + }, + "settings.profiles.intro": { + "message": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", + "description": "Intro paragraph on the Profiles settings tab explaining what profiles are for." + }, + "settings.profiles.addProfile": { + "message": "Add Profile", + "description": "Button on the Profiles settings tab to add a profile." + }, + "settings.profiles.active": { + "message": "Active", + "description": "Badge marking the currently active profile in the profiles table." + }, + "settings.profiles.emptyTitle": { + "message": "No Profiles", + "description": "Title of the empty state when there are no profiles." + }, + "settings.profiles.emptyDescription": { + "message": "Create a profile to connect to a NetBird management server.", + "description": "Body of the no-profiles empty state." + }, + "settings.error.loadTitle": { + "message": "Load Settings Failed", + "description": "Error-dialog title when loading settings fails." + }, + "settings.error.saveTitle": { + "message": "Save Settings Failed", + "description": "Error-dialog title when saving settings fails." + }, + "settings.error.debugBundleTitle": { + "message": "Debug Bundle Failed", + "description": "Error-dialog title when creating the debug bundle fails." + }, + "settings.tabs.general": { + "message": "General", + "description": "Settings tab label: General. Keep short." + }, + "settings.tabs.network": { + "message": "Network", + "description": "Settings tab label: Network. Keep short." + }, + "settings.tabs.security": { + "message": "Security", + "description": "Settings tab label: Security. Keep short." + }, + "settings.tabs.profiles": { + "message": "Profiles", + "description": "Settings tab label: Profiles. Keep short." + }, + "settings.tabs.ssh": { + "message": "SSH", + "description": "Settings tab label: SSH. Acronym — keep as-is." + }, + "settings.tabs.advanced": { + "message": "Advanced", + "description": "Settings tab label: Advanced. Keep short." + }, + "settings.tabs.troubleshooting": { + "message": "Troubleshoot", + "description": "Settings tab label: Troubleshoot. Keep short." + }, + "settings.tabs.about": { + "message": "About", + "description": "Settings tab label: About. Keep short." + }, + "settings.tabs.updateAvailable": { + "message": "Update Available", + "description": "Settings tab label / badge shown when an update is available." + }, + "settings.general.section.general": { + "message": "General", + "description": "Section heading on the General settings tab." + }, + "settings.general.section.connection": { + "message": "Connection", + "description": "Section heading for connection-related options on the General tab." + }, + "settings.general.connectOnStartup.label": { + "message": "Connect on Startup", + "description": "Toggle label: connect automatically when the service starts." + }, + "settings.general.connectOnStartup.help": { + "message": "Automatically establish a connection when the service starts.", + "description": "Helper text for the connect-on-startup toggle." + }, + "settings.general.notifications.label": { + "message": "Desktop Notifications", + "description": "Toggle label: enable desktop notifications." + }, + "settings.general.notifications.help": { + "message": "Show desktop notifications for new updates and connection events.", + "description": "Helper text for the desktop-notifications toggle." + }, + "settings.general.autostart.label": { + "message": "Launch NetBird UI at Login", + "description": "Toggle label: launch the NetBird UI at login." + }, + "settings.general.autostart.help": { + "message": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", + "description": "Helper text clarifying autostart affects only the UI, not the background service." + }, + "settings.general.autostart.errorTitle": { + "message": "Autostart Change Failed", + "description": "Error-dialog title when changing the autostart setting fails." + }, + "settings.general.language.label": { + "message": "Display Language", + "description": "Label for the display-language picker." + }, + "settings.general.language.help": { + "message": "Choose the language for the NetBird interface.", + "description": "Helper text for the language picker." + }, + "settings.general.language.search": { + "message": "Search language…", + "description": "Placeholder in the language picker's search field." + }, + "settings.general.language.empty": { + "message": "No languages match.", + "description": "Shown when no languages match the search." + }, + "settings.general.management.label": { + "message": "Management Server", + "description": "Label for the management-server selector." + }, + "settings.general.management.help": { + "message": "Connect to NetBird Cloud or your own self-hosted management server. Changes will reconnect the client.", + "description": "Helper text explaining Cloud vs self-hosted management server; warns that changes reconnect the client." + }, + "settings.general.management.cloud": { + "message": "Cloud", + "description": "Option label for using NetBird Cloud as the management server." + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted", + "description": "Option label for using a self-hosted management server. 'Self-hosted' is a common technical term." + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder for the self-hosted server field. It is a sample URL — do not translate the URL itself." + }, + "settings.general.management.urlError": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid management-server URL. The example URL stays as-is." + }, + "settings.general.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.", + "description": "Soft warning when the management URL couldn't be reached; the user may save anyway." + }, + "settings.general.management.switchCloudTitle": { + "message": "Switch to NetBird Cloud?", + "description": "Confirmation-dialog title for switching to NetBird Cloud." + }, + "settings.general.management.switchCloudMessage": { + "message": "This disconnects your self-hosted server.\nYou may need to log in again.", + "description": "Confirmation body warning the self-hosted server will be disconnected. Contains a line break (\\n) — keep it." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Switch to Cloud", + "description": "Confirm button for switching to Cloud. Keep short." + }, + "settings.network.section.connectivity": { + "message": "Connectivity", + "description": "Section heading for connectivity options on the Network tab." + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS", + "description": "Section heading for routing and DNS options. 'DNS' is an acronym — keep it." + }, + "settings.network.lazy.label": { + "message": "Lazy Connections", + "description": "Toggle label: Lazy Connections (on-demand connections)." + }, + "settings.network.lazy.help": { + "message": "Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling.", + "description": "Helper text for lazy connections." + }, + "settings.network.monitor.label": { + "message": "Reconnect on Network Change", + "description": "Toggle label: reconnect automatically on network change." + }, + "settings.network.monitor.help": { + "message": "Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep.", + "description": "Helper text for the network-change reconnect toggle." + }, + "settings.network.dns.label": { + "message": "Enable DNS", + "description": "Toggle label: enable DNS. 'DNS' — keep acronym." + }, + "settings.network.dns.help": { + "message": "Apply NetBird-managed DNS settings to the host resolver.", + "description": "Helper text for the enable-DNS toggle." + }, + "settings.network.clientRoutes.label": { + "message": "Enable Client Routes", + "description": "Toggle label: enable client routes." + }, + "settings.network.clientRoutes.help": { + "message": "Accept routes from other peers to reach their networks.", + "description": "Helper text for client routes (accept routes from other peers)." + }, + "settings.network.serverRoutes.label": { + "message": "Enable Server Routes", + "description": "Toggle label: enable server routes." + }, + "settings.network.serverRoutes.help": { + "message": "Advertise this host's local routes to other peers.", + "description": "Helper text for server routes (advertise this host's local routes to other peers)." + }, + "settings.network.ipv6.label": { + "message": "Enable IPv6", + "description": "Toggle label: enable IPv6. 'IPv6' — keep as-is." + }, + "settings.network.ipv6.help": { + "message": "Use IPv6 addressing for the NetBird overlay network.", + "description": "Helper text for the IPv6 toggle." + }, + "settings.security.section.firewall": { + "message": "Firewall", + "description": "Section heading: Firewall." + }, + "settings.security.section.encryption": { + "message": "Encryption", + "description": "Section heading: Encryption." + }, + "settings.security.blockInbound.label": { + "message": "Block Inbound Traffic", + "description": "Toggle label: block inbound traffic." + }, + "settings.security.blockInbound.help": { + "message": "Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected.", + "description": "Helper text for blocking inbound traffic." + }, + "settings.security.blockLan.label": { + "message": "Block LAN Access", + "description": "Toggle label: block LAN access. 'LAN' — keep acronym." + }, + "settings.security.blockLan.help": { + "message": "Prevent peers from reaching your local network or its devices when this device routes their traffic.", + "description": "Helper text for blocking LAN access." + }, + "settings.security.rosenpass.label": { + "message": "Enable Quantum-Resistance", + "description": "Toggle label: enable quantum-resistance." + }, + "settings.security.rosenpass.help": { + "message": "Add a post-quantum key exchange via Rosenpass on top of WireGuard®.", + "description": "Helper text: adds a post-quantum key exchange via Rosenpass on top of WireGuard®. 'Rosenpass' and 'WireGuard®' are product names — do not translate; keep the ® symbol." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Enable Permissive Mode", + "description": "Toggle label: enable permissive mode (for quantum-resistance)." + }, + "settings.security.rosenpassPermissive.help": { + "message": "Allow connections to peers without quantum-resistance support.", + "description": "Helper text for permissive mode (allow peers without quantum-resistance support)." + }, + "settings.ssh.section.server": { + "message": "Server", + "description": "Section heading: Server (SSH settings)." + }, + "settings.ssh.section.capabilities": { + "message": "Capabilities", + "description": "Section heading: Capabilities (SSH features)." + }, + "settings.ssh.section.authentication": { + "message": "Authentication", + "description": "Section heading: Authentication (SSH)." + }, + "settings.ssh.server.label": { + "message": "Enable SSH Server", + "description": "Toggle label: enable the SSH server." + }, + "settings.ssh.server.help": { + "message": "Run the NetBird SSH server on this host so other peers can connect to it.", + "description": "Helper text for the SSH server toggle." + }, + "settings.ssh.root.label": { + "message": "Allow Root Login", + "description": "Toggle label: allow root login over SSH. 'root' is the Unix superuser account — keep as-is." + }, + "settings.ssh.root.help": { + "message": "Let peers sign in as the root user. Disable to require a non-privileged account.", + "description": "Helper text for allowing root login." + }, + "settings.ssh.sftp.label": { + "message": "Allow SFTP", + "description": "Toggle label: allow SFTP. 'SFTP' — keep acronym." + }, + "settings.ssh.sftp.help": { + "message": "Transfer files securely using native SFTP or SCP clients.", + "description": "Helper text about SFTP/SCP file transfer. Keep the acronyms." + }, + "settings.ssh.localForward.label": { + "message": "Local Port Forwarding", + "description": "Toggle label: local port forwarding." + }, + "settings.ssh.localForward.help": { + "message": "Let connecting peers tunnel local ports to services reachable from this host.", + "description": "Helper text for local port forwarding." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote Port Forwarding", + "description": "Toggle label: remote port forwarding." + }, + "settings.ssh.remoteForward.help": { + "message": "Let connecting peers expose ports on this host back to their own machine.", + "description": "Helper text for remote port forwarding." + }, + "settings.ssh.jwt.label": { + "message": "Enable JWT Authentication", + "description": "Toggle label: enable JWT authentication. 'JWT' — keep acronym." + }, + "settings.ssh.jwt.help": { + "message": "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.", + "description": "Helper text for JWT auth. 'IdP' (identity provider) and 'ACL' are acronyms — keep them." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT Cache TTL", + "description": "Label for the JWT cache time-to-live field. 'JWT' and 'TTL' — keep acronyms." + }, + "settings.ssh.jwtTtl.help": { + "message": "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.", + "description": "Helper text for the JWT cache TTL; mentions setting 0 to disable caching." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Second(s)", + "description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural." + }, + "settings.advanced.section.interface": { + "message": "Interface", + "description": "Section heading: Interface (network-interface settings)." + }, + "settings.advanced.section.security": { + "message": "Security", + "description": "Section heading: Security (advanced)." + }, + "settings.advanced.interfaceName.label": { + "message": "Name", + "description": "Field label for the WireGuard interface name." + }, + "settings.advanced.interfaceName.error": { + "message": "Use 1-15 letters, digits, dots, hyphens, or underscores.", + "description": "Validation message for the interface name (allowed characters)." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Must start with \"utun\" followed by a number (e.g. utun100).", + "description": "Validation message specific to macOS, where the name must start with 'utun' followed by a number. Keep 'utun' and the example." + }, + "settings.advanced.port.label": { + "message": "Port", + "description": "Field label: Port." + }, + "settings.advanced.port.error": { + "message": "Enter a port between {min} and {max}.", + "description": "Validation message for the port range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.port.help": { + "message": "If set to 0, a random free port will be used.", + "description": "Helper text: 0 means a random free port is used." + }, + "settings.advanced.mtu.label": { + "message": "MTU", + "description": "Field label: MTU. 'MTU' — keep acronym." + }, + "settings.advanced.mtu.error": { + "message": "Enter an MTU value between {min} and {max}.", + "description": "Validation message for the MTU range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key", + "description": "Field label: Pre-shared Key." + }, + "settings.advanced.psk.help": { + "message": "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.", + "description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them." + }, + "settings.troubleshooting.section.title": { + "message": "Debug bundle", + "description": "Section heading: Debug bundle." + }, + "settings.troubleshooting.intro": { + "message": "A debug bundle helps NetBird support investigate connection problems.
It's a .zip file with logs, system details and debug information from your device.", + "description": "Intro paragraph on the Troubleshoot tab. Contains an HTML '
' line break — keep it exactly. '.zip' is a file extension — keep it." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymize Sensitive Information", + "description": "Toggle label: anonymize sensitive information in the bundle." + }, + "settings.troubleshooting.anonymize.help": { + "message": "Hides public IP addresses and non-NetBird domains from logs.", + "description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Include System Information", + "description": "Toggle label: include system information in the bundle." + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, network interfaces, and routing tables.", + "description": "Helper text listing the system info included (OS, kernel, interfaces, routing tables)." + }, + "settings.troubleshooting.upload.label": { + "message": "Upload Bundle to NetBird Servers", + "description": "Toggle label: upload the bundle to NetBird servers." + }, + "settings.troubleshooting.upload.help": { + "message": "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.", + "description": "Helper text for uploading the bundle. 'GitHub' and 'Slack' are brands — keep them." + }, + "settings.troubleshooting.trace.label": { + "message": "Capture Trace Logs", + "description": "Toggle label: capture trace logs. 'TRACE' is a log level." + }, + "settings.troubleshooting.trace.help": { + "message": "Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built.", + "description": "Helper text explaining trace capture restarts NetBird briefly. 'TRACE' — keep as-is." + }, + "settings.troubleshooting.duration.label": { + "message": "Capture Duration", + "description": "Label for the trace-capture duration field." + }, + "settings.troubleshooting.duration.help": { + "message": "How long to capture trace logs before generating the bundle.", + "description": "Helper text for the capture duration." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(s)", + "description": "Unit suffix after the duration field. The '(s)' marks an optional plural." + }, + "settings.troubleshooting.create": { + "message": "Create Bundle", + "description": "Button to create the debug bundle. Keep short." + }, + "settings.troubleshooting.progress.description": { + "message": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.", + "description": "Status text shown while the bundle is being collected; asks the user to keep the window open." + }, + "settings.troubleshooting.cancelling": { + "message": "Canceling…", + "description": "Status shown while cancelling bundle creation. Ends with an ellipsis." + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug bundle successfully uploaded!", + "description": "Success title after the bundle was uploaded." + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Bundle saved", + "description": "Title shown when the bundle was saved locally (not uploaded)." + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Share the upload key below with NetBird support. A local copy was also saved on your device.", + "description": "Body after upload, telling the user to share the upload key with support." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Your debug bundle has been saved locally.", + "description": "Body shown when the bundle was only saved locally." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copy Key", + "description": "Button to copy the upload key. Keep short." + }, + "settings.troubleshooting.done.openFolder": { + "message": "Open Folder", + "description": "Button to open the folder containing the bundle. Keep short." + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Open file location", + "description": "Button to reveal the bundle file in the OS file manager." + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload failed: {reason} The bundle is still saved locally.", + "description": "Shown when upload failed but the bundle was saved locally. {reason} is the server error text; keep it." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload failed. The bundle is still saved locally.", + "description": "Shown when upload failed (no specific reason) but the bundle was saved locally." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Switching to trace logging…", + "description": "Progress stage: switching to trace logging. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnecting NetBird…", + "description": "Progress stage: reconnecting NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturing logs — {elapsed} / {total}", + "description": "Progress stage: capturing logs. {elapsed} and {total} are time values (e.g. 0:30 / 2:00); keep both." + }, + "settings.troubleshooting.stage.restoring": { + "message": "Restoring previous log level…", + "description": "Progress stage: restoring the previous log level. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generating debug bundle…", + "description": "Progress stage: generating the debug bundle. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.uploading": { + "message": "Uploading to NetBird…", + "description": "Progress stage: uploading to NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Canceling…", + "description": "Progress stage: cancelling. Ends with an ellipsis." + }, + "settings.about.client": { + "message": "NetBird Client v{version}", + "description": "About tab: client product name and version. {version} is a version number; keep it. 'NetBird Client' — keep brand." + }, + "settings.about.clientName": { + "message": "NetBird Client", + "description": "About tab: the client product name shown when no version is available. Brand — do not translate." + }, + "settings.about.development": { + "message": "[Development]", + "description": "Badge shown next to the version on development builds. Keep the square brackets." + }, + "settings.about.gui": { + "message": "GUI v{version}", + "description": "About tab: UI component name and version. {version} is a version number; keep it. 'GUI' = the graphical app." + }, + "settings.about.guiName": { + "message": "GUI", + "description": "About tab: the UI component name shown without a version. 'GUI' = the graphical app." + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved.", + "description": "Copyright line. {year} is the current year; keep it. 'NetBird' — brand." + }, + "settings.about.links.imprint": { + "message": "Imprint", + "description": "Footer link: Imprint (legal notice / Impressum)." + }, + "settings.about.links.privacy": { + "message": "Privacy", + "description": "Footer link: Privacy policy." + }, + "settings.about.links.cla": { + "message": "CLA", + "description": "Footer link: CLA (Contributor License Agreement). Acronym — keep as-is." + }, + "settings.about.links.terms": { + "message": "Terms of Service", + "description": "Footer link: Terms of Service." + }, + "settings.about.community.github": { + "message": "GitHub", + "description": "Community link: GitHub. Brand — do not translate." + }, + "settings.about.community.slack": { + "message": "Slack", + "description": "Community link: Slack. Brand — do not translate." + }, + "settings.about.community.forum": { + "message": "Forum", + "description": "Community link: Forum." + }, + "settings.about.community.documentation": { + "message": "Documentation", + "description": "Community link: Documentation." + }, + "settings.about.community.feedback": { + "message": "Feedback", + "description": "Community link: Feedback." + }, + "update.banner.message": { + "message": "NetBird {version} is ready to install.", + "description": "Update banner text when a new version is ready to install. {version} is the version number; keep it." + }, + "update.banner.later": { + "message": "Later", + "description": "Banner button to postpone the update. Keep short." + }, + "update.banner.installNow": { + "message": "Install now", + "description": "Banner button to install the update now. Keep short." + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} is available for download.", + "description": "Update card text when a version is available to download. {version} is the version number; keep it." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} is available for install.", + "description": "Update card text when a version is available to install. {version} is the version number; keep it." + }, + "update.card.whatsNew": { + "message": "What's new?", + "description": "Link / label that opens the release notes." + }, + "update.card.installNow": { + "message": "Install Now", + "description": "Update card button: install now. Keep short." + }, + "update.card.getInstaller": { + "message": "Download", + "description": "Update card button: download the installer." + }, + "update.card.autoCheckInterval": { + "message": "NetBird checks for updates in the background.", + "description": "Note that NetBird checks for updates in the background." + }, + "update.card.changelog": { + "message": "Changelog", + "description": "Link / label: Changelog." + }, + "update.card.onLatestVersion": { + "message": "You're on the latest version", + "description": "Shown when the client is already up to date." + }, + "update.header.tooltip": { + "message": "Update Available", + "description": "Tooltip on the header update badge." + }, + "update.overlay.updatingVersion": { + "message": "Updating NetBird to v{version}", + "description": "Heading in the install-progress window while updating to a specific version. {version} is the version number; keep it. The 'v' prefix is part of the version display." + }, + "update.overlay.updating": { + "message": "Updating NetBird", + "description": "Heading in the install-progress window while updating when the target version isn't known." + }, + "update.overlay.description": { + "message": "A newer version is available and is being installed. NetBird will restart automatically once the update is finished.", + "description": "Install-progress window body explaining NetBird will restart automatically after the update." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update Is Taking Too Long", + "description": "Install-progress window error title: the update took too long." + }, + "update.overlay.error.timeoutDescription": { + "message": "Installing {target} took too long and didn't finish.", + "description": "Install-progress window error body for a timeout. {target} is the target-version label (see update.overlay.error.targetVersion / targetFallback); keep it." + }, + "update.overlay.error.canceledTitle": { + "message": "Update Was Stopped", + "description": "Install-progress window error title: the update was stopped / canceled." + }, + "update.overlay.error.canceledDescription": { + "message": "The update to {target} was canceled before it finished.", + "description": "Install-progress window error body for a canceled update. {target} is the target-version label; keep it." + }, + "update.overlay.error.failTitle": { + "message": "Couldn't Install the Update", + "description": "Install-progress window error title: the update couldn't be installed." + }, + "update.overlay.error.failDescription": { + "message": "{target} couldn't be installed.", + "description": "Install-progress window error body for a failed install. {target} is the target-version label; keep it." + }, + "update.overlay.error.unknownMessage": { + "message": "unknown error", + "description": "Fallback text for an unspecified error, used inside other messages." + }, + "update.overlay.error.targetVersion": { + "message": "v{version}", + "description": "The target-version label substituted into {target}. {version} is a number; keep the 'v' prefix." + }, + "update.overlay.error.targetFallback": { + "message": "the new version", + "description": "Fallback target label used when the version isn't known, substituted into {target}." + }, + "update.error.loadStateTitle": { + "message": "Load Update State Failed", + "description": "Error-dialog title when loading update state fails." + }, + "update.error.triggerTitle": { + "message": "Start Update Failed", + "description": "Error-dialog title when starting the update fails." + }, + "update.page.versionLine": { + "message": "Updating client to: {version}.", + "description": "Install-progress window text naming the target version. {version} is the version number; keep it." + }, + "update.page.versionLineGeneric": { + "message": "Updating client.", + "description": "Install-progress text used when the target version isn't known." + }, + "update.page.outdated": { + "message": "Your client version is older than the auto-update version set in Management.", + "description": "Note that the client is older than the auto-update version set in Management. 'Management' = the NetBird management server / console." + }, + "update.page.status.running": { + "message": "Updating", + "description": "Install status: updating." + }, + "update.page.status.timeout": { + "message": "Update timed out. Please try again.", + "description": "Install status: timed out; asks the user to retry." + }, + "update.page.status.canceled": { + "message": "Update canceled.", + "description": "Install status: canceled." + }, + "update.page.status.failed": { + "message": "Update failed: {message}", + "description": "Install status: failed. {message} is the error detail; keep it." + }, + "update.page.status.unknownError": { + "message": "unknown update error", + "description": "Fallback text for an unknown update error, used inside update.page.status.failed." + }, + "update.page.failedTitle": { + "message": "Update Failed", + "description": "Heading shown when the install failed." + }, + "update.page.timeoutMessage": { + "message": "Update timed out.", + "description": "Message shown when the install timed out." + }, + "update.page.dontClose": { + "message": "Please don't close this window.", + "description": "Warning asking the user not to close the install window." + }, + "update.page.updating": { + "message": "Updating…", + "description": "Short status: updating. Ends with an ellipsis." + }, + "update.page.complete": { + "message": "Update complete", + "description": "Short status: update complete." + }, + "update.page.failed": { + "message": "Update failed", + "description": "Short status: update failed." + }, + "window.title.settings": { + "message": "Settings", + "description": "OS window-chrome title for the Settings window. The app prefixes it with 'NetBird - '." + }, + "window.title.signIn": { + "message": "Sign-in", + "description": "OS window-chrome title for the sign-in window." + }, + "window.title.sessionExpiration": { + "message": "Session Expiring", + "description": "OS window-chrome title for the session-expiration window." + }, + "window.title.updating": { + "message": "Updating", + "description": "OS window-chrome title for the update / install window." + }, + "window.title.welcome": { + "message": "Welcome to NetBird", + "description": "OS window-chrome title for the first-launch welcome window. 'NetBird' — brand." + }, + "window.title.error": { + "message": "Error", + "description": "OS window-chrome title for the error window." + }, + "welcome.title": { + "message": "Look for NetBird in your tray", + "description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar." + }, + "welcome.description": { + "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step explaining the tray icon." + }, + "welcome.continue": { + "message": "Continue", + "description": "Primary button to advance the onboarding. Keep short." + }, + "welcome.back": { + "message": "Back", + "description": "Button to go back a step in onboarding. Keep short." + }, + "welcome.management.title": { + "message": "Set up NetBird", + "description": "Heading on the onboarding step for choosing a management server." + }, + "welcome.management.description": { + "message": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", + "description": "Body of the management step; mentions choosing Self-hosted if the user runs their own server." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud", + "description": "Option title: NetBird Cloud. Brand — do not translate 'NetBird Cloud'." + }, + "welcome.management.cloud.description": { + "message": "Use our hosted service. No setup required.", + "description": "Option body for NetBird Cloud (hosted service, no setup required)." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted", + "description": "Option title: Self-hosted." + }, + "welcome.management.selfHosted.description": { + "message": "Connect to your own management server.", + "description": "Option body for connecting to your own management server." + }, + "welcome.management.urlLabel": { + "message": "Management server URL", + "description": "Field label for the management-server URL." + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder. Sample URL — do not translate it." + }, + "welcome.management.urlInvalid": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid URL. The example URL stays as-is." + }, + "welcome.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.", + "description": "Soft warning when the server couldn't be reached during onboarding; the user may continue anyway." + }, + "welcome.management.checking": { + "message": "Checking…", + "description": "Status shown while checking the server URL. Ends with an ellipsis." + }, + "browserLogin.title": { + "message": "Continue in your browser to complete the login", + "description": "Heading telling the user to finish signing in via their browser." + }, + "browserLogin.notSeeing": { + "message": "Not seeing the browser tab?", + "description": "Prompt asking whether the user doesn't see the opened browser tab." + }, + "browserLogin.tryAgain": { + "message": "Try again", + "description": "Button to re-open the browser sign-in page. Keep short." + }, + "browserLogin.openFailedTitle": { + "message": "Open Browser Failed", + "description": "Error-dialog title when the browser couldn't be opened." + }, + "sessionExpiration.title": { + "message": "Session expiring soon", + "description": "Heading warning the session will expire soon." + }, + "sessionExpiration.titleLater": { + "message": "Your session will expire", + "description": "Alternate, less-urgent heading: the session will expire." + }, + "sessionExpiration.description": { + "message": "This device will disconnect soon. Renew with a browser sign-in.", + "description": "Body warning the device will disconnect soon; renew via a browser sign-in." + }, + "sessionExpiration.descriptionLater": { + "message": "A browser sign-in keeps this device connected to your network.", + "description": "Body for the less-urgent variant; a browser sign-in keeps the device connected." + }, + "sessionExpiration.stay": { + "message": "Renew session", + "description": "Button to renew the session (keep connected). Keep short." + }, + "sessionExpiration.authenticate": { + "message": "Authenticate", + "description": "Button to authenticate / sign in. Keep short." + }, + "sessionExpiration.logout": { + "message": "Logout", + "description": "Button to log out. Keep short." + }, + "sessionExpiration.expired": { + "message": "Session expired", + "description": "Heading shown once the session has actually expired." + }, + "sessionExpiration.expiredDescription": { + "message": "Device disconnected. Authenticate with a browser sign-in to reconnect.", + "description": "Body shown after expiry; authenticate via a browser sign-in to reconnect." + }, + "sessionExpiration.close": { + "message": "Close", + "description": "Close button on the session window. Keep short." + }, + "sessionExpiration.extendFailedTitle": { + "message": "Extend Session Failed", + "description": "Error-dialog title when extending the session fails." + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Logout Failed", + "description": "Error-dialog title when logging out fails." + }, + "peers.search.placeholder": { + "message": "Search by name or IP", + "description": "Placeholder in the peers search field." + }, + "peers.filter.all": { + "message": "All", + "description": "Peers filter option: All. Keep short." + }, + "peers.filter.online": { + "message": "Online", + "description": "Peers filter option: Online. Keep short." + }, + "peers.filter.offline": { + "message": "Offline", + "description": "Peers filter option: Offline. Keep short." + }, + "peers.empty.title": { + "message": "No peers available", + "description": "Title of the empty state when no peers are available." + }, + "peers.empty.description": { + "message": "You either don't have any peers available or have no access to any of them.", + "description": "Body of the no-peers empty state." + }, + "peers.details.domain": { + "message": "Domain", + "description": "Peer detail label: Domain." + }, + "peers.details.netbirdIp": { + "message": "NetBird IP", + "description": "Peer detail label: NetBird IP address. 'NetBird' — brand; 'IP' — keep acronym." + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6", + "description": "Peer detail label: NetBird IPv6 address. Keep 'NetBird' and 'IPv6'." + }, + "peers.details.publicKey": { + "message": "Public key", + "description": "Peer detail label: WireGuard public key." + }, + "peers.details.connection": { + "message": "Connection", + "description": "Peer detail label: Connection." + }, + "peers.details.latency": { + "message": "Latency", + "description": "Peer detail label: Latency." + }, + "peers.details.lastHandshake": { + "message": "Last handshake", + "description": "Peer detail label: time of the last WireGuard handshake." + }, + "peers.details.statusSince": { + "message": "Last connection update", + "description": "Peer detail label: time since the last connection-status update." + }, + "peers.details.bytes": { + "message": "Bytes", + "description": "Peer detail label: Bytes (data transferred)." + }, + "peers.details.bytesSent": { + "message": "Sent", + "description": "Peer detail label: bytes sent." + }, + "peers.details.bytesReceived": { + "message": "Received", + "description": "Peer detail label: bytes received." + }, + "peers.details.localIce": { + "message": "Local ICE", + "description": "Peer detail label: local ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.remoteIce": { + "message": "Remote ICE", + "description": "Peer detail label: remote ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.never": { + "message": "Never", + "description": "Value shown when an event has never happened (e.g. last handshake = Never)." + }, + "peers.details.justNow": { + "message": "Just now", + "description": "Relative-time value meaning a moment ago." + }, + "peers.details.refresh": { + "message": "Refresh", + "description": "Button to refresh peer details. Keep short." + }, + "peers.status.connected": { + "message": "Connected", + "description": "Peer status: connected." + }, + "peers.status.connecting": { + "message": "Connecting", + "description": "Peer status: connecting." + }, + "peers.status.disconnected": { + "message": "Disconnected", + "description": "Peer status: disconnected." + }, + "peers.details.relayAddress": { + "message": "Relay", + "description": "Peer detail label: Relay (relay-server address)." + }, + "peers.details.networks": { + "message": "Resources", + "description": "Peer detail label for the peer's network resources. Labelled 'Resources' in the UI." + }, + "peers.details.relayed": { + "message": "Relayed", + "description": "Connection-type value: the connection is relayed (not direct). Technical term." + }, + "peers.details.p2p": { + "message": "P2P", + "description": "Connection-type value: peer-to-peer (direct). 'P2P' — keep as-is." + }, + "peers.details.rosenpass": { + "message": "Rosenpass enabled", + "description": "Peer detail indicating Rosenpass (quantum-resistance) is enabled. 'Rosenpass' — product name, do not translate." + }, + "networks.search.placeholder": { + "message": "Search by network or domain", + "description": "Placeholder in the resources search field." + }, + "networks.filter.all": { + "message": "All", + "description": "Resources filter: All. Keep short." + }, + "networks.filter.active": { + "message": "Active", + "description": "Resources filter: Active. Keep short." + }, + "networks.filter.overlapping": { + "message": "Overlapping", + "description": "Resources filter: Overlapping (overlapping IP ranges). Keep short." + }, + "networks.empty.title": { + "message": "No resources available", + "description": "Title of the empty state when no resources are available." + }, + "networks.empty.description": { + "message": "You either don't have any network resources available or have no access to any of them.", + "description": "Body of the no-resources empty state." + }, + "networks.selected": { + "message": "Selected", + "description": "Label / badge: the resource is selected (enabled)." + }, + "networks.unselected": { + "message": "Not selected", + "description": "Label / badge: the resource is not selected." + }, + "networks.ips.heading": { + "message": "Resolved IPs", + "description": "Heading for the list of a resource's resolved IP addresses." + }, + "networks.bulk.selectionCount": { + "message": "{selected} of {total} Active", + "description": "Header showing how many resources are active. {selected} and {total} are numbers; keep both." + }, + "networks.bulk.enableAll": { + "message": "Enable all", + "description": "Button to enable all resources. Keep short." + }, + "networks.bulk.disableAll": { + "message": "Disable all", + "description": "Button to disable all resources. Keep short." + }, + "exitNodes.search.placeholder": { + "message": "Search exit nodes", + "description": "Placeholder in the exit-nodes search field." + }, + "exitNodes.none": { + "message": "None", + "description": "Option meaning no exit node. Keep short." + }, + "exitNodes.empty.title": { + "message": "No exit nodes available", + "description": "Title of the empty state when no exit nodes are available." + }, + "exitNodes.empty.description": { + "message": "No exit nodes have been shared with this peer.", + "description": "Body of the no-exit-nodes empty state." + }, + "exitNodes.card.title": { + "message": "Exit Node", + "description": "Card heading: Exit Node." + }, + "exitNodes.card.statusActive": { + "message": "Active", + "description": "Exit node card status: Active." + }, + "exitNodes.card.statusInactive": { + "message": "Inactive", + "description": "Exit node card status: Inactive." + }, + "exitNodes.dropdown.noneTitle": { + "message": "None", + "description": "Dropdown option title: None (no exit node)." + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direct connection without an exit node", + "description": "Dropdown option body for None: direct connection without an exit node." + }, + "quickActions.connect": { + "message": "Connect", + "description": "Quick-action button: Connect. Keep short." + }, + "quickActions.disconnect": { + "message": "Disconnect", + "description": "Quick-action button: Disconnect. Keep short." + }, + "daemon.unavailable.title": { + "message": "NetBird Service Is Not Running", + "description": "Title of the overlay shown when the NetBird background service isn't running." + }, + "daemon.unavailable.description": { + "message": "The app will reconnect automatically once the service is running.", + "description": "Body reassuring the user the app will reconnect once the service is running." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation", + "description": "Documentation link on the daemon-unavailable overlay." + }, + "error.jwt_clock_skew": { + "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", + "description": "Sign-in error shown to the user: the device clock is out of sync with the server." + }, + "error.jwt_expired": { + "message": "Your sign-in token has expired. Please sign in again.", + "description": "Sign-in error: the sign-in token expired; sign in again." + }, + "error.jwt_signature_invalid": { + "message": "Sign-in failed: the token signature is invalid. Please contact your administrator.", + "description": "Sign-in error: the token signature is invalid; contact the administrator." + }, + "error.session_expired": { + "message": "Your session has expired. Please sign in again.", + "description": "Error: the session expired; sign in again." + }, + "error.invalid_setup_key": { + "message": "The setup key is missing or invalid.", + "description": "Error: the setup key is missing or invalid. 'setup key' is a NetBird term." + }, + "error.permission_denied": { + "message": "Sign-in was rejected by the server.", + "description": "Error: the server rejected the sign-in." + }, + "error.daemon_unreachable": { + "message": "The NetBird daemon is not responding. Please check that the service is running.", + "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." + }, + "error.unknown": { + "message": "Operation failed.", + "description": "Generic fallback error message used when no specific error applies." + } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 717f601fa..53d57802a 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1,454 +1,1262 @@ { - "tray.tooltip": "NetBird", - "tray.status.disconnected": "Lekapcsolva", - "tray.status.daemonUnavailable": "Nem fut", - "tray.status.error": "Hiba", - "tray.status.connected": "Kapcsolódva", - "tray.status.connecting": "Kapcsolódás", - "tray.status.needsLogin": "Bejelentkezés szükséges", - "tray.status.loginFailed": "Sikertelen bejelentkezés", - "tray.status.sessionExpired": "Munkamenet lejárt", - "tray.session.expiresIn": "Munkamenet lejár {remaining} múlva", - "tray.session.unit.lessThanMinute": "egy percnél kevesebb", - "tray.session.unit.minute": "1 perc", - "tray.session.unit.minutes": "{count} perc", - "tray.session.unit.hour": "1 óra", - "tray.session.unit.hours": "{count} óra", - "tray.session.unit.day": "1 nap", - "tray.session.unit.days": "{count} nap", - - "tray.menu.open": "NetBird megnyitása", - "tray.menu.connect": "Csatlakozás", - "tray.menu.disconnect": "Bontás", - "tray.menu.exitNode": "Kilépő csomópont", - "tray.menu.networks": "Erőforrások", - "tray.menu.profiles": "Profilok", - "tray.menu.manageProfiles": "Profilok kezelése", - "tray.menu.settings": "Beállítások...", - "tray.menu.debugBundle": "Hibakeresési csomag készítése", - "tray.menu.about": "Súgó és támogatás", - "tray.menu.github": "GitHub", - "tray.menu.documentation": "Dokumentáció", - "tray.menu.troubleshoot": "Hibakeresés", - "tray.menu.downloadLatest": "Legfrissebb verzió letöltése", - "tray.menu.installVersion": "{version} verzió telepítése", - "tray.menu.guiVersion": "Felület: {version}", - "tray.menu.daemonVersion": "Daemon: {version}", - "tray.menu.versionUnknown": "—", - "tray.menu.quit": "NetBird bezárása", - - "notify.update.title": "NetBird frissítés elérhető", - "notify.update.body": "Elérhető a NetBird {version}.", - "notify.update.enforcedSuffix": " A rendszergazda kötelezővé tette ezt a frissítést.", - "notify.error.title": "Hiba", - "notify.error.connect": "Csatlakozás sikertelen", - "notify.error.disconnect": "Bontás sikertelen", - "notify.error.switchProfile": "Átváltás sikertelen erre: {profile}", - "notify.error.exitNode": "A kilépő csomópont frissítése sikertelen: {name}", - "notify.sessionExpired.title": "NetBird munkamenet lejárt", - "notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.", - "notify.sessionWarning.title": "Munkamenet hamarosan lejár", - "notify.sessionWarning.body": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz.", - "notify.sessionWarning.bodyGeneric": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz.", - "notify.sessionWarning.extend": "Meghosszabbítás", - "notify.sessionWarning.dismiss": "Elvetés", - "notify.sessionWarning.failed": "A NetBird munkamenet meghosszabbítása sikertelen", - "notify.sessionWarning.successTitle": "NetBird munkamenet meghosszabbítva", - "notify.sessionWarning.successBody": "A munkamenet frissítve.", - "notify.sessionDeadlineRejected.title": "Munkamenet-határidő elutasítva", - "notify.sessionDeadlineRejected.body": "A szerver érvénytelen munkamenet-határidőt küldött. 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.copy": "Másolás", - "common.togglePasswordVisibility": "Jelszó láthatóságának váltása", - "common.increase": "Növelés", - "common.decrease": "Csökkenté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", - "common.noResults.title": "Nincs találat", - "common.noResults.description": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket.", - "notConnected.title": "Lecsatlakozva", - "notConnected.description": "Csatlakozz először a NetBirdhöz, hogy részletes információkat láthass a társakról, hálózati erőforrásokról és kilépő csomópontokról.", - - "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": "{connected} / {total} csatlakoztatva", - "nav.resources.title": "Erőforrások", - "nav.resources.description": "{active} / {total} aktív", - "nav.exitNode.title": "Kilépő csomópontok", - "nav.exitNode.none": "Nem aktív", - "nav.exitNode.using": "Ezen át: {name}", - - "header.openSettings": "Beállítások megnyitása", - "header.togglePanel": "Oldalsó panel váltása", - "header.menu.settings": "Beállítások…", - "header.menu.defaultView": "Alapnézet", - "header.menu.advancedView": "Speciális nézet", - "header.menu.updateAvailable": "Frissítés elérhető", - - "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.selector.switchTo": "Váltás erre a profilra", - "profile.dropdown.activeProfile": "Aktív profil", - "profile.dropdown.switchProfile": "Profilváltás", - "profile.dropdown.noEmail": "Egyéb", - "profile.dropdown.addProfile": "Profil hozzáadása", - "profile.dropdown.manageProfiles": "Profilok kezelése", - "profile.dropdown.settings": "Beállítások", - - "profile.dialog.title": "Új profil", - "profile.dialog.nameLabel": "Profilnév", - "profile.dialog.description": "Adjon profiljának egy könnyen azonosítható nevet.", - "profile.dialog.placeholder": "pl. Munka", - "profile.dialog.required": "Adjon meg egy profilnevet, pl. Munka, Otthon", - "profile.dialog.submit": "Profil hozzáadása", - "profile.dialog.managementHelp": "NetBird Cloud vagy saját kiszolgáló.", - "profile.dialog.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes.", - - "profile.switch.title": "Váltás a(z) \"{name}\" profilra?", - "profile.switch.message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva.", - "profile.switch.confirm": "Megerősítés", - "profile.deregister.title": "\"{name}\" profil leválasztása?", - "profile.deregister.message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához.", - "profile.deregister.confirm": "Leválasztás", - "profile.delete.title": "\"{name}\" profil törlése?", - "profile.delete.message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza.", - "profile.delete.disabledActive": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt.", - "profile.delete.disabledDefault": "Az alapértelmezett profil nem törölhető.", - "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.profiles": "Profilok", - "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.profiles.section.profiles": "Profilok", - "settings.profiles.intro": "Tartson külön NetBird-identitásokat egymás mellett, például munkahelyi és személyes fiókokat, vagy különböző kezelőszervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat.", - "settings.profiles.addProfile": "Profil hozzáadása", - "settings.profiles.active": "Aktív", - "settings.profiles.emptyTitle": "Nincsenek profilok", - "settings.profiles.emptyDescription": "Hozzon létre egy profilt a NetBird kezelőszerverhez való csatlakozáshoz.", - - "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.autostart.label": "NetBird UI indítása bejelentkezéskor", - "settings.general.autostart.help": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem.", - "settings.general.autostart.errorTitle": "Az automatikus indítás módosítása sikertelen", - "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.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes.", - "settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?", - "settings.general.management.switchCloudMessage": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, 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.network.ipv6.label": "IPv6 engedélyezése", - "settings.network.ipv6.help": "IPv6-címzés használata a NetBird overlay hálózathoz.", - - "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.interfaceName.error": "Használj 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást.", - "settings.advanced.interfaceName.errorMac": "„utun” után számmal kezdődjön (pl. utun100).", - "settings.advanced.port.label": "Port", - "settings.advanced.port.error": "Adj meg egy portot {min} és {max} között.", - "settings.advanced.port.help": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ.", - "settings.advanced.mtu.label": "MTU", - "settings.advanced.mtu.error": "Adj meg egy MTU értéket {min} és {max} között.", - "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.
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.clientName": "NetBird Kliens", - "settings.about.development": "[Fejlesztés]", - "settings.about.gui": "Felület v{version}", - "settings.about.guiName": "Felület", - "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", - "settings.about.community.github": "GitHub", - "settings.about.community.slack": "Slack", - "settings.about.community.forum": "Fórum", - "settings.about.community.documentation": "Dokumentáció", - "settings.about.community.feedback": "Visszajelzés", - - "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.versionAvailableDownload": "A {version} verzió letöltésre elérhető.", - "update.card.versionAvailableInstall": "A {version} verzió telepítésre elérhető.", - "update.card.whatsNew": "Mi az újdonság?", - "update.card.installNow": "Telepítés most", - "update.card.getInstaller": "Letöltés", - "update.card.autoCheckInterval": "A NetBird a háttérben keres frissítéseket.", - "update.card.changelog": "Változásnapló", - "update.card.onLatestVersion": "A legfrissebb verziót használod", - "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.error.loadStateTitle": "Frissítési állapot betöltése sikertelen", - "update.error.triggerTitle": "Frissítés indítása sikertelen", - - "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", - - "window.title.settings": "Beállítások", - "window.title.signIn": "Bejelentkezés", - "window.title.sessionExpiration": "Munkamenet lejár", - "window.title.updating": "Frissítés", - "window.title.welcome": "Üdvözli a NetBird", - "window.title.error": "Hiba", - - "welcome.title": "Keresse a NetBirdöt a tálcán", - "welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.", - "welcome.continue": "Folytatás", - "welcome.back": "Vissza", - "welcome.management.title": "NetBird beállítása", - "welcome.management.description": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van.", - "welcome.management.cloud.title": "NetBird Cloud", - "welcome.management.cloud.description": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra.", - "welcome.management.selfHosted.title": "Saját üzemeltetésű", - "welcome.management.selfHosted.description": "Csatlakozás a saját menedzsmentszerveréhez.", - "welcome.management.urlLabel": "Menedzsmentszerver URL", - "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", - "welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443", - "welcome.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes.", - "welcome.management.checking": "Ellenőrzés…", - - "browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez", - "browserLogin.notSeeing": "Nem látja a böngésző fülét?", - "browserLogin.tryAgain": "Próbálja újra", - "browserLogin.openFailedTitle": "A böngésző megnyitása sikertelen", - - "sessionExpiration.title": "A munkamenet hamarosan lejár", - "sessionExpiration.titleLater": "A munkamenete lejár", - "sessionExpiration.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.", - "sessionExpiration.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.", - "sessionExpiration.stay": "Munkamenet megújítása", - "sessionExpiration.authenticate": "Bejelentkezés", - "sessionExpiration.logout": "Kijelentkezés", - "sessionExpiration.expired": "Munkamenet lejárt", - "sessionExpiration.expiredDescription": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz.", - "sessionExpiration.close": "Bezárás", - "sessionExpiration.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen", - "sessionExpiration.logoutFailedTitle": "Kijelentkezés sikertelen", - - "peers.search.placeholder": "Keresés név vagy IP alapján", - "peers.filter.all": "Összes", - "peers.filter.online": "Online", - "peers.filter.offline": "Offline", - "peers.empty.title": "Nincs elérhető társ", - "peers.empty.description": "Önnek vagy nincsenek elérhető társai vagy nincs hozzáférése egyikhez sem.", - "peers.details.domain": "Domain", - "peers.details.netbirdIp": "NetBird IP", - "peers.details.netbirdIpv6": "NetBird IPv6", - "peers.details.publicKey": "Publikus kulcs", - "peers.details.connection": "Kapcsolat", - "peers.details.latency": "Késleltetés", - "peers.details.lastHandshake": "Utolsó kézfogás", - "peers.details.statusSince": "Utolsó kapcsolati frissítés", - "peers.details.bytes": "Bájtok", - "peers.details.bytesSent": "Küldve", - "peers.details.bytesReceived": "Fogadva", - "peers.details.localIce": "Helyi ICE", - "peers.details.remoteIce": "Távoli ICE", - "peers.details.never": "Soha", - "peers.details.justNow": "Épp most", - "peers.details.refresh": "Frissítés", - "peers.status.connected": "Csatlakozva", - "peers.status.connecting": "Csatlakozás", - "peers.status.disconnected": "Lecsatlakozva", - "peers.details.relayAddress": "Relay", - "peers.details.networks": "Erőforrások", - "peers.details.relayed": "Relayed", - "peers.details.p2p": "P2P", - "peers.details.rosenpass": "Rosenpass engedélyezve", - - "networks.search.placeholder": "Keresés hálózat vagy domain alapján", - "networks.filter.all": "Összes", - "networks.filter.active": "Aktív", - "networks.filter.overlapping": "Átfedő", - "networks.empty.title": "Nincs elérhető erőforrás", - "networks.empty.description": "Önnek vagy nincsenek elérhető hálózati erőforrásai vagy nincs hozzáférése egyikhez sem.", - "networks.selected": "Kiválasztva", - "networks.unselected": "Nincs kiválasztva", - "networks.ips.heading": "Feloldott IP-címek", - "networks.bulk.selectionCount": "{selected} / {total} aktív", - "networks.bulk.enableAll": "Összes engedélyezése", - "networks.bulk.disableAll": "Összes letiltása", - - "exitNodes.search.placeholder": "Keresés a kilépő csomópontok között", - "exitNodes.none": "Egyik sem", - "exitNodes.empty.title": "Nincs elérhető kilépő csomópont", - "exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.", - "exitNodes.card.title": "Kilépő csomópont", - "exitNodes.card.statusActive": "Aktív", - "exitNodes.card.statusInactive": "Inaktív", - "exitNodes.dropdown.noneTitle": "Egyik sem", - "exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül", - - "quickActions.connect": "Csatlakozás", - "quickActions.disconnect": "Bontás", - - "daemon.unavailable.title": "A NetBird szolgáltatás nem fut", - "daemon.unavailable.description": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető.", - "daemon.unavailable.docsLink": "Dokumentáció", - - "error.jwt_clock_skew": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra.", - "error.jwt_expired": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra.", - "error.jwt_signature_invalid": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával.", - "error.session_expired": "A munkamenet lejárt. Kérjük, jelentkezzen be újra.", - "error.invalid_setup_key": "A telepítési kulcs hiányzik vagy érvénytelen.", - "error.permission_denied": "A szerver elutasította a bejelentkezést.", - "error.daemon_unreachable": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás.", - "error.unknown": "A művelet meghiúsult." + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Lekapcsolva" + }, + "tray.status.daemonUnavailable": { + "message": "Nem fut" + }, + "tray.status.error": { + "message": "Hiba" + }, + "tray.status.connected": { + "message": "Kapcsolódva" + }, + "tray.status.connecting": { + "message": "Kapcsolódás" + }, + "tray.status.needsLogin": { + "message": "Bejelentkezés szükséges" + }, + "tray.status.loginFailed": { + "message": "Sikertelen bejelentkezés" + }, + "tray.status.sessionExpired": { + "message": "Munkamenet lejárt" + }, + "tray.session.expiresIn": { + "message": "Munkamenet lejár {remaining} múlva" + }, + "tray.session.unit.lessThanMinute": { + "message": "egy percnél kevesebb" + }, + "tray.session.unit.minute": { + "message": "1 perc" + }, + "tray.session.unit.minutes": { + "message": "{count} perc" + }, + "tray.session.unit.hour": { + "message": "1 óra" + }, + "tray.session.unit.hours": { + "message": "{count} óra" + }, + "tray.session.unit.day": { + "message": "1 nap" + }, + "tray.session.unit.days": { + "message": "{count} nap" + }, + "tray.menu.open": { + "message": "NetBird megnyitása" + }, + "tray.menu.connect": { + "message": "Csatlakozás" + }, + "tray.menu.disconnect": { + "message": "Bontás" + }, + "tray.menu.exitNode": { + "message": "Kilépő csomópont" + }, + "tray.menu.networks": { + "message": "Erőforrások" + }, + "tray.menu.profiles": { + "message": "Profilok" + }, + "tray.menu.manageProfiles": { + "message": "Profilok kezelése" + }, + "tray.menu.settings": { + "message": "Beállítások..." + }, + "tray.menu.debugBundle": { + "message": "Hibakeresési csomag készítése" + }, + "tray.menu.about": { + "message": "Súgó és támogatás" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentáció" + }, + "tray.menu.troubleshoot": { + "message": "Hibakeresés" + }, + "tray.menu.downloadLatest": { + "message": "Legfrissebb verzió letöltése" + }, + "tray.menu.installVersion": { + "message": "{version} verzió telepítése" + }, + "tray.menu.guiVersion": { + "message": "Felület: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird bezárása" + }, + "notify.update.title": { + "message": "NetBird frissítés elérhető" + }, + "notify.update.body": { + "message": "Elérhető a NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " A rendszergazda kötelezővé tette ezt a frissítést." + }, + "notify.error.title": { + "message": "Hiba" + }, + "notify.error.connect": { + "message": "Csatlakozás sikertelen" + }, + "notify.error.disconnect": { + "message": "Bontás sikertelen" + }, + "notify.error.switchProfile": { + "message": "Átváltás sikertelen erre: {profile}" + }, + "notify.error.exitNode": { + "message": "A kilépő csomópont frissítése sikertelen: {name}" + }, + "notify.sessionExpired.title": { + "message": "NetBird munkamenet lejárt" + }, + "notify.sessionExpired.body": { + "message": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "notify.sessionWarning.title": { + "message": "Munkamenet hamarosan lejár" + }, + "notify.sessionWarning.body": { + "message": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.extend": { + "message": "Meghosszabbítás" + }, + "notify.sessionWarning.dismiss": { + "message": "Elvetés" + }, + "notify.sessionWarning.failed": { + "message": "A NetBird munkamenet meghosszabbítása sikertelen" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird munkamenet meghosszabbítva" + }, + "notify.sessionWarning.successBody": { + "message": "A munkamenet frissítve." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Munkamenet-határidő elutasítva" + }, + "notify.sessionDeadlineRejected.body": { + "message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra." + }, + "common.cancel": { + "message": "Mégse" + }, + "common.save": { + "message": "Mentés" + }, + "common.saveChanges": { + "message": "Módosítások mentése" + }, + "common.saving": { + "message": "Mentés…" + }, + "common.close": { + "message": "Bezárás" + }, + "common.copy": { + "message": "Másolás" + }, + "common.togglePasswordVisibility": { + "message": "Jelszó láthatóságának váltása" + }, + "common.increase": { + "message": "Növelés" + }, + "common.decrease": { + "message": "Csökkentés" + }, + "common.delete": { + "message": "Törlés" + }, + "common.create": { + "message": "Létrehozás" + }, + "common.add": { + "message": "Hozzáadás" + }, + "common.remove": { + "message": "Eltávolítás" + }, + "common.refresh": { + "message": "Frissítés" + }, + "common.loading": { + "message": "Betöltés…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nincs találat" + }, + "common.noResults.description": { + "message": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket." + }, + "notConnected.title": { + "message": "Lecsatlakozva" + }, + "notConnected.description": { + "message": "Csatlakozz először a NetBirdhöz, hogy részletes információkat láthass a társakról, hálózati erőforrásokról és kilépő csomópontokról." + }, + "connect.status.disconnected": { + "message": "Lekapcsolva" + }, + "connect.status.connecting": { + "message": "Csatlakozás…" + }, + "connect.status.connected": { + "message": "Csatlakoztatva" + }, + "connect.status.disconnecting": { + "message": "Bontás…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nem elérhető" + }, + "connect.status.loginRequired": { + "message": "Bejelentkezés szükséges" + }, + "connect.error.loginTitle": { + "message": "Bejelentkezés sikertelen" + }, + "connect.error.connectTitle": { + "message": "Csatlakozás sikertelen" + }, + "connect.error.disconnectTitle": { + "message": "Bontás sikertelen" + }, + "nav.peers.title": { + "message": "Társak" + }, + "nav.peers.description": { + "message": "{connected} / {total} csatlakoztatva" + }, + "nav.resources.title": { + "message": "Erőforrások" + }, + "nav.resources.description": { + "message": "{active} / {total} aktív" + }, + "nav.exitNode.title": { + "message": "Kilépő csomópontok" + }, + "nav.exitNode.none": { + "message": "Nem aktív" + }, + "nav.exitNode.using": { + "message": "Ezen át: {name}" + }, + "header.openSettings": { + "message": "Beállítások megnyitása" + }, + "header.togglePanel": { + "message": "Oldalsó panel váltása" + }, + "header.menu.settings": { + "message": "Beállítások…" + }, + "header.menu.defaultView": { + "message": "Alapnézet" + }, + "header.menu.advancedView": { + "message": "Speciális nézet" + }, + "header.menu.updateAvailable": { + "message": "Frissítés elérhető" + }, + "profile.selector.loading": { + "message": "Betöltés…" + }, + "profile.selector.noProfile": { + "message": "Nincs profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil keresése név alapján…" + }, + "profile.selector.emptyTitle": { + "message": "Nem található profil" + }, + "profile.selector.emptyDescription": { + "message": "Próbáljon más keresőkifejezést, vagy hozzon létre új profilt." + }, + "profile.selector.newProfile": { + "message": "Új profil" + }, + "profile.selector.moreOptions": { + "message": "További műveletek" + }, + "profile.selector.deregister": { + "message": "Leválasztás" + }, + "profile.selector.delete": { + "message": "Profil törlése" + }, + "profile.selector.switchTo": { + "message": "Váltás erre a profilra" + }, + "profile.dropdown.activeProfile": { + "message": "Aktív profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profilváltás" + }, + "profile.dropdown.noEmail": { + "message": "Egyéb" + }, + "profile.dropdown.addProfile": { + "message": "Profil hozzáadása" + }, + "profile.dropdown.manageProfiles": { + "message": "Profilok kezelése" + }, + "profile.dropdown.settings": { + "message": "Beállítások" + }, + "profile.dialog.title": { + "message": "Új profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilnév" + }, + "profile.dialog.description": { + "message": "Adjon profiljának egy könnyen azonosítható nevet." + }, + "profile.dialog.placeholder": { + "message": "pl. Munka" + }, + "profile.dialog.required": { + "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" + }, + "profile.dialog.submit": { + "message": "Profil hozzáadása" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud vagy saját kiszolgáló." + }, + "profile.dialog.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes." + }, + "profile.switch.title": { + "message": "Váltás a(z) \"{name}\" profilra?" + }, + "profile.switch.message": { + "message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva." + }, + "profile.switch.confirm": { + "message": "Megerősítés" + }, + "profile.deregister.title": { + "message": "\"{name}\" profil leválasztása?" + }, + "profile.deregister.message": { + "message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához." + }, + "profile.deregister.confirm": { + "message": "Leválasztás" + }, + "profile.delete.title": { + "message": "\"{name}\" profil törlése?" + }, + "profile.delete.message": { + "message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza." + }, + "profile.delete.disabledActive": { + "message": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt." + }, + "profile.delete.disabledDefault": { + "message": "Az alapértelmezett profil nem törölhető." + }, + "profile.error.switchTitle": { + "message": "Profilváltás sikertelen" + }, + "profile.error.deregisterTitle": { + "message": "Leválasztás sikertelen" + }, + "profile.error.deleteTitle": { + "message": "Profil törlése sikertelen" + }, + "profile.error.createTitle": { + "message": "Profil létrehozása sikertelen" + }, + "profile.error.loadTitle": { + "message": "Profilok betöltése sikertelen" + }, + "settings.error.loadTitle": { + "message": "Beállítások betöltése sikertelen" + }, + "settings.error.saveTitle": { + "message": "Beállítások mentése sikertelen" + }, + "settings.error.debugBundleTitle": { + "message": "Hibakeresési csomag sikertelen" + }, + "settings.tabs.general": { + "message": "Általános" + }, + "settings.tabs.network": { + "message": "Hálózat" + }, + "settings.tabs.security": { + "message": "Biztonság" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.profiles": { + "message": "Profilok" + }, + "settings.tabs.advanced": { + "message": "Speciális" + }, + "settings.tabs.troubleshooting": { + "message": "Hibaelhárítás" + }, + "settings.tabs.about": { + "message": "Névjegy" + }, + "settings.tabs.updateAvailable": { + "message": "Frissítés elérhető" + }, + "settings.profiles.section.profiles": { + "message": "Profilok" + }, + "settings.profiles.intro": { + "message": "Tartson külön NetBird-identitásokat egymás mellett, például munkahelyi és személyes fiókokat, vagy különböző kezelőszervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." + }, + "settings.profiles.addProfile": { + "message": "Profil hozzáadása" + }, + "settings.profiles.active": { + "message": "Aktív" + }, + "settings.profiles.emptyTitle": { + "message": "Nincsenek profilok" + }, + "settings.profiles.emptyDescription": { + "message": "Hozzon létre egy profilt a NetBird kezelőszerverhez való csatlakozáshoz." + }, + "settings.general.section.general": { + "message": "Általános" + }, + "settings.general.section.connection": { + "message": "Kapcsolat" + }, + "settings.general.connectOnStartup.label": { + "message": "Csatlakozás indításkor" + }, + "settings.general.connectOnStartup.help": { + "message": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít." + }, + "settings.general.notifications.label": { + "message": "Asztali értesítések" + }, + "settings.general.notifications.help": { + "message": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről." + }, + "settings.general.autostart.label": { + "message": "NetBird UI indítása bejelentkezéskor" + }, + "settings.general.autostart.help": { + "message": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem." + }, + "settings.general.autostart.errorTitle": { + "message": "Az automatikus indítás módosítása sikertelen" + }, + "settings.general.language.label": { + "message": "Megjelenítési nyelv" + }, + "settings.general.language.help": { + "message": "Válassza ki a NetBird felület nyelvét." + }, + "settings.general.language.search": { + "message": "Nyelv keresése…" + }, + "settings.general.language.empty": { + "message": "Nincs találat." + }, + "settings.general.management.label": { + "message": "Kezelőszerver" + }, + "settings.general.management.help": { + "message": "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": { + "message": "Felhő" + }, + "settings.general.management.selfHosted": { + "message": "Saját üzemeltetésű" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes." + }, + "settings.general.management.switchCloudTitle": { + "message": "Átváltás a NetBird Cloudra?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Váltás a felhőre" + }, + "settings.network.section.connectivity": { + "message": "Kapcsolódás" + }, + "settings.network.section.routingDns": { + "message": "Útválasztás és DNS" + }, + "settings.network.lazy.label": { + "message": "Késleltetett kapcsolatok" + }, + "settings.network.lazy.help": { + "message": "Állandó kapcsolatok fenntartása helyett a NetBird igény szerint, aktivitás vagy jelzés alapján aktiválja azokat." + }, + "settings.network.monitor.label": { + "message": "Újracsatlakozás hálózatváltáskor" + }, + "settings.network.monitor.help": { + "message": "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": { + "message": "DNS engedélyezése" + }, + "settings.network.dns.help": { + "message": "A NetBird által kezelt DNS-beállítások alkalmazása a gazda DNS-feloldójára." + }, + "settings.network.clientRoutes.label": { + "message": "Kliens útvonalak engedélyezése" + }, + "settings.network.clientRoutes.help": { + "message": "Más társak útvonalainak elfogadása az ő hálózataik eléréséhez." + }, + "settings.network.serverRoutes.label": { + "message": "Szerver útvonalak engedélyezése" + }, + "settings.network.serverRoutes.help": { + "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más társak számára." + }, + "settings.network.ipv6.label": { + "message": "IPv6 engedélyezése" + }, + "settings.network.ipv6.help": { + "message": "IPv6-címzés használata a NetBird overlay hálózathoz." + }, + "settings.security.section.firewall": { + "message": "Tűzfal" + }, + "settings.security.section.encryption": { + "message": "Titkosítás" + }, + "settings.security.blockInbound.label": { + "message": "Bejövő forgalom blokkolása" + }, + "settings.security.blockInbound.help": { + "message": "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": { + "message": "LAN-hozzáférés blokkolása" + }, + "settings.security.blockLan.help": { + "message": "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": { + "message": "Kvantumellenálló titkosítás engedélyezése" + }, + "settings.security.rosenpass.help": { + "message": "Post-kvantum kulcscsere hozzáadása Rosenpass segítségével a WireGuard® tetejére." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Engedékeny mód engedélyezése" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli társakkal." + }, + "settings.ssh.section.server": { + "message": "Szerver" + }, + "settings.ssh.section.capabilities": { + "message": "Képességek" + }, + "settings.ssh.section.authentication": { + "message": "Hitelesítés" + }, + "settings.ssh.server.label": { + "message": "SSH szerver engedélyezése" + }, + "settings.ssh.server.help": { + "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más társak csatlakozhassanak." + }, + "settings.ssh.root.label": { + "message": "Root bejelentkezés engedélyezése" + }, + "settings.ssh.root.help": { + "message": "Társak bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." + }, + "settings.ssh.sftp.label": { + "message": "SFTP engedélyezése" + }, + "settings.ssh.sftp.help": { + "message": "Fájlok biztonságos átvitele natív SFTP- vagy SCP-kliensekkel." + }, + "settings.ssh.localForward.label": { + "message": "Helyi porttovábbítás" + }, + "settings.ssh.localForward.help": { + "message": "A csatlakozó társak helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." + }, + "settings.ssh.remoteForward.label": { + "message": "Távoli porttovábbítás" + }, + "settings.ssh.remoteForward.help": { + "message": "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": { + "message": "JWT-hitelesítés engedélyezése" + }, + "settings.ssh.jwt.help": { + "message": "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": { + "message": "JWT gyorsítótár TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "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": { + "message": "másodperc" + }, + "settings.advanced.section.interface": { + "message": "Interfész" + }, + "settings.advanced.section.security": { + "message": "Biztonság" + }, + "settings.advanced.interfaceName.label": { + "message": "Név" + }, + "settings.advanced.interfaceName.error": { + "message": "Használj 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "„utun” után számmal kezdődjön (pl. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Adj meg egy portot {min} és {max} között." + }, + "settings.advanced.port.help": { + "message": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Adj meg egy MTU értéket {min} és {max} között." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared kulcs" + }, + "settings.advanced.psk.help": { + "message": "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": { + "message": "Hibakeresési csomag" + }, + "settings.troubleshooting.intro": { + "message": "A hibakeresési csomag segít a NetBird támogatásnak a kapcsolati problémák kivizsgálásában.
Egy .zip fájl, amely naplókat, rendszerinformációkat és hibakeresési adatokat tartalmaz az eszközéről." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Érzékeny információk anonimizálása" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Rendszerinformációk beillesztése" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Tartalmazza az OS-t, a kernelt, a hálózati interfészeket és az útválasztási táblákat." + }, + "settings.troubleshooting.upload.label": { + "message": "Csomag feltöltése a NetBird szerverekre" + }, + "settings.troubleshooting.upload.help": { + "message": "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": { + "message": "Trace naplók rögzítése" + }, + "settings.troubleshooting.trace.help": { + "message": "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": { + "message": "Rögzítés időtartama" + }, + "settings.troubleshooting.duration.help": { + "message": "Mennyi ideig rögzítse a trace naplókat a csomag elkészítése előtt." + }, + "settings.troubleshooting.duration.suffix": { + "message": "perc" + }, + "settings.troubleshooting.create": { + "message": "Csomag létrehozása" + }, + "settings.troubleshooting.progress.description": { + "message": "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": { + "message": "Megszakítás…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "A hibakeresési csomag feltöltése sikeres!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Csomag elmentve" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "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": { + "message": "A hibakeresési csomag helyileg elmentve." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Kulcs másolása" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Mappa megnyitása" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Fájl helyének megnyitása" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Feltöltés sikertelen: {reason} A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Feltöltés sikertelen. A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Váltás trace naplózásra…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird újracsatlakoztatása…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Naplók rögzítése — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Korábbi napló szint visszaállítása…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Hibakeresési csomag generálása…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Feltöltés a NetBirdhöz…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Megszakítás…" + }, + "settings.about.client": { + "message": "NetBird Kliens v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Kliens" + }, + "settings.about.development": { + "message": "[Fejlesztés]" + }, + "settings.about.gui": { + "message": "Felület v{version}" + }, + "settings.about.guiName": { + "message": "Felület" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Minden jog fenntartva." + }, + "settings.about.links.imprint": { + "message": "Impresszum" + }, + "settings.about.links.privacy": { + "message": "Adatvédelem" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Felhasználási feltételek" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Dokumentáció" + }, + "settings.about.community.feedback": { + "message": "Visszajelzés" + }, + "update.banner.message": { + "message": "A NetBird {version} telepítésre kész." + }, + "update.banner.later": { + "message": "Később" + }, + "update.banner.installNow": { + "message": "Telepítés most" + }, + "update.card.versionAvailableDownload": { + "message": "A {version} verzió letöltésre elérhető." + }, + "update.card.versionAvailableInstall": { + "message": "A {version} verzió telepítésre elérhető." + }, + "update.card.whatsNew": { + "message": "Mi az újdonság?" + }, + "update.card.installNow": { + "message": "Telepítés most" + }, + "update.card.getInstaller": { + "message": "Letöltés" + }, + "update.card.autoCheckInterval": { + "message": "A NetBird a háttérben keres frissítéseket." + }, + "update.card.changelog": { + "message": "Változásnapló" + }, + "update.card.onLatestVersion": { + "message": "A legfrissebb verziót használod" + }, + "update.header.tooltip": { + "message": "Frissítés elérhető" + }, + "update.overlay.updatingVersion": { + "message": "NetBird frissítése a következőre: v{version}" + }, + "update.overlay.updating": { + "message": "NetBird frissítése" + }, + "update.overlay.description": { + "message": "Egy újabb verzió elérhető és települ. A NetBird automatikusan újraindul a frissítés befejeztével." + }, + "update.overlay.error.timeoutTitle": { + "message": "A frissítés túl sokáig tart" + }, + "update.overlay.error.timeoutDescription": { + "message": "A(z) {target} telepítése túl sokáig tartott, és nem fejeződött be." + }, + "update.overlay.error.canceledTitle": { + "message": "A frissítés megszakítva" + }, + "update.overlay.error.canceledDescription": { + "message": "A(z) {target} frissítését megszakították a befejezés előtt." + }, + "update.overlay.error.failTitle": { + "message": "A frissítés nem telepíthető" + }, + "update.overlay.error.failDescription": { + "message": "A(z) {target} nem volt telepíthető." + }, + "update.overlay.error.unknownMessage": { + "message": "ismeretlen hiba" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "az új verzió" + }, + "update.error.loadStateTitle": { + "message": "Frissítési állapot betöltése sikertelen" + }, + "update.error.triggerTitle": { + "message": "Frissítés indítása sikertelen" + }, + "update.page.versionLine": { + "message": "Kliens frissítése erre: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Kliens frissítése." + }, + "update.page.outdated": { + "message": "Az Ön kliensverziója régebbi, mint a Managementben beállított automatikus frissítési verzió." + }, + "update.page.status.running": { + "message": "Frissítés" + }, + "update.page.status.timeout": { + "message": "A frissítés időtúllépés miatt megszakadt. Kérjük, próbálja újra." + }, + "update.page.status.canceled": { + "message": "Frissítés megszakítva." + }, + "update.page.status.failed": { + "message": "Frissítés sikertelen: {message}" + }, + "update.page.status.unknownError": { + "message": "ismeretlen frissítési hiba" + }, + "update.page.failedTitle": { + "message": "Frissítés sikertelen" + }, + "update.page.timeoutMessage": { + "message": "Frissítés időtúllépés." + }, + "update.page.dontClose": { + "message": "Kérjük, ne zárja be ezt az ablakot." + }, + "update.page.updating": { + "message": "Frissítés…" + }, + "update.page.complete": { + "message": "Frissítés kész" + }, + "update.page.failed": { + "message": "Frissítés sikertelen" + }, + "window.title.settings": { + "message": "Beállítások" + }, + "window.title.signIn": { + "message": "Bejelentkezés" + }, + "window.title.sessionExpiration": { + "message": "Munkamenet lejár" + }, + "window.title.updating": { + "message": "Frissítés" + }, + "window.title.welcome": { + "message": "Üdvözli a NetBird" + }, + "window.title.error": { + "message": "Hiba" + }, + "welcome.title": { + "message": "Keresse a NetBirdöt a tálcán" + }, + "welcome.description": { + "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.continue": { + "message": "Folytatás" + }, + "welcome.back": { + "message": "Vissza" + }, + "welcome.management.title": { + "message": "NetBird beállítása" + }, + "welcome.management.description": { + "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra." + }, + "welcome.management.selfHosted.title": { + "message": "Saját üzemeltetésű" + }, + "welcome.management.selfHosted.description": { + "message": "Csatlakozás a saját menedzsmentszerveréhez." + }, + "welcome.management.urlLabel": { + "message": "Menedzsmentszerver URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes." + }, + "welcome.management.checking": { + "message": "Ellenőrzés…" + }, + "browserLogin.title": { + "message": "Folytassa a böngészőben a bejelentkezés befejezéséhez" + }, + "browserLogin.notSeeing": { + "message": "Nem látja a böngésző fülét?" + }, + "browserLogin.tryAgain": { + "message": "Próbálja újra" + }, + "browserLogin.openFailedTitle": { + "message": "A böngésző megnyitása sikertelen" + }, + "sessionExpiration.title": { + "message": "A munkamenet hamarosan lejár" + }, + "sessionExpiration.titleLater": { + "message": "A munkamenete lejár" + }, + "sessionExpiration.description": { + "message": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell." + }, + "sessionExpiration.descriptionLater": { + "message": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt." + }, + "sessionExpiration.stay": { + "message": "Munkamenet megújítása" + }, + "sessionExpiration.authenticate": { + "message": "Bejelentkezés" + }, + "sessionExpiration.logout": { + "message": "Kijelentkezés" + }, + "sessionExpiration.expired": { + "message": "Munkamenet lejárt" + }, + "sessionExpiration.expiredDescription": { + "message": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz." + }, + "sessionExpiration.close": { + "message": "Bezárás" + }, + "sessionExpiration.extendFailedTitle": { + "message": "A munkamenet meghosszabbítása sikertelen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Kijelentkezés sikertelen" + }, + "peers.search.placeholder": { + "message": "Keresés név vagy IP alapján" + }, + "peers.filter.all": { + "message": "Összes" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nincs elérhető társ" + }, + "peers.empty.description": { + "message": "Önnek vagy nincsenek elérhető társai vagy nincs hozzáférése egyikhez sem." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Publikus kulcs" + }, + "peers.details.connection": { + "message": "Kapcsolat" + }, + "peers.details.latency": { + "message": "Késleltetés" + }, + "peers.details.lastHandshake": { + "message": "Utolsó kézfogás" + }, + "peers.details.statusSince": { + "message": "Utolsó kapcsolati frissítés" + }, + "peers.details.bytes": { + "message": "Bájtok" + }, + "peers.details.bytesSent": { + "message": "Küldve" + }, + "peers.details.bytesReceived": { + "message": "Fogadva" + }, + "peers.details.localIce": { + "message": "Helyi ICE" + }, + "peers.details.remoteIce": { + "message": "Távoli ICE" + }, + "peers.details.never": { + "message": "Soha" + }, + "peers.details.justNow": { + "message": "Épp most" + }, + "peers.details.refresh": { + "message": "Frissítés" + }, + "peers.status.connected": { + "message": "Csatlakozva" + }, + "peers.status.connecting": { + "message": "Csatlakozás" + }, + "peers.status.disconnected": { + "message": "Lecsatlakozva" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Erőforrások" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass engedélyezve" + }, + "networks.search.placeholder": { + "message": "Keresés hálózat vagy domain alapján" + }, + "networks.filter.all": { + "message": "Összes" + }, + "networks.filter.active": { + "message": "Aktív" + }, + "networks.filter.overlapping": { + "message": "Átfedő" + }, + "networks.empty.title": { + "message": "Nincs elérhető erőforrás" + }, + "networks.empty.description": { + "message": "Önnek vagy nincsenek elérhető hálózati erőforrásai vagy nincs hozzáférése egyikhez sem." + }, + "networks.selected": { + "message": "Kiválasztva" + }, + "networks.unselected": { + "message": "Nincs kiválasztva" + }, + "networks.ips.heading": { + "message": "Feloldott IP-címek" + }, + "networks.bulk.selectionCount": { + "message": "{selected} / {total} aktív" + }, + "networks.bulk.enableAll": { + "message": "Összes engedélyezése" + }, + "networks.bulk.disableAll": { + "message": "Összes letiltása" + }, + "exitNodes.search.placeholder": { + "message": "Keresés a kilépő csomópontok között" + }, + "exitNodes.none": { + "message": "Egyik sem" + }, + "exitNodes.empty.title": { + "message": "Nincs elérhető kilépő csomópont" + }, + "exitNodes.empty.description": { + "message": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat." + }, + "exitNodes.card.title": { + "message": "Kilépő csomópont" + }, + "exitNodes.card.statusActive": { + "message": "Aktív" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktív" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Egyik sem" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Közvetlen kapcsolat kilépő csomópont nélkül" + }, + "quickActions.connect": { + "message": "Csatlakozás" + }, + "quickActions.disconnect": { + "message": "Bontás" + }, + "daemon.unavailable.title": { + "message": "A NetBird szolgáltatás nem fut" + }, + "daemon.unavailable.description": { + "message": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentáció" + }, + "error.jwt_clock_skew": { + "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." + }, + "error.jwt_expired": { + "message": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra." + }, + "error.jwt_signature_invalid": { + "message": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával." + }, + "error.session_expired": { + "message": "A munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "error.invalid_setup_key": { + "message": "A telepítési kulcs hiányzik vagy érvénytelen." + }, + "error.permission_denied": { + "message": "A szerver elutasította a bejelentkezést." + }, + "error.daemon_unreachable": { + "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." + }, + "error.unknown": { + "message": "A művelet meghiúsult." + } } From f8e3ac6d92bfef040c4a07ad05eda3bfef8c3491 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 9 Jun 2026 16:31:52 +0200 Subject: [PATCH 3/7] refactor, lint, cleanup --- client/ui/CLAUDE.md | 4 +- client/ui/frontend/CLAUDE.md | 255 +++++------ client/ui/frontend/src/app.tsx | 30 +- client/ui/frontend/src/components/Badge.tsx | 8 - .../src/components/CopyToClipboard.tsx | 33 +- .../src/components/LanguagePicker.tsx | 10 +- .../src/components/ManagementServerSwitch.tsx | 4 - .../ui/frontend/src/components/SquareIcon.tsx | 5 - client/ui/frontend/src/components/Tooltip.tsx | 13 +- .../frontend/src/components/TruncatedText.tsx | 12 +- .../frontend/src/components/VerticalTabs.tsx | 121 +++-- .../src/components/buttons/Button.tsx | 15 +- .../src/components/dialog/ConfirmDialog.tsx | 9 - .../src/components/dialog/ConfirmModal.tsx | 16 +- .../src/components/dialog/DialogActions.tsx | 10 +- .../components/dialog/DialogDescription.tsx | 20 +- .../src/components/dialog/DialogHeading.tsx | 9 - .../empty-state/DaemonUnavailableOverlay.tsx | 6 +- .../src/components/empty-state/EmptyState.tsx | 7 +- .../frontend/src/components/inputs/Input.tsx | 300 ++++++++----- .../src/components/inputs/SearchInput.tsx | 74 ++-- .../components/switches/FancyToggleSwitch.tsx | 35 +- .../components/switches/SwitchItemGroup.tsx | 6 +- .../src/components/typography/Label.tsx | 6 +- .../src/contexts/ClientVersionContext.tsx | 25 +- .../src/contexts/DebugBundleContext.tsx | 162 ++++--- .../frontend/src/contexts/DialogContext.tsx | 32 +- .../src/contexts/NavSectionContext.tsx | 13 +- .../frontend/src/contexts/NetworksContext.tsx | 106 ++--- .../src/contexts/PeerDetailContext.tsx | 13 +- .../frontend/src/contexts/ProfileContext.tsx | 66 +-- .../frontend/src/contexts/SettingsContext.tsx | 51 +-- .../frontend/src/contexts/StatusContext.tsx | 63 ++- .../frontend/src/contexts/ViewModeContext.tsx | 49 +-- client/ui/frontend/src/globals.css | 28 +- .../frontend/src/hooks/useAutoSizeWindow.ts | 63 +-- .../frontend/src/hooks/useKeyboardShortcut.ts | 34 +- .../ui/frontend/src/hooks/useManagementUrl.ts | 70 +-- client/ui/frontend/src/layouts/AppLayout.tsx | 7 - .../ui/frontend/src/layouts/AppRightPanel.tsx | 3 - client/ui/frontend/src/lib/cn.ts | 2 +- client/ui/frontend/src/lib/dialogs.ts | 30 -- client/ui/frontend/src/lib/errors.ts | 69 +-- client/ui/frontend/src/lib/formatters.ts | 42 +- client/ui/frontend/src/lib/i18n.ts | 44 +- client/ui/frontend/src/lib/logs.ts | 35 +- client/ui/frontend/src/lib/mock.ts | 416 ------------------ client/ui/frontend/src/lib/platform.ts | 45 +- client/ui/frontend/src/lib/sorting.ts | 27 ++ .../auto-update/UpdateInProgressDialog.tsx | 62 +-- .../modules/auto-update/UpdateVersionCard.tsx | 10 +- .../src/modules/error/ErrorDialog.tsx | 17 +- .../login/LoginWaitingForBrowserDialog.tsx | 23 +- .../main/MainConnectionStatusSwitch.tsx | 142 +----- .../src/modules/main/MainExitNodeSwitcher.tsx | 24 +- .../frontend/src/modules/main/MainHeader.tsx | 23 +- .../ui/frontend/src/modules/main/MainPage.tsx | 12 +- .../main/advanced/networks/NetworkFilters.tsx | 21 +- .../main/advanced/networks/Networks.tsx | 117 +++-- .../main/advanced/peers/PeerDetailPanel.tsx | 25 +- .../main/advanced/peers/PeerFilters.tsx | 4 +- .../src/modules/main/advanced/peers/Peers.tsx | 91 ++-- .../src/modules/profiles/ProfileAvatar.tsx | 5 +- .../modules/profiles/ProfileCreationModal.tsx | 32 +- .../src/modules/profiles/ProfileDropdown.tsx | 145 +++--- .../src/modules/profiles/ProfilesTab.tsx | 105 ++--- .../session/SessionExpirationDialog.tsx | 74 +--- .../src/modules/settings/SettingsAbout.tsx | 42 +- .../src/modules/settings/SettingsAccent.tsx | 8 +- .../src/modules/settings/SettingsAdvanced.tsx | 46 +- .../src/modules/settings/SettingsGeneral.tsx | 22 +- .../modules/settings/SettingsNavigation.tsx | 86 ++-- .../src/modules/settings/SettingsPage.tsx | 129 +++--- .../src/modules/settings/SettingsSSH.tsx | 14 +- .../src/modules/settings/SettingsSection.tsx | 4 - .../settings/SettingsTroubleshooting.tsx | 37 +- .../src/modules/welcome/WelcomeDialog.tsx | 40 +- .../modules/welcome/WelcomeStepManagement.tsx | 32 +- .../src/modules/welcome/WelcomeStepTray.tsx | 9 +- 79 files changed, 1441 insertions(+), 2463 deletions(-) delete mode 100644 client/ui/frontend/src/lib/dialogs.ts delete mode 100644 client/ui/frontend/src/lib/mock.ts create mode 100644 client/ui/frontend/src/lib/sorting.ts diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 33eb8f8a0..b8586214e 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -98,7 +98,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created - **InstallProgress** (`/#/dialog/install-progress?version=`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close). - **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. -- **Error** (`/#/dialog/error?message=`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - "` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body. +- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/errors.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body. The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm. @@ -133,7 +133,7 @@ The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — e ### Errors → custom Error window -User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean. +User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/errors.ts` (alongside `formatErrorMessage`), which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean. **Skip dialogs entirely** 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). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 1d89f8f78..1c5b85797 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -1,14 +1,12 @@ # NetBird Wails UI — Frontend Working Notes -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`. +The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`. -> **Keep these notes current.** 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. +> **Keep these notes current.** Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase. ## Stack & tooling -React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by `NetBirdConnectToggle`. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`. +React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by the connect toggle. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`. ## Path aliases & bindings @@ -16,200 +14,171 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar `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) +## Routing (`app.tsx`) -`HashRouter` with the following routes: +`HashRouter`. Dialog routes are grouped under a parent `<Route path="dialog">` (URL grouping only, no shared layout); the two in-window routes sit under `<AppLayout>`. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. -| Path | Component | Layout | Where it opens | +| Path | Component (module) | Layout | Window | |---|---|---|---| -| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route | -| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) | -| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). | -| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpiration(seconds)`, always-on-top, mm:ss countdown via `?seconds=`). Drives both the soon-to-expire warning and (when seconds elapse to zero) the expired state. | -| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. | -| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. | -| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). | +| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window | +| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Settings auxiliary window | +| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | SSO browser-wait, always-on-top | +| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Install progress, always-on-top | +| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Session expiry warning, always-on-top | +| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | First-launch onboarding | +| `/dialog/error` | `ErrorDialog` (modules/error/) | none | App's single error surface, always-on-top | | `*` | `<Navigate to="/">` | `AppLayout` | Catch-all | -In `app.tsx` the dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping. +Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in `services/windowmanager.go` — see `../CLAUDE.md`. Frontend-relevant notes per window: -`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route). +- **Settings** — opened via `WindowManager.OpenSettings(tab)`. The window stays at `/#/settings` for its whole lifetime (no `SetURL` between opens, so `AppLayout`'s providers never remount). Active tab is React local state in `SettingsPage`, set from the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is driven in React by a `document.visibilitychange` listener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which races `Hide` and flashes the previous tab for one frame). +- **install-progress** — owns the install-result polling + 5s daemon-down-grace, calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (user-driven enforced branch) and on the `installing` flip from `netbird:update:state` (force-install branch). +- **session-expiration** — `?seconds=` drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emit `trigger-login`; Logout calls `Connection.Logout`. +- **welcome** — opened from Go's `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. +- **error** — `errorDialog({Title, Message})` in `lib/errors.ts` opens this (not a native OS box). `title` is the window chrome title (set Go-side, not in the body); `message` is read from `useSearchParams` and rendered next to a danger `SquareIcon`, with a Close button (Escape also closes → `WindowManager.CloseError()`). -Page-specific chrome lives next to the page, not in the layout: -- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only: - - `Header` reads `useViewMode` (view-mode dropdown) and `useClientVersion` (update badge). - - `ViewModeProvider` wraps the whole of `Main` because both `Header` and `MainBody` read view mode. It calls `Window.SetSize` on the current Wails window, so it must not be visible to the Settings window. - - `NavSectionProvider` is mounted only inside the advanced-mode branch (`MainBody → AdvancedRightPanel`) — the default-mode view has no Peers/Resources/Exit Nodes tabs and no consumer of `useNavSection`. Default mode therefore skips the provider entirely. - - `Header.tsx`, `Navigation.tsx`, and `ConnectionStatusSwitch.tsx` are siblings of `Main.tsx` in `pages/main/` because nothing else uses them. -- **`pages/Settings.tsx`** owns the `h-12` `wails-draggable` strip at the top (so the macOS traffic-light buttons that float over the `MacTitleBarHiddenInset` window don't overlap content), then renders the vertical tabs — no view-mode, no nav, no header. +## Layouts -## Directory layout (src/) +`AppLayout` is the only router-level layout. It mounts the shared provider stack and renders `<Outlet/>`: -- `app.tsx` — root render + route table. The canonical registry of every route; scan this file to enumerate pages. -- `layouts/AppLayout.tsx` — the router-level layout. Mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) and renders `<Outlet/>`. (`layouts/` also holds `AppRightPanel.tsx`, see below.) -- `modules/<feature>/` — every feature owns its own folder: page entry (named `<Feature>Page.tsx`), local components, and everything else it needs: - - `modules/main/` — `MainPage.tsx` + main-window chrome (`Header.tsx`, `ConnectionStatusSwitch.tsx`). - - `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`. - - `modules/settings/` — `SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`. - - `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window). - - `modules/session/` — `SessionExpirationDialog.tsx` (session expiration warning + expired state). - - `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`. - - `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal. - - `modules/error/` — `ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`. - - `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator. +``` +DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider +``` - Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise. -- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`." -- `components/` — presentational primitives, no domain coupling. Grouped by family: - - `components/buttons/` — `Button`, `IconButton`. - - `components/inputs/` — `Input`, `SearchInput`. - - `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly). - - `components/switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`. - - `components/typography/` — `Label`, `HelpText`. - - `components/empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`. - - Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives). -- `layouts/` — `AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`. -- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`). -- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller. -- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there. +- `DialogProvider` is outermost (and outside the daemon gate) so `useConfirm()` works regardless of daemon state. +- `StatusProvider` owns the single `DaemonFeed.Get` + `netbird:status` subscription and **only renders its children when the daemon is reachable** — otherwise it short-circuits to `<DaemonUnavailableOverlay/>`. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return. +- Order matters: `SettingsContext` (mounted in `SettingsPage`) depends on `ProfileContext`; `ClientVersionContext` reads `StatusContext` events. + +`AppRightPanel` (in `layouts/`) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it). + +Page-specific chrome and providers live in the page, not the layout: + +- **`MainPage`** (main window only) mounts `ViewModeProvider` (wraps the whole page — both `MainHeader` and `MainBody` read view mode; it calls `Window.SetSize`, so it must not be visible to the Settings window), `NetworksProvider`, and `PeerDetailProvider`. `NavSectionProvider` is mounted **only** inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer of `useNavSection`. +- **`SettingsPage`** owns the `wails-draggable` strip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs. + +## Directory layout (`src/`) + +- `app.tsx` — root render + route table. The canonical registry of every route. Also wires init-time bootstrap (`initLogForwarding`, `welcome`, `initI18n`, `initPlatform`) before first render. +- `layouts/` — `AppLayout.tsx` (the only router-level layout) and `AppRightPanel.tsx` (shared content-panel shell). +- `modules/<feature>/` — each feature owns its folder: a `*Page.tsx` entry where applicable, plus its local components. + - `main/` — `MainPage.tsx`, `MainHeader.tsx`, `MainConnectionStatusSwitch.tsx` (connect toggle + the `startLogin` SSO orchestrator), `MainExitNodeSwitcher.tsx`. + - `main/advanced/` — advanced-mode-only surfaces: `Navigation.tsx` (Peers/Networks tab switch) plus `peers/` (`Peers.tsx`, `PeerDetailPanel.tsx`, `PeerFilters.tsx`) and `networks/` (`Networks.tsx`, `NetworkFilters.tsx`). There is no exit-nodes sub-module — exit-node state lives in `NetworksContext` and the UI is `MainExitNodeSwitcher` (shown in default mode too). + - `settings/` — `SettingsPage.tsx`, `SettingsNavigation.tsx`, `SettingsSection.tsx`, `SettingsSkeleton.tsx`, and the tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSecurity`, `SettingsSSH`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). The Profiles tab is `modules/profiles/ProfilesTab.tsx`. + - `profiles/` — `ProfileDropdown.tsx` (header), `ProfileCreationModal.tsx`, `ProfilesTab.tsx` (settings table), `ProfileAvatar.tsx`. Context in `contexts/ProfileContext.tsx`. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + `useManagementUrl`); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the `managementUrl` onto it (keyed by profile name, before switching), then switches. Row actions confirm via `useConfirm()`. + - `welcome/` — `WelcomeDialog.tsx` (orchestrator) + `WelcomeStepTray.tsx`, `WelcomeStepManagement.tsx`. The management step renders only when active profile is `"default"`, the profile email is empty, and the management URL is cloud-default-or-empty (`shouldShowManagementStep`). Self-hosted URL reachability is a soft warning (`useManagementUrl.checkManagementUrlReachable`) — the user can re-click Continue to proceed past a failed check. + - `login/` — `LoginWaitingForBrowserDialog.tsx` (SSO browser-wait window). + - `session/` — `SessionExpirationDialog.tsx`. + - `auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`. + - `error/` — `ErrorDialog.tsx`. +- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`." +- `components/` — presentational primitives, no daemon RPCs, no router: + - `buttons/` — `Button`, `IconButton`. + - `inputs/` — `Input`, `SearchInput`. + - `dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation, usually driven via `useConfirm()`). + - `switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`. + - `typography/` — `Label`, `HelpText`. + - `empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`, `DaemonUnavailableOverlay`. + - Flat at root: `Badge`, `CopyToClipboard`, `DropdownMenu`, `SquareIcon`, `Tooltip`, `TruncatedText`, `VerticalTabs`, `LanguagePicker`, `ManagementServerSwitch`. +- `hooks/` — `useAutoSizeWindow.ts` (auto-size + `Window.Show` for auxiliary dialogs), `useKeyboardShortcut.ts`, `useManagementUrl.ts` (management-URL helpers: `CLOUD_MANAGEMENT_URL`, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`). +- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts` (`formatErrorMessage` + the `errorDialog({Title, Message})` window wrapper), `formatters.ts` (byte/latency/relative-time + `shortenDns`), `sorting.ts` (`reconcileOrder` — order-preserving list reconciliation shared by the peers/networks/profiles lists), `i18n.ts`, `logs.ts` (forwards console + uncaught errors to the Go log pipeline), `platform.ts` (`isMacOS`/`isWindows`), `welcome.ts`. +- `assets/` — fonts, logos, flags. ## Wails event bus -Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed payload> }`. The event name strings live next to their usage (no central registry on the TS side). +Subscribe with `Events.On(name, handler)`; the handler receives `{ data: <typed payload> }`. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each `Events.On` adds an emit-time fan-out. -| Event name (string) | Payload | Emitted by | Consumed by | +| Event name | Payload | Emitted by | Consumed by | |---|---|---|---| -| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `contexts/StatusContext` (`useStatus`) | -| `netbird:event` | `SystemEvent` | `services/peers.go toastStreamLoop` | Not currently subscribed on the TS side — Status is read via `useStatus().status.events` instead. The tray (Go) consumes it for OS notifications. | -| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `contexts/ProfileContext` refreshes so a tray-initiated switch paints in the React UI. | -| `netbird:update:available` | `UpdateAvailable` | `services/peers.go fanOutUpdateEvents` | Not directly subscribed on the TS side; `ClientVersionContext` derives `updateVersion` from `status.events` metadata instead. | -| `netbird:update:progress` | `UpdateProgress` | same | Drives the tray. UI side: `WindowManager.OpenInstallProgress` is what opens the install window; the React listener for `installing` flips lives in `ClientVersionContext`. | -| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `modules/auto-update/ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing`. | -| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `pages/main/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` | -| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); `pages/main/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()` when fired. No Go-side emitter today. | -| `netbird:settings:open` | `string` (tab id, e.g. `"general"`, `"profiles"`) | `services/windowmanager.go OpenSettings` (before Go calls `Show`) | `modules/settings/SettingsPage.tsx` — just `setActive(e.data)`. Reset-on-close is **not** driven by this event — see the `visibilitychange` listener in the same file. | +| `netbird:status` | `Status` | `services/peers.go` | `StatusContext` (the only subscriber) | +| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `ProfileContext` — refreshes so a tray-initiated switch paints in the UI | +| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing` | +| `netbird:settings:open` | `string` (tab id) | `services/windowmanager.go OpenSettings` (before `Show`) | `SettingsPage` — `setActive(e.data)`. Reset-on-close is the `visibilitychange` listener, not this event. | +| `netbird:preferences:changed` | `{ language }` | Go after `SetLanguage` / `SetViewMode` | `lib/i18n.ts` — calls `i18next.changeLanguage` so a flip from any window paints everywhere | +| `browser-login:cancel` | (none) | `LoginWaitingForBrowserDialog` Cancel button **or** Go on window close | `MainConnectionStatusSwitch`'s `startLogin()` to abort the in-flight `WaitSSOLogin` | +| `trigger-login` | (none) | `services.EventTriggerLogin` (reserved; no Go emitter today) | `MainConnectionStatusSwitch` subscribes and runs `startLogin()` | -If you wire a new daemon-event subscriber on the TS side, prefer subscribing once at the context level rather than per-screen — the Wails event bus is process-wide and each `Events.On` adds an emit-time fan-out. +`netbird:event`, `netbird:update:available`, and `netbird:update:progress` are emitted Go-side for the tray but **not** subscribed on the TS side — the UI derives the same info from `useStatus().status.events`. ## Contexts and state -State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`. +State that crosses screens/windows lives in context, each provider mounted exactly once. -- **`useStatus`** (`contexts/StatusContext.tsx`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. The provider owns a single `Peers.Get()` + `netbird:status` subscription and renders `<DaemonUnavailableOverlay/>`. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down. +- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. +- **`ProfileContext`** — `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` + a `refresh()`. +- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === 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 the mask. +- **`DebugBundleContext`** — 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. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`. +- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches: + 1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases. + 2. `available && enforced && !installing` — user-driven; `triggerUpdate` opens the install-progress window then calls `Update.Trigger()`. + 3. `available && enforced && installing` — daemon already installing; the flip auto-opens the install-progress window. +- **`NetworksContext`** — routed networks + exit nodes derived from `status.networks`; optimistic overrides for instant toggle feedback. **`PeerDetailContext`** — which peer detail panel is open in advanced view. **`NavSectionContext`** — the advanced-mode Peers/Networks tab selection. -- **`ProfileContext`** (`modules/profiles/`) — `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()`. +### View mode + no client-side persistence -- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` 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 `"**********"`. +`ViewModeProvider` (`contexts/ViewModeContext.tsx`, mounted in `MainPage`) owns `viewMode: "default" | "advanced"`, consumed via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH`: Default 380, Advanced 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is `setFrame:` (frame, incl. ~28px title bar) while initial `windowNew` uses `initWithContentRect:` (content). Passing a constant would chop ~28px off the content area on the first switch. `main.go` opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from `Preferences.Get()` on mount without triggering a resize. -- **`DebugBundleProvider` + `useDebugBundle`** (`contexts/DebugBundleContext.tsx`) — 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`. - -- **`ClientVersionContext`** (`modules/auto-update/`) — seeds from `Update.GetState()` and subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. **Three branches**: - 1. `available && !enforced` — download-only. `UpdateVersionCard` shows "Version X is available for download" + "Download installer" → opens GitHub releases. - 2. `available && enforced && !installing` — user-driven enforced. `UpdateVersionCard` shows "Version X is available for install" + "Install now" → `triggerUpdate` opens `/install-progress` window then calls `Update.Trigger()`. - 3. `available && enforced && installing` — daemon already installing (force-install). The `installing` flip auto-opens `/install-progress` via `WindowManager.OpenInstallProgress`. - -### Default/Advanced view + no client-side persistence - -The `ViewModeProvider` (`src/lib/viewMode.tsx`, mounted in `AppLayout`) owns a `viewMode: "default" | "advanced"` state and is consumed by `Header.tsx`'s "more" dropdown via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH` at the top of `viewMode.tsx`: Default = 380, Advanced = 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is implemented as `setFrame:` (frame, incl. ~28px title bar) while the initial `windowNew` uses `initWithContentRect:` (content). Passing a constant 640 would chop ~28px off the content area on the first switch and visually shift everything inside (the connect toggle is `justify-center` in a column that depends on the parent's height). Reusing the live height keeps content area stable across all switches. The view is persisted user-side (see Go-side `preferences.Store`): `main.go` opens the main window at the saved width so the user never sees a 380→900 flash on launch, and the provider hydrates its React state from `Preferences.Get()` in a mount effect (no resize triggered there — Go already sized it). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`). +**No `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job: settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`. ## Localisation (i18n) -Bootstrap lives in `src/lib/i18n.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) from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), 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. +Bootstrap in `src/lib/i18n.ts`, awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, glob-imports every bundle from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to `netbird:preferences:changed` so a flip from any window calls `i18next.changeLanguage` here. -**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go-side "unset" signal — `preferences.Store` no longer pre-fills a default). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each tag, and picks the first base code (`de` from `de-DE`) that has a shipped bundle — then calls `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back without re-detecting. If nothing matches (or the store is unreachable) the session falls through to `en`. From the second launch onward, the Go-side persisted value wins and detection is skipped. The tray (`localizer.go`) treats empty as English via its own fallback to `i18n.DefaultLanguage` so the first menu render before SetLanguage round-trips is still readable. +**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go "unset" signal). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each, and picks the first base code (`de` from `de-DE`) with a shipped bundle — then `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to `en`. From the second launch the persisted value wins. -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. - -**Usage in components.** Default to the hook: +**Usage.** Default to the hook: ```ts import { useTranslation } from "react-i18next"; const { t } = useTranslation(); -return <span>{t("settings.tabs.general")}</span>; -// with placeholders: -t("update.card.versionAvailable", { version: updateVersion }) +t("settings.tabs.general"); +t("update.card.versionAvailable", { version: updateVersion }); // placeholders ``` -For strings outside React (event handlers in modules, `Dialogs.Error` titles set from `useDebugBundle`, `useManagementUrl`, `ProfileContext`, `SettingsContext`) import the i18next instance directly: +Outside React (module-scope event handlers, error titles) import the instance directly: `import i18next from "@/lib/i18n"`. -```ts -import i18next from "@/i18n"; -await Dialogs.Error({ Title: i18next.t("settings.error.saveTitle"), Message: ... }); -``` +**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI). -**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: +**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json`, append the row to `_index.json`, and drop the matching `<code>.svg` into `src/assets/flags/1x1/` (source from the dashboard repo's same-name folder). `LanguagePicker.tsx` eager-globs that flags directory at build time, so **only check in flags for languages we actually ship**. `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale. -```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; -``` +**What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated. -Compare against the variable, never against an English literal. +## Login flow (`startLogin` in `MainConnectionStatusSwitch.tsx`) -**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "General", "description": "..." }` (not a bare string). `description` is translator context for Crowdin (read natively from the source file, ignored at runtime); only `en/common.json` carries descriptions, target bundles carry just `message`. `lib/i18n.ts` strips each entry down to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Adding a key: add `{ "message": "…", "description": "…" }` to `en/common.json` first (the fallback), then `{ "message": "…" }` to 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 `client/ui/i18n/locales/<code>/common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship** — `LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), 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/components/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/SessionExpiration/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`) - -The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence: +The SSO flow is a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence: 1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user. -2. If the daemon needs SSO (`needsSsoLogin`): - - `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window (Hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`). - - `LoginWaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top. - - `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first. - - On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`). +2. If SSO is needed (`needsSsoLogin`): + - `WindowManager.OpenBrowserLogin(uri)` opens the sign-in popup (hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`). + - The dialog fires `Connection.OpenURL(uri)` from its mount effect (done from the dialog, not `startLogin`, so the browser doesn't race the still-hidden popup). + - `Promise.race(WaitSSOLogin, browser-login:cancel)`. + - On cancel: cancel the in-flight `WaitSSOLogin` gRPC so the daemon drops the abandoned device code. 3. `Connection.Up({})` to bring the new session up. -Errors that aren't cancellations surface via `Dialogs.Error`. - -This is the only SSO entry point used by the polished Main UI. There is no `/login` route in `app.tsx`; if you add one, wire it up here rather than introducing a parallel SSO flow. - -## Components - -`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`. +`onSettled` (releasing the caller's React-level guard) fires the instant the flow ends — **before** the error dialog — never gated on the dialog. Errors that aren't cancellations surface via `errorDialog`. This is the only SSO entry point; there's no `/login` route — wire any new SSO trigger through here. ## Dialogs convention -**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow. +**Errors → `errorDialog({Title, Message})` from `src/lib/errors.ts`** (which also exports `formatErrorMessage`), never `Dialogs.*` from `@wailsio/runtime`. Despite the name it opens the custom always-on-top `/#/dialog/error` window via `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **`errorDialog()` resolves as soon as the window opens — it does not block until dismissed.** -Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time. +For **confirmations**, use `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` mounted at the provider level. Used by the Profiles tab and the management-server cloud switch. -For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`. +**Skip dialogs entirely** for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full rationale in `../CLAUDE.md`. ## Tailwind tokens -Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = `nb-gray-950`); `netbird` is brand orange (`#f68330`). 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/`. +Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background `nb-gray-950`); `netbird` is brand orange (`#f68330`). New code uses `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under `src/assets/fonts/`. ## Wails-specific quirks -- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, dialog wrappers like `ConfirmDialog`). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click. -- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod. -- **`Window.SetSize(w, h)`.** Called from `viewMode.tsx`'s `setViewMode` when the user flips the view-mode dropdown. Width comes from `VIEW_WIDTH` (380 / 900); height is read fresh from `Window.Size()` and re-passed, because Wails' macOS `windowSetSize` treats height as the frame (including title bar) while initial window creation treats it as content — re-asserting a constant would shrink the content area by one title-bar height. See the "Default/Advanced view" section above. -- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails). - -## Things in flight (don't be surprised by) - -- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table. -- **`modules/session/SessionExpirationDialog.tsx`** is the always-on-top auxiliary window for the SSO expiration warning. Triggered by the tray (`tray_session.go openSessionExpiration` at T-FinalWarningLead; `openSessionExtendFlow` from the "Expires in …" tray row). Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. When the countdown hits zero the same component flips to the "expired" copy (`sessionExpiration.expired*` keys). - -## Wails Go API reference - -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. +- **Window dragging.** Class `wails-draggable` on regions that should drag the OS window (headers, the Settings title strip, dialog wrappers). `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click. +- **Webview asset access.** Reference assets through Vite: `import url from "@/assets/.../foo.svg"`. Absolute filesystem paths don't work in dev or prod. +- **`Window.SetSize(w, h)`.** Called from `ViewModeContext`'s `setViewMode`. Height is read fresh from `Window.Size()` and re-passed — see the View mode section for why a constant would shrink the content area. +- **Main-window width.** Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (`MainPage` → `isWindows() ? 364 : 380`; see wails/wails#3260). +- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` (legal links) and the BrowserLogin "Try again". `SettingsAbout` has a `window.open` fallback for when Wails refuses (non-http schemes are rejected). ## Useful references -- `WAILS-API.md` (sibling) — full binding signatures, push events, and model shapes. +- `WAILS-API.md` (sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns `$CancellablePromise<T>` (`await` and ignore `.cancel()` in practice). Regenerate via `pnpm bindings` after any Go-side change. - Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`. - Wails v3 docs (may 403 from some clients): https://v3.wails.io/ -- `../CLAUDE.md` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals. +- `../CLAUDE.md` — Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals. diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index e3aa72735..d831e42dd 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -17,47 +17,45 @@ import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; -// Install console.* + uncaught-error forwarding before anything else runs -// so even init-time logs reach the Go log pipeline. +// Must run first so even init-time logs reach the Go log pipeline. initLogForwarding(); welcome(); Promise.all([ 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); }), initPlatform().catch((e) => { console.error("platform init failed:", e); }), -]) - .finally(() => { - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( +]).finally(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> <SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}> <HashRouter> <Routes> <Route path="dialog"> - <Route path="browser-login" element={<LoginWaitingForBrowserDialog />} /> + <Route + path="browser-login" + element={<LoginWaitingForBrowserDialog />} + /> <Route path="install-progress" element={<UpdateInProgressDialog />} /> - <Route path="session-expiration" element={<SessionExpirationDialog />} /> + <Route + path="session-expiration" + element={<SessionExpirationDialog />} + /> <Route path="welcome" element={<WelcomeDialog />} /> <Route path="error" element={<ErrorDialog />} /> </Route> <Route element={<AppLayout />}> <Route index element={<MainPage />} /> <Route path="settings" element={<SettingsPage />} /> - <Route - path="*" - element={<Navigate to={"/"} replace />} - /> + <Route path="*" element={<Navigate to={"/"} replace />} /> </Route> </Routes> </HashRouter> </SkeletonTheme> </React.StrictMode>, - ); - }); + ); +}); diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index ca8f9275f..a87efadbc 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -5,12 +5,8 @@ import { cn } from "@/lib/cn"; export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger"; type Props = HTMLAttributes<HTMLSpanElement> & { - /** Visual color scheme. Defaults to `info` (sky), used as the - * "Active profile" indicator. */ variant?: BadgeVariant; - /** Optional leading lucide icon. */ icon?: ComponentType<LucideProps>; - /** Override icon size. Defaults to 10px to match the compact pill. */ iconSize?: number; }; @@ -23,10 +19,6 @@ const VARIANT_CLASSES: Record<BadgeVariant, string> = { danger: "bg-red-900 border border-red-700 text-red-200", }; -// Pill shape sized for inline use next to text. `top-px` nudges the badge -// down so its midline aligns with the surrounding text baseline; `leading-none` -// lets the small text sit flush in the pill without the line-height padding -// inflating it. export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge( { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest }, ref, diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 73703e462..40ac41ecb 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -1,9 +1,7 @@ -import { useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Check, Copy } from "lucide-react"; import { cn } from "@/lib/cn"; -// Static map — Tailwind JIT only picks up literal class names, so dynamic -// template strings would be invisible to it. const VARIANT_HOVER = { default: "group-hover/copy:[&_*]:text-nb-gray-300", bright: "group-hover/copy:[&_*]:text-nb-gray-200", @@ -19,10 +17,6 @@ type CopyToClipboardProps = { className?: string; iconClassName?: string; alwaysShowIcon?: boolean; - // variant picks the text colour the wrapped content fades into on hover. - // - "default" → nb-gray-300 (peer-details, settings, etc.) - // - "bright" → nb-gray-200 (deeper-surface contexts like the main - // connection card where text needs more lift) variant?: CopyToClipboardVariant; }; @@ -36,8 +30,15 @@ export const CopyToClipboard = ({ alwaysShowIcon = false, variant = "default", }: CopyToClipboardProps) => { - const wrapperRef = useRef<HTMLDivElement>(null); + const wrapperRef = useRef<HTMLButtonElement>(null); const [copied, setCopied] = useState(false); + const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -47,29 +48,25 @@ export const CopyToClipboard = ({ try { await navigator.clipboard.writeText(text); setCopied(true); - setTimeout(() => setCopied(false), 500); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 500); } catch { - // } }; return ( - <div + <button + type="button" ref={wrapperRef} onClick={handleClick} className={cn( - "inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable", + "inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left", className, )} > <span className={cn( "relative truncate min-w-0", - // [&_*] is Tailwind's arbitrary descendant variant: & is - // this element, _ is the CSS descendant combinator, * is - // every descendant. The generated selector has higher - // specificity than a child's own text-nb-gray-* class, so - // the hover colour wins the cascade. "[&_*]:transition-colors", VARIANT_HOVER[variant], )} @@ -105,6 +102,6 @@ export const CopyToClipboard = ({ )} /> </span> - </div> + </button> ); }; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 3d44bdda4..0a4660e43 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -3,7 +3,6 @@ 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 { errorDialog } from "@/lib/dialogs.ts"; import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react"; import { Preferences } from "@bindings/services"; import { LanguageCode, type Language } from "@bindings/i18n/models.js"; @@ -11,14 +10,9 @@ import { HelpText } from "@/components/typography/HelpText"; import { Label } from "@/components/typography/Label"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; -// Intentionally no flag icons here: flags represent countries, not -// languages (German is spoken across DE/AT/CH; English across US/UK/AU/ -// etc.). Each label shows the endonym followed by the englishName in -// parentheses when the two differ (e.g. "Deutsch (German)"), in both -// the trigger and the dropdown rows. -// See: https://www.flagsarenotlanguages.com/blog/ +// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ const labelFor = (lang: Language): string => lang.englishName && lang.englishName !== lang.displayName diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx index 4370e8147..496cea062 100644 --- a/client/ui/frontend/src/components/ManagementServerSwitch.tsx +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -7,10 +7,6 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts"; type Props = { value: ManagementMode; onChange: (mode: ManagementMode) => void; - // fullWidth stretches the segmented control to fill its container — - // the SettingsGeneral row uses the default (shrink-to-content) layout, - // the welcome dialog asks for the wide variant so the picker spans the - // narrow dialog width. fullWidth?: boolean; }; diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx index f951a002a..d7237a68d 100644 --- a/client/ui/frontend/src/components/SquareIcon.tsx +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -2,11 +2,6 @@ import { ComponentType } from "react"; import { LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; -// SquareIcon is the rounded-square icon tile used by dialog-style surfaces -// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide -// icon centered inside. The `variant` selects the semantic colour scheme — all -// variants keep the neutral dark tile + border; only the icon colour changes -// to match the action's severity. export type SquareIconVariant = "default" | "info" | "warning" | "danger"; const variantClass: Record<SquareIconVariant, string> = { diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index 4badd65e5..12787518d 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -12,11 +12,7 @@ type Props = { alignOffset?: number; interactive?: boolean; keepOpenOnClick?: boolean; - // Overrides the default tooltip-content chrome (background, padding, - // border, radius). Use when a richer body needs popover-style layout. contentClassName?: string; - // Ms to wait after pointer-leave before closing. Lets the user cross - // a gap between trigger and content without the tooltip snapping shut. closeDelay?: number; }; @@ -60,10 +56,7 @@ export const Tooltip = ({ }; return ( - <RTooltip.Provider - delayDuration={delayDuration} - disableHoverableContent={!interactive} - > + <RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}> <RTooltip.Root open={open} onOpenChange={handleOpenChange}> <RTooltip.Trigger asChild @@ -86,9 +79,7 @@ export const Tooltip = ({ alignOffset={alignOffset} onPointerEnter={interactive ? cancelClose : undefined} onPointerLeave={interactive ? scheduleClose : undefined} - onPointerDownOutside={ - interactive ? undefined : (e) => e.preventDefault() - } + onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()} className={cn( "z-50 select-none text-xs text-nb-gray-100 shadow-lg", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx index bf4e33bd2..5b2d2160c 100644 --- a/client/ui/frontend/src/components/TruncatedText.tsx +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -8,17 +8,7 @@ type Props = { delayDuration?: number; }; -// Renders text with `truncate`; measures scrollWidth vs clientWidth after -// layout and wraps in a Tooltip only when the text actually overflows. Avoids -// the "tooltip on hover even though everything fits" annoyance. The caller -// supplies the wrapper styling (font, max-width, etc.) via className — this -// component only owns the truncate + measure + tooltip behavior. -export const TruncatedText = ({ - text, - className, - tooltipContent, - delayDuration = 600, -}: Props) => { +export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { const ref = useRef<HTMLSpanElement>(null); const [overflowing, setOverflowing] = useState(false); diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 73f9d4a7e..43b6a523a 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -3,32 +3,32 @@ import * as Tabs from "@radix-ui/react-tabs"; import { LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; -const Root = forwardRef< - HTMLDivElement, - Omit<Tabs.TabsProps, "orientation"> ->(function VerticalTabsRoot({ className, ...props }, ref) { - return ( - <Tabs.Root - ref={ref} - orientation={"vertical"} - className={cn("flex flex-1 min-h-0", className)} - {...props} - /> - ); -}); - -const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>( - function VerticalTabsList({ className, ...props }, ref) { +const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>( + function VerticalTabsRoot({ className, ...props }, ref) { return ( - <Tabs.List + <Tabs.Root ref={ref} - className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)} + orientation={"vertical"} + className={cn("flex flex-1 min-h-0", className)} {...props} /> ); }, ); +const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList( + { className, ...props }, + ref, +) { + return ( + <Tabs.List + ref={ref} + className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)} + {...props} + /> + ); +}); + type TriggerProps = Tabs.TabsTriggerProps & { icon: ComponentType<LucideProps>; title: string; @@ -36,54 +36,47 @@ type TriggerProps = Tabs.TabsTriggerProps & { adornment?: ReactNode; }; -const Trigger = forwardRef<HTMLButtonElement, TriggerProps>( - function VerticalTabsTrigger( - { icon: Icon, title, iconSize = 16, adornment, className, ...props }, - ref, - ) { - return ( - <Tabs.Trigger - ref={ref} +const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger( + { icon: Icon, title, iconSize = 16, adornment, className, ...props }, + ref, +) { + return ( + <Tabs.Trigger + ref={ref} + className={cn( + "group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left", + "transition-colors duration-150", + "data-[state=active]:bg-nb-gray-930", + "data-[state=inactive]:hover:bg-nb-gray-935", + className, + )} + {...props} + > + <Icon + size={iconSize} className={cn( - "group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left", - "transition-colors duration-150", - "data-[state=active]:bg-nb-gray-930", - "data-[state=inactive]:hover:bg-nb-gray-935", - className, + "shrink-0 ml-2 transition-colors duration-150", + "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", )} - {...props} - > - <Icon - size={iconSize} - className={cn( - "shrink-0 ml-2 transition-colors duration-150", - "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", - )} - /> - <h2 - className={cn( - "font-medium text-sm truncate min-w-0 transition-colors duration-150", - "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", - )} - > - {title} - </h2> - {adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>} - </Tabs.Trigger> - ); - }, -); - -const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>( - function VerticalTabsContent({ className, ...props }, ref) { - return ( - <Tabs.Content - ref={ref} - className={cn("outline-none", className)} - {...props} /> - ); - }, -); + <h2 + className={cn( + "font-medium text-sm truncate min-w-0 transition-colors duration-150", + "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", + )} + > + {title} + </h2> + {adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>} + </Tabs.Trigger> + ); +}); + +const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent( + { className, ...props }, + ref, +) { + return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />; +}); export const VerticalTabs = Object.assign(Root, { List, Trigger, Content }); diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 02b009203..40dd8b1cc 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -1,6 +1,6 @@ import { cva, VariantProps } from "class-variance-authority"; import { Check, Copy, Loader2 } from "lucide-react"; -import { ButtonHTMLAttributes, forwardRef, useState } from "react"; +import { ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/cn"; @@ -10,9 +10,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar disabled?: boolean; stopPropagation?: boolean; copy?: string; - // When true, the content is replaced by a centered spinner while keeping - // the button's rendered width/height (the content stays in the layout, - // just hidden). Also disables the button. loading?: boolean; } @@ -134,6 +131,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button ref, ) { const [copied, setCopied] = useState(false); + const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); const iconSize = size === "xs" ? 12 : 14; return ( <button @@ -156,7 +160,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button .writeText(copy) .then(() => { setCopied(true); - setTimeout(() => setCopied(false), 1500); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); }) .catch(() => {}); } diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index 161944e1d..2bf35987e 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -2,15 +2,6 @@ import { ReactNode, forwardRef } from "react"; import { cn } from "@/lib/cn.ts"; import { isMacOS } from "@/lib/platform.ts"; -// ConfirmDialog is the shared layout wrapper used by dialog-style window -// surfaces (SessionExpiration, …). Purely a layout -// primitive — callers compose the contents (SquareIcon, DialogHeading, -// DialogDescription, DialogActions) so each dialog can tweak its own -// internal structure without growing the ConfirmDialog API. -// -// Callers that mount the dialog inside its own Wails window pair this -// with useAutoSizeWindow by forwarding the returned ref onto the content -// wrapper so the window height tracks the rendered content. type ConfirmDialogProps = { children: ReactNode; }; diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 1ff029714..aa34b26b9 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -6,25 +6,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading"; import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogActions } from "@/components/dialog/DialogActions"; -// ConfirmModal is the shared in-app confirmation modal — a left-aligned -// title + (optionally multi-line) description with Cancel / confirm buttons -// in the footer. It's the in-window counterpart to a native confirm dialog. -// -// Most call sites should not render this directly: use the imperative -// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a -// single instance at the provider level. Render ConfirmModal yourself only -// when you need bespoke control over its open/busy lifecycle. type ConfirmModalProps = { open: boolean; title: ReactNode; description: ReactNode; - /** Confirm button label. */ confirmLabel: string; - /** Cancel button label; defaults to the shared "Cancel" string. */ cancelLabel?: string; - /** Use the destructive (red) confirm button variant. */ danger?: boolean; - /** Disable the buttons (and ignore dismiss) while an action runs. */ busy?: boolean; onConfirm: () => void; onCancel: () => void; @@ -43,9 +31,7 @@ export const ConfirmModal = ({ }: ConfirmModalProps) => { const { t } = useTranslation(); - // Retain the last shown content so it stays rendered through Radix's - // close animation instead of blanking out the instant the caller clears - // its state on close. + // Retain last content so it survives Radix's close animation. type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & { cancelLabel: string; }; diff --git a/client/ui/frontend/src/components/dialog/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx index 9c2659e9e..54619c913 100644 --- a/client/ui/frontend/src/components/dialog/DialogActions.tsx +++ b/client/ui/frontend/src/components/dialog/DialogActions.tsx @@ -1,21 +1,13 @@ import { ReactNode } from "react"; import { cn } from "@/lib/cn"; -// DialogActions wraps a vertical stack of Buttons inside a dialog surface. -// The wails-no-draggable class lets the user click the buttons even when -// the dialog window itself is draggable from any background region. type DialogActionsProps = { children: ReactNode; className?: string; }; export const DialogActions = ({ children, className }: DialogActionsProps) => ( - <div - className={cn( - "wails-no-draggable flex flex-col gap-3 w-full mx-auto", - className, - )} - > + <div className={cn("wails-no-draggable flex flex-col gap-3 w-full mx-auto", className)}> {children} </div> ); diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx index 68ae621ac..7d0392dee 100644 --- a/client/ui/frontend/src/components/dialog/DialogDescription.tsx +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -1,8 +1,6 @@ import { ReactNode } from "react"; import { cn } from "@/lib/cn"; -// DialogDescription is the supporting description text rendered under a -// DialogHeading inside ConfirmDialog (and similar dialog surfaces). type DialogAlign = "left" | "center" | "right"; const alignClass: Record<DialogAlign, string> = { @@ -17,18 +15,12 @@ type DialogDescriptionProps = { align?: DialogAlign; }; -export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => ( - // w-full for the same reason DialogHeading carries it — see the - // comment there. The default text-center remains visually identical - // to before; left/right alignment now anchors to the dialog content - // edge instead of collapsing to no-op on a content-width box. - <p - className={cn( - "w-full text-sm text-nb-gray-300 select-none", - alignClass[align], - className, - )} - > +export const DialogDescription = ({ + children, + className, + align = "center", +}: DialogDescriptionProps) => ( + <p className={cn("w-full text-sm text-nb-gray-300 select-none", alignClass[align], className)}> {children} </p> ); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index 4b5e3fa33..431bfc052 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -1,9 +1,6 @@ import { ReactNode } from "react"; import { cn } from "@/lib/cn"; -// DialogHeading is the title text used inside ConfirmDialog (and any other -// dialog-style surface with the same shape). Pair with DialogDescription -// for the standard title/description stack. type DialogAlign = "left" | "center" | "right"; const alignClass: Record<DialogAlign, string> = { @@ -19,12 +16,6 @@ type DialogHeadingProps = { }; export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => ( - // w-full so the alignClass actually has a box to anchor against. - // The wrapping <p> defaulted to content width inside a flex column, - // which made `text-left` a no-op (nothing to push the text away - // from). Stretching the element is invisible for the default - // text-center case (center of content == center of box) and lets - // text-left/right line up with the dialog's content edge. <p className={cn( "w-full text-base font-semibold text-nb-gray-50 select-none", diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx index 12e84df88..fee9049b5 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -7,7 +7,7 @@ import { useStatus } from "@/contexts/StatusContext.tsx"; const DOCS_URL = "https://docs.netbird.io/how-to/installation"; function openUrl(url: string) { - void Browser.OpenURL(url).catch(() => window.open(url, "_blank")); + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); } export const DaemonUnavailableOverlay = () => { @@ -21,10 +21,6 @@ export const DaemonUnavailableOverlay = () => { className={ "fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable" } - onKeyDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} > <div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}> <div diff --git a/client/ui/frontend/src/components/empty-state/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx index f4b312339..774ed8e90 100644 --- a/client/ui/frontend/src/components/empty-state/EmptyState.tsx +++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx @@ -3,6 +3,10 @@ import { LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; import { SquareIcon } from "@/components/SquareIcon"; +// Knob to shift the centered main-window content up/down together. +export const CONTENT_VERTICAL_OFFSET = "-1.4rem"; +export const contentTop = (base: string) => `calc(${base} + ${CONTENT_VERTICAL_OFFSET})`; + type Props = { icon: ComponentType<LucideProps>; title: string; @@ -15,8 +19,9 @@ export const EmptyState = ({ icon, title, description, className }: Props) => { <div className={cn("py-12 text-center", className)}> <div className={ - "flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]" + "flex flex-col items-center justify-start max-w-sm mx-auto relative" } + style={{ top: contentTop("7.8rem") }} > <SquareIcon icon={icon} className={"mb-3"} /> <p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p> diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index c7aa2645b..f5f538e76 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -1,6 +1,14 @@ import { cva, VariantProps } from "class-variance-authority"; import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; -import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react"; +import { + forwardRef, + InputHTMLAttributes, + ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/cn"; import { Label } from "@/components/typography/Label"; @@ -14,9 +22,6 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input maxWidthClass?: string; icon?: ReactNode; error?: string; - // A soft, non-blocking caveat rendered in orange (vs. error's red). Used - // e.g. for "couldn't reach this server" where the value is syntactically - // fine and the user may still proceed. `error` takes precedence. warning?: string; prefixClassName?: string; showPasswordToggle?: boolean; @@ -52,6 +57,151 @@ const inputVariants = cva("", { }, }); +function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number { + const stepAttr = el.step === "" ? 1 : Number(el.step); + const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1; + const min = el.min === "" ? -Infinity : Number(el.min); + const max = el.max === "" ? Infinity : Number(el.max); + const current = el.value === "" ? 0 : Number(el.value); + let next = (Number.isFinite(current) ? current : 0) + delta * step; + if (next < min) next = min; + if (next > max) next = max; + return next; +} + +function buildInputClassName( + opts: Readonly<{ + variant: InputVariants["variant"]; + hasCustomPrefix: boolean; + hasSuffix: boolean; + hasIcon: boolean; + readOnly?: boolean; + showStepper: boolean; + className?: string; + }>, +): string { + return cn( + inputVariants({ variant: opts.variant }), + "flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text", + "file:bg-transparent file:text-sm file:font-medium file:border-0", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-40", + opts.hasCustomPrefix && "!border-l-0 !rounded-l-none", + opts.hasSuffix && "!pr-9", + opts.hasIcon && "!pl-10", + "border", + opts.readOnly && "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800", + opts.showStepper && + "!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]", + opts.className, + ); +} + +function InputAffix({ + content, + error, + disabled, + className, +}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) { + return ( + <div + className={cn( + inputVariants({ prefixSuffixVariant: error ? "error" : "default" }), + "flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm", + "border items-center whitespace-nowrap", + disabled && "opacity-40", + className, + )} + > + {content} + </div> + ); +} + +function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) { + return ( + <div + className={cn( + "absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]", + disabled && "opacity-40", + )} + > + {icon} + </div> + ); +} + +function InputSuffixSlot({ + suffix, + disabled, +}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) { + return ( + <div + className={cn( + "absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none", + disabled && "opacity-30", + )} + > + {suffix} + </div> + ); +} + +function NumberStepper({ + error, + disabled, + onStep, +}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) { + const { t } = useTranslation(); + return ( + <div + className={cn( + "flex flex-col h-[40px] shrink-0 overflow-hidden", + "border border-l-0 rounded-r-md", + "border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900", + error && "dark:border-red-500", + disabled && "opacity-40 pointer-events-none", + )} + > + <button + type="button" + tabIndex={-1} + aria-label={t("common.increase")} + onClick={() => onStep(1)} + className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default" + > + <ChevronUp size={12} /> + </button> + <button + type="button" + tabIndex={-1} + aria-label={t("common.decrease")} + onClick={() => onStep(-1)} + className={cn( + "flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default", + "border-t border-neutral-200 dark:border-nb-gray-700", + )} + > + <ChevronDown size={12} /> + </button> + </div> + ); +} + +function FieldMessage({ error, warning }: Readonly<{ error?: string; warning?: string }>) { + if (!error && !warning) return null; + return ( + <span + className={cn( + "text-xs mt-2 inline-flex items-center gap-1", + error ? "text-red-500" : "text-orange-400", + )} + > + {error ?? warning} + </span> + ); +} + export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( { className, @@ -82,28 +232,29 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( const reactId = useId(); const inputId = id ?? (label ? `input-${reactId}` : undefined); + const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const internalRef = useRef<HTMLInputElement | null>(null); const setRefs = (el: HTMLInputElement | null) => { internalRef.current = el; if (typeof ref === "function") ref(el); - else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el; + else if (ref) ref.current = el; }; const stepBy = (delta: 1 | -1) => { const el = internalRef.current; if (!el || el.disabled || el.readOnly) return; const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, + globalThis.HTMLInputElement.prototype, "value", )?.set; - const stepAttr = el.step !== "" ? Number(el.step) : 1; - const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1; - const min = el.min !== "" ? Number(el.min) : -Infinity; - const max = el.max !== "" ? Number(el.max) : Infinity; - const current = el.value === "" ? 0 : Number(el.value); - let next = (Number.isFinite(current) ? current : 0) + delta * step; - if (next < min) next = min; - if (next > max) next = max; + const next = computeNextStepValue(el, delta); setter?.call(el, String(next)); el.dispatchEvent(new Event("input", { bubbles: true })); }; @@ -121,14 +272,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( ) : null; const onCopy = async () => { - const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? ""); + const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value); if (!text) return; try { await navigator.clipboard.writeText(text); setCopied(true); - setTimeout(() => setCopied(false), 1500); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); } catch { - // ignore } }; @@ -145,37 +296,33 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( const suffix = passwordToggle || copyToggle || customSuffix; const showStepper = isNumber; + const warningVariant = warning ? "warning" : variant; + const resolvedVariant = error ? "error" : warningVariant; + + const inputClassName = buildInputClassName({ + variant: resolvedVariant, + hasCustomPrefix: !!customPrefix, + hasSuffix: !!suffix, + hasIcon: !!icon, + readOnly: props.readOnly, + showStepper, + className, + }); return ( <div className="flex flex-col w-full min-w-0"> {label && <Label htmlFor={inputId}>{label}</Label>} <div className={cn("flex relative h-[40px] w-full", maxWidthClass)}> {customPrefix && ( - <div - className={cn( - inputVariants({ - prefixSuffixVariant: error ? "error" : "default", - }), - "flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm", - "border items-center whitespace-nowrap", - props.disabled && "opacity-40", - prefixClassName, - )} - > - {customPrefix} - </div> + <InputAffix + content={customPrefix} + error={error} + disabled={props.disabled} + className={prefixClassName} + /> )} - {icon && ( - <div - className={cn( - "absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]", - props.disabled && "opacity-40", - )} - > - {icon} - </div> - )} + {icon && <InputIconSlot icon={icon} disabled={props.disabled} />} <div className="relative flex flex-grow min-w-0"> <input @@ -183,82 +330,17 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( type={inputType} ref={setRefs} {...props} - className={cn( - inputVariants({ - variant: error ? "error" : warning ? "warning" : variant, - }), - "flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text", - "file:bg-transparent file:text-sm file:font-medium file:border-0", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", - "disabled:cursor-not-allowed disabled:opacity-40", - customPrefix && "!border-l-0 !rounded-l-none", - suffix && "!pr-9", - icon && "!pl-10", - "border", - props.readOnly && - "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800", - showStepper && - "!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]", - className, - )} + className={inputClassName} /> - {suffix && ( - <div - className={cn( - "absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none", - props.disabled && "opacity-30", - )} - > - {suffix} - </div> - )} + {suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />} </div> {showStepper && ( - <div - className={cn( - "flex flex-col h-[40px] shrink-0 overflow-hidden", - "border border-l-0 rounded-r-md", - "border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900", - error && "dark:border-red-500", - props.disabled && "opacity-40 pointer-events-none", - )} - > - <button - type="button" - tabIndex={-1} - aria-label={t("common.increase")} - onClick={() => stepBy(1)} - className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default" - > - <ChevronUp size={12} /> - </button> - <button - type="button" - tabIndex={-1} - aria-label={t("common.decrease")} - onClick={() => stepBy(-1)} - className={cn( - "flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default", - "border-t border-neutral-200 dark:border-nb-gray-700", - )} - > - <ChevronDown size={12} /> - </button> - </div> + <NumberStepper error={error} disabled={props.disabled} onStep={stepBy} /> )} </div> - {(error || warning) && ( - <span - className={cn( - "text-xs mt-2 inline-flex items-center gap-1", - error ? "text-red-500" : "text-orange-400", - )} - > - {error ?? warning} - </span> - )} + <FieldMessage error={error} warning={warning} /> </div> ); }); diff --git a/client/ui/frontend/src/components/inputs/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx index 97e07dcc5..e2f9e79d2 100644 --- a/client/ui/frontend/src/components/inputs/SearchInput.tsx +++ b/client/ui/frontend/src/components/inputs/SearchInput.tsx @@ -7,49 +7,39 @@ type Props = InputHTMLAttributes<HTMLInputElement> & { shortcut?: ReactNode; }; -export const SearchInput = forwardRef<HTMLInputElement, Props>( - function SearchInput( - { iconSize = 16, className, disabled, shortcut, ...props }, - ref, - ) { - return ( - <div +export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput( + { iconSize = 16, className, disabled, shortcut, ...props }, + ref, +) { + return ( + <div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}> + <SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} /> + <input + ref={ref} + type={"text"} + disabled={disabled} + {...props} className={cn( - "flex items-center gap-2 px-1 h-10", - disabled && "opacity-50", + "w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400", + "outline-none border-none", + disabled && "cursor-not-allowed", + className, )} - > - <SearchIcon - size={iconSize} - className={"text-nb-gray-300 shrink-0"} - /> - <input - ref={ref} - type={"text"} - disabled={disabled} - {...props} + /> + {shortcut && ( + <span className={cn( - "w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400", - "outline-none border-none", - disabled && "cursor-not-allowed", - className, + "shrink-0 select-none", + "inline-flex items-center justify-center", + "h-5 min-w-[20px] px-1.5 rounded", + "border border-nb-gray-850 bg-nb-gray-920", + "text-[10px] font-medium text-nb-gray-400", + "wails-no-draggable", )} - /> - {shortcut && ( - <span - className={cn( - "shrink-0 select-none", - "inline-flex items-center justify-center", - "h-5 min-w-[20px] px-1.5 rounded", - "border border-nb-gray-850 bg-nb-gray-920", - "text-[10px] font-medium text-nb-gray-400", - "wails-no-draggable", - )} - > - {shortcut} - </span> - )} - </div> - ); - }, -); + > + {shortcut} + </span> + )} + </div> + ); +}); diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx index 5d92883d0..8464a6639 100644 --- a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -31,39 +31,27 @@ export default function FancyToggleSwitch({ labelClassName, textWrapperClassName = "max-w-lg", }: Readonly<Props>) { + const childrenRef = React.useRef<HTMLDivElement>(null); + if (loading) { - // Match the global SkeletonTheme in app.tsx (#25282d base / - // #33373e highlight) so the loading row blends in with - // SettingsSkeleton. box-decoration-clone gives every wrapped line - // of text its own rounded corners instead of just the first/last. const shimmer = "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse"; return ( - <div - className={cn("inline-block text-left w-full", className)} - aria-busy - > + <div className={cn("inline-block text-left w-full", className)} aria-busy> <div className={"flex justify-between gap-10"}> <div className={cn(textWrapperClassName)}> <Label className={labelClassName}> <span className={shimmer}>{label}</span> </Label> <HelpText margin={false}> - <span - className={cn( - shimmer, - "text-[0.6rem] leading-relaxed", - )} - > + <span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}> {helpText} </span> </HelpText> </div> <div className={"mt-2 pr-1"}> <div - className={ - "h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse" - } + className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"} /> </div> </div> @@ -71,16 +59,19 @@ export default function FancyToggleSwitch({ ); } - const handleToggle = () => { - if (disabled) return; + const fromChildren = (target: EventTarget | null) => + target instanceof Node && childrenRef.current?.contains(target); + + const handleToggle = (event: React.MouseEvent) => { + if (disabled || fromChildren(event.target)) return; onChange(!value); }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (disabled) return; + if (disabled || fromChildren(event.target)) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - handleToggle(); + onChange(!value); } }; @@ -108,7 +99,7 @@ export default function FancyToggleSwitch({ </div> </div> {children && value ? ( - <div className="mt-4" onClick={(e) => e.stopPropagation()}> + <div className="mt-4" ref={childrenRef}> {children} </div> ) : null} diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx index 7c13837ad..a0324254b 100644 --- a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx +++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx @@ -1,11 +1,10 @@ import * as RadioGroup from "@radix-ui/react-radio-group"; -import { createContext, ReactNode, useContext, useId } from "react"; +import { createContext, ReactNode, useContext, useId, useMemo } from "react"; import { cn } from "@/lib/cn"; type SwitchItemGroupContextValue = { value: string; layoutId: string; - disabled: boolean; }; const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null); @@ -34,9 +33,10 @@ export const SwitchItemGroup = ({ disabled = false, }: Props) => { const layoutId = useId(); + const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); return ( - <SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}> + <SwitchItemGroupContext.Provider value={contextValue}> <RadioGroup.Root value={value} onValueChange={onChange} diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx index c62ab496a..5c093eba4 100644 --- a/client/ui/frontend/src/components/typography/Label.tsx +++ b/client/ui/frontend/src/components/typography/Label.tsx @@ -27,11 +27,7 @@ export const Label = forwardRef<HTMLElement, LabelProps>(function Label( } return ( - <LabelPrimitive.Root - ref={ref as Ref<HTMLLabelElement>} - className={classes} - {...props} - > + <LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}> {children} </LabelPrimitive.Root> ); diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx index c3de09aba..c0699a9ee 100644 --- a/client/ui/frontend/src/contexts/ClientVersionContext.tsx +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -9,18 +9,12 @@ import { type ReactNode, } from "react"; import { Events } from "@wailsio/runtime"; -import { errorDialog } from "@/lib/dialogs.ts"; - import { Update as UpdateSvc, WindowManager } from "@bindings/services"; import type { State as UpdateState } from "@bindings/updater/models.js"; import i18next from "@/lib/i18n"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; -// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and -// (for Trigger) handled by the install window's polling-grace branch; a -// second popup on top of those is pure noise. Every Update RPC routes -// through the shared gRPC conn, so the Unavailable code is the marker. const isDaemonUnavailable = (e: unknown): boolean => { const msg = e instanceof Error ? e.message : String(e); return msg.includes("code = Unavailable"); @@ -81,9 +75,6 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => }; }, []); - // Force-install branch: daemon's progress_window:show flipped installing - // to true while the UI was idle. Open the install window so the user - // sees the progress UI without having to click anything. const prevInstallingRef = useRef(false); useEffect(() => { if (state.installing && !prevInstallingRef.current) { @@ -92,19 +83,11 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => prevInstallingRef.current = state.installing; }, [state.installing, state.version]); - // Enforced user-driven branch: kick Trigger() in the background, then - // hand off to the install window. The window owns the polling loop and - // the final Quit() — this provider just fires the trigger. const triggerUpdate = useCallback(() => { setUpdating(true); WindowManager.OpenInstallProgress(state.version || "").catch(console.error); UpdateSvc.Trigger() .catch(async (e) => { - // The daemon may already be down (force-install branch raced - // us). The install window's polling loop handles that case. - // Anything else is a real failure — close the install window - // (otherwise it spins forever on a daemon that won't ever - // produce a result) and surface the error. if (isDaemonUnavailable(e)) return; WindowManager.CloseInstallProgress().catch(console.error); await errorDialog({ @@ -127,9 +110,5 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => [state, triggerUpdate, updating], ); - return ( - <ClientVersionContext.Provider value={value}> - {children} - </ClientVersionContext.Provider> - ); + return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>; }; diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx index 9e0f79957..574a9051b 100644 --- a/client/ui/frontend/src/contexts/DebugBundleContext.tsx +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -1,18 +1,8 @@ -import { - createContext, - useContext, - useRef, - useState, - type ReactNode, -} from "react"; -import { errorDialog } from "@/lib/dialogs.ts"; -import { - Connection as ConnectionSvc, - Debug as DebugSvc, -} from "@bindings/services"; +import { createContext, useContext, useRef, useState, type ReactNode } from "react"; +import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services"; import type { DebugBundleResult } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; -import { formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; import { useProfile } from "@/contexts/ProfileContext.tsx"; const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; @@ -47,8 +37,64 @@ const sleep = (ms: number, signal: AbortSignal) => signal.addEventListener("abort", onAbort); }); -const isAbort = (e: unknown) => - e instanceof DOMException && e.name === "AbortError"; +const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError"; + +const throwIfAborted = (signal: AbortSignal) => { + if (signal.aborted) throw new DOMException("aborted", "AbortError"); +}; + +const setLogLevelBestEffort = async (level: string) => { + try { + await DebugSvc.SetLogLevel({ level }); + } catch { + // empty + } +}; + +type LevelState = { original: string; raised: boolean }; + +const runTracePhase = async ( + signal: AbortSignal, + level: LevelState, + setStage: (s: DebugStage) => void, + target: { profileName: string; username: string }, + traceMinutes: number, +) => { + setStage({ kind: "preparing-trace" }); + try { + const cur = await DebugSvc.GetLogLevel(); + if (cur?.level) level.original = cur.level; + } catch { + // empty + } + throwIfAborted(signal); + await DebugSvc.SetLogLevel({ level: "trace" }); + level.raised = true; + + throwIfAborted(signal); + setStage({ kind: "reconnecting" }); + try { + await ConnectionSvc.Down(); + } catch { + // empty + } + throwIfAborted(signal); + await ConnectionSvc.Up(target); + + const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60; + for (let remaining = totalSec; remaining > 0; remaining--) { + setStage({ kind: "capturing", remainingSec: remaining, totalSec }); + await sleep(1000, signal); + } + + setStage({ kind: "restoring-level" }); + try { + await DebugSvc.SetLogLevel({ level: level.original }); + level.raised = false; + } catch { + // empty + } +}; const useDebugBundle = () => { const { activeProfile, username } = useProfile(); @@ -75,66 +121,24 @@ const useDebugBundle = () => { const ctrl = new AbortController(); abortRef.current = ctrl; const signal = ctrl.signal; - const checkAbort = () => { - if (signal.aborted) - throw new DOMException("aborted", "AbortError"); - }; const uploadUrl = upload ? NETBIRD_UPLOAD_URL : ""; - let originalLevel = "info"; - let raisedLevel = false; + const level: LevelState = { original: "info", raised: false }; try { if (trace) { - setStage({ kind: "preparing-trace" }); - try { - const cur = await DebugSvc.GetLogLevel(); - if (cur?.level) originalLevel = cur.level; - } catch { - // best effort - } - checkAbort(); - await DebugSvc.SetLogLevel({ level: "trace" }); - raisedLevel = true; - - checkAbort(); - setStage({ kind: "reconnecting" }); - try { - await ConnectionSvc.Down(); - } catch { - // already down - } - checkAbort(); - await ConnectionSvc.Up({ - profileName: activeProfile, - username, - }); - - const totalSec = - Math.max(1, Math.min(30, traceMinutes)) * 60; - for (let remaining = totalSec; remaining > 0; remaining--) { - setStage({ - kind: "capturing", - remainingSec: remaining, - totalSec, - }); - await sleep(1000, signal); - } - - setStage({ kind: "restoring-level" }); - try { - await DebugSvc.SetLogLevel({ level: originalLevel }); - raisedLevel = false; - } catch { - // restore is best-effort - } + await runTracePhase( + signal, + level, + setStage, + { profileName: activeProfile, username }, + traceMinutes, + ); } - checkAbort(); + throwIfAborted(signal); setStage({ kind: "bundling" }); - const logFileCount = trace - ? TRACE_LOG_FILE_COUNT - : PLAIN_LOG_FILE_COUNT; + const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT; if (uploadUrl) setStage({ kind: "uploading" }); const result = await DebugSvc.Bundle({ @@ -143,7 +147,7 @@ const useDebugBundle = () => { uploadUrl, logFileCount, }); - checkAbort(); + throwIfAborted(signal); if (result.path) setLastBundlePath(result.path); setStage({ kind: "done", @@ -152,13 +156,7 @@ const useDebugBundle = () => { }); } catch (e) { if (isAbort(e)) { - if (raisedLevel) { - try { - await DebugSvc.SetLogLevel({ level: originalLevel }); - } catch { - // best effort - } - } + if (level.raised) await setLogLevelBestEffort(level.original); setStage({ kind: "idle" }); return; } @@ -174,7 +172,9 @@ const useDebugBundle = () => { const openBundleDir = () => { if (!lastBundlePath) return; - void DebugSvc.RevealFile(lastBundlePath).catch(() => {}); + DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) => + console.error("[DebugBundleContext] reveal failed", err), + ); }; return { @@ -204,19 +204,13 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null); export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { const value = useDebugBundle(); - return ( - <DebugBundleContext.Provider value={value}> - {children} - </DebugBundleContext.Provider> - ); + return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>; }; export const useDebugBundleContext = () => { const ctx = useContext(DebugBundleContext); if (!ctx) { - throw new Error( - "useDebugBundleContext must be used inside DebugBundleProvider", - ); + throw new Error("useDebugBundleContext must be used inside DebugBundleProvider"); } return ctx; }; diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx index ea43640be..ffb07ab25 100644 --- a/client/ui/frontend/src/contexts/DialogContext.tsx +++ b/client/ui/frontend/src/contexts/DialogContext.tsx @@ -1,24 +1,19 @@ -import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; import { ConfirmModal } from "@/components/dialog/ConfirmModal"; -// DialogContext exposes an imperative `confirm(...)` that resolves to a -// boolean — the in-app equivalent of a native confirmation dialog. The -// single <ConfirmModal/> lives here at the provider level, so call sites -// just `await confirm({...})` instead of each wiring up their own modal -// component + open/busy state. -// -// const confirm = useConfirm(); -// if (await confirm({ title, description, confirmLabel })) { …do it… } -// -// Mounted once (outermost in AppLayout) so it's available in every in-window -// route across both the main and settings windows. export type ConfirmOptions = { title: ReactNode; description: ReactNode; confirmLabel: string; - /** Defaults to the shared "Cancel" string inside ConfirmModal. */ cancelLabel?: string; - /** Use the destructive (red) confirm button variant. */ danger?: boolean; }; @@ -28,7 +23,7 @@ type DialogContextValue = { const DialogContext = createContext<DialogContextValue | null>(null); -export function DialogProvider({ children }: { children: ReactNode }) { +export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) { const [open, setOpen] = useState(false); const [options, setOptions] = useState<ConfirmOptions | null>(null); const resolverRef = useRef<((result: boolean) => void) | null>(null); @@ -41,17 +36,16 @@ export function DialogProvider({ children }: { children: ReactNode }) { }); }, []); - // Resolve the pending promise and start the close animation. The options - // stay in state so ConfirmModal still has content to render while it - // animates out. const settle = (result: boolean) => { resolverRef.current?.(result); resolverRef.current = null; setOpen(false); }; + const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]); + return ( - <DialogContext.Provider value={{ confirm }}> + <DialogContext.Provider value={value}> {children} <ConfirmModal open={open} diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx index f45dec4d6..c08a3c824 100644 --- a/client/ui/frontend/src/contexts/NavSectionContext.tsx +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; export type NavSection = "peers" | "networks"; @@ -12,18 +12,13 @@ const NavSectionContext = createContext<NavSectionContextValue | null>(null); export const useNavSection = (): NavSectionContextValue => { const ctx = useContext(NavSectionContext); if (!ctx) { - throw new Error( - "useNavSection must be used inside NavSectionProvider", - ); + throw new Error("useNavSection must be used inside NavSectionProvider"); } return ctx; }; export const NavSectionProvider = ({ children }: { children: ReactNode }) => { const [section, setSection] = useState<NavSection>("peers"); - return ( - <NavSectionContext.Provider value={{ section, setSection }}> - {children} - </NavSectionContext.Provider> - ); + const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]); + return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>; }; diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx index e928723e5..ef7231700 100644 --- a/client/ui/frontend/src/contexts/NetworksContext.tsx +++ b/client/ui/frontend/src/contexts/NetworksContext.tsx @@ -12,10 +12,9 @@ import { Networks as NetworksSvc } from "@bindings/services"; import type { Network } from "@bindings/services/models.js"; import { useStatus } from "@/contexts/StatusContext"; -// A range is treated as an exit-node candidate when any of its CIDRs is a -// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single -// comma-joined range string for one peer. -export const isDefaultRoute = (range: string): boolean => +// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node. +// The daemon may merge a v4+v6 pair into a single comma-joined range string. +export const isExitNode = (range: string): boolean => range.split(",").some((part) => { const trimmed = part.trim(); return trimmed === "0.0.0.0/0" || trimmed === "::/0"; @@ -45,33 +44,61 @@ export const useNetworks = () => { export const NetworksProvider = ({ children }: { children: ReactNode }) => { const { status } = useStatus(); const [routes, setRoutes] = useState<Network[]>([]); - // Optimistic overrides: id → expected `selected` value. Applied on top of - // the server-side `routes` so toggles paint instantly. Entries are cleared - // either when the next server snapshot agrees (success path) or when the - // RPC throws (rollback). Linear-style optimistic mutation tracking. const [pending, setPending] = useState<Map<string, boolean>>(new Map()); - // Mirror of `pending` for use inside async callbacks without re-binding - // them on every change. const pendingRef = useRef(pending); useEffect(() => { pendingRef.current = pending; }, [pending]); - const setPendingFor = useCallback((updates: Array<[string, boolean]>) => { - setPending((prev) => { - const next = new Map(prev); - for (const [id, sel] of updates) next.set(id, sel); - return next; - }); + // Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever. + const STUCK_OVERRIDE_MS = 4000; + const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); + + const clearTimer = useCallback((id: string) => { + const tid = timersRef.current.get(id); + if (tid !== undefined) { + clearTimeout(tid); + timersRef.current.delete(id); + } }, []); - const clearPendingFor = useCallback((ids: string[]) => { - setPending((prev) => { - if (ids.every((id) => !prev.has(id))) return prev; - const next = new Map(prev); - for (const id of ids) next.delete(id); - return next; - }); + const clearPendingFor = useCallback( + (ids: string[]) => { + for (const id of ids) clearTimer(id); + setPending((prev) => { + if (ids.every((id) => !prev.has(id))) return prev; + const next = new Map(prev); + for (const id of ids) next.delete(id); + return next; + }); + }, + [clearTimer], + ); + + const setPendingFor = useCallback( + (updates: Array<[string, boolean]>) => { + setPending((prev) => { + const next = new Map(prev); + for (const [id, sel] of updates) next.set(id, sel); + return next; + }); + for (const [id] of updates) { + clearTimer(id); + timersRef.current.set( + id, + setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS), + ); + } + }, + [clearTimer, clearPendingFor], + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const tid of timers.values()) clearTimeout(tid); + timers.clear(); + }; }, []); const refresh = useCallback(async () => { @@ -83,19 +110,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { } }, []); - // The daemon bumps networksRevision whenever the routed-network set or a - // selection changes (from any surface) and pushes it on the status stream. - // Refetch on every bump so the list stays live without polling — and on - // mount, since the revision is already defined by the time this provider - // renders (StatusProvider only mounts children once the daemon is reachable). const networksRevision = status?.networksRevision; useEffect(() => { - void refresh(); + refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err)); }, [refresh, networksRevision]); - // When the server snapshot agrees with a pending optimistic value, the - // mutation is confirmed — drop the override so the row tracks the server - // again. Runs whenever routes change. useEffect(() => { if (pendingRef.current.size === 0) return; const confirmed: string[] = []; @@ -116,13 +135,10 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { } else { await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false }); } - // Don't clear pending here — let the revision-driven refresh - // confirm via the snapshot-match effect. That avoids a flash - // back to old state if the refresh races the RPC return. + // Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back. await refresh(); } catch (e) { console.error(e); - // Roll back to the last server-observed value for each id. setPending((prev) => { const next = new Map(prev); for (const [id] of rollback) next.delete(id); @@ -143,9 +159,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { [mutate, setPendingFor], ); - // Batch toggle for the bottom-bar select-all switch. The daemon's - // Select/Deselect RPCs accept an ID list natively, so we don't fan out - // per-ID calls — one round-trip + one refresh. const setNetworksSelected = useCallback( async (ids: string[], selected: boolean) => { if (ids.length === 0) return; @@ -160,11 +173,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { [mutate, setPendingFor, routes], ); - // Exit nodes are mutually exclusive, but the daemon enforces that now — - // selecting one deselects the other exit nodes. Append so activating an - // exit node doesn't wipe the user's network-route selections. We also - // mirror that mutual-exclusion locally so the optimistic paint matches - // the daemon's eventual state. + // Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches. const toggleExitNode = useCallback( async (id: string, selected: boolean) => { const target = !selected; @@ -172,7 +181,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { const rollback: Array<[string, boolean]> = [[id, selected]]; if (target) { for (const r of routes) { - if (r.id !== id && isDefaultRoute(r.range) && r.selected) { + if (r.id !== id && isExitNode(r.range) && r.selected) { updates.push([r.id, false]); rollback.push([r.id, true]); } @@ -185,9 +194,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { ); const value = useMemo<NetworksContextValue>(() => { - // Apply pending overrides on top of the server snapshot. The override - // map is usually empty or tiny (one entry per in-flight toggle), so - // the per-route lookup is effectively free. const effective = pending.size === 0 ? routes @@ -197,8 +203,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { ? r : { ...r, selected: override }; }); - const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range)); - const exitNodes = effective.filter((r) => isDefaultRoute(r.range)); + const networkRoutes = effective.filter((r) => !isExitNode(r.range)); + const exitNodes = effective.filter((r) => isExitNode(r.range)); const activeExitNode = exitNodes.find((r) => r.selected) ?? null; return { routes: effective, diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx index 0cbe6f898..e20b2670d 100644 --- a/client/ui/frontend/src/contexts/PeerDetailContext.tsx +++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; import type { PeerStatus } from "@bindings/services/models.js"; type PeerDetailContextValue = { @@ -11,18 +11,13 @@ const PeerDetailContext = createContext<PeerDetailContextValue | null>(null); export const usePeerDetail = (): PeerDetailContextValue => { const ctx = useContext(PeerDetailContext); if (!ctx) { - throw new Error( - "usePeerDetail must be used inside PeerDetailProvider", - ); + throw new Error("usePeerDetail must be used inside PeerDetailProvider"); } return ctx; }; export const PeerDetailProvider = ({ children }: { children: ReactNode }) => { const [selected, setSelected] = useState<PeerStatus | null>(null); - return ( - <PeerDetailContext.Provider value={{ selected, setSelected }}> - {children} - </PeerDetailContext.Provider> - ); + const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]); + return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>; }; diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index c202a9b26..616655176 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -3,19 +3,15 @@ import { useCallback, useContext, useEffect, + useMemo, useState, type ReactNode, } from "react"; import { Events } from "@wailsio/runtime"; -import { errorDialog } from "@/lib/dialogs.ts"; -import { - Connection, - ProfileSwitcher, - 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"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; const EVENT_PROFILE_CHANGED = "netbird:profile:changed"; @@ -58,10 +54,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { setActiveProfile(active.profileName || "default"); setProfiles(list); } catch (e) { - // Daemon-down is already surfaced globally by - // DaemonUnavailableOverlay; a second popup on top of it is - // pure noise. Every profile RPC routes through the same gRPC - // conn, so the Unavailable code is the reliable marker. + // Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here. const msg = e instanceof Error ? e.message : String(e); if (msg.includes("code = Unavailable")) { return; @@ -76,16 +69,14 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { }, []); useEffect(() => { - void refresh(); + refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err)); }, [refresh]); useEffect(() => { - // The tray and other windows drive switches through the same - // ProfileSwitcher.SwitchActive RPC, which emits this event on success. - // Without the subscription, a tray-initiated switch leaves this - // window painting the old activeProfile until the next mount. const off = Events.On(EVENT_PROFILE_CHANGED, () => { - void refresh(); + refresh().catch((err: unknown) => + console.error("[ProfileContext] refresh failed", err), + ); }); return () => { off(); @@ -124,21 +115,30 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { [username, refresh], ); - return ( - <ProfileContext.Provider - value={{ - username, - activeProfile, - profiles, - loaded, - refresh, - switchProfile, - addProfile, - removeProfile, - logoutProfile, - }} - > - {children} - </ProfileContext.Provider> + const value = useMemo<ProfileContextValue>( + () => ({ + username, + activeProfile, + profiles, + loaded, + refresh, + switchProfile, + addProfile, + removeProfile, + logoutProfile, + }), + [ + username, + activeProfile, + profiles, + loaded, + refresh, + switchProfile, + addProfile, + removeProfile, + logoutProfile, + ], ); + + return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>; }; diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 276e32199..7222073bc 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -3,20 +3,22 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, type ReactNode, } from "react"; -import { errorDialog } from "@/lib/dialogs.ts"; import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; import { useProfile } from "@/contexts/ProfileContext.tsx"; import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; -import { formatErrorMessage as errorMessage } from "@/lib/errors.ts"; +import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts"; const SAVE_DEBOUNCE_MS = 400; +const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err); + export type AutostartState = { supported: boolean; enabled: boolean }; type SettingsContextValue = { @@ -47,9 +49,7 @@ export const useSettings = () => { export const useAutostartSetting = () => { const ctx = useContext(AutostartContext); if (!ctx) { - throw new Error( - "useAutostartSetting must be used inside AutostartSettingsProvider", - ); + throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider"); } return ctx; }; @@ -97,10 +97,7 @@ const useSettingsState = () => { const save = useCallback( async (next: Config) => { - // The daemon masks an existing PSK as "**********" in GetConfig. - // Sending the mask back round-trips it into the saved config and - // wgtypes.ParseKey fails on the next connect. Drop the mask so - // unrelated toggles don't corrupt the stored PSK. + // Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect). const { preSharedKey, ...rest } = next; try { await SettingsSvc.SetConfig({ @@ -126,7 +123,7 @@ const useSettingsState = () => { const next = { ...c, [k]: v }; if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { - void save(next); + save(next).catch(logSaveError); }, SAVE_DEBOUNCE_MS); return next; }); @@ -175,26 +172,19 @@ const useSettingsState = () => { }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, guiVersion, setField, saveField, saveFields, saveNow } = - useSettingsState(); + const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState(); + + const value = useMemo<SettingsContextValue | null>( + () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), + [config, guiVersion, setField, saveField, saveFields, saveNow], + ); return ( <div className={"flex-1 min-h-0 overflow-y-auto"}> - {!config ? ( - <SettingsSkeleton /> + {value ? ( + <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider> ) : ( - <SettingsContext.Provider - value={{ - config, - guiVersion, - setField, - saveField, - saveFields, - saveNow, - }} - > - {children} - </SettingsContext.Provider> + <SettingsSkeleton /> )} </div> ); @@ -232,9 +222,10 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) } }, []); - return ( - <AutostartContext.Provider value={{ autostart, setAutostartEnabled }}> - {children} - </AutostartContext.Provider> + const value = useMemo<AutostartContextValue>( + () => ({ autostart, setAutostartEnabled }), + [autostart, setAutostartEnabled], ); + + return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>; }; diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx index 45cd8dc15..0164d892e 100644 --- a/client/ui/frontend/src/contexts/StatusContext.tsx +++ b/client/ui/frontend/src/contexts/StatusContext.tsx @@ -1,21 +1,19 @@ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { Events } from "@wailsio/runtime"; import { DaemonFeed } from "@bindings/services"; -import type { Status } from "@bindings/services/models.js"; +import { Status } from "@bindings/services/models.js"; import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx"; const EVENT_STATUS = "netbird:status"; -// StatusContext is the single subscription point for the daemon status -// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener, -// and the synthetic DaemonUnavailable handling. The provider also renders -// the DaemonUnavailableOverlay so every layout that mounts it inherits the -// same blocker without re-importing the component. -// -// Boolean flags consumers should prefer over hand-rolled checks: -// - isReady first DaemonFeed.Get has resolved -// - isDaemonUnavailable ready and status === "DaemonUnavailable" -// - isDaemonAvailable ready and status !== "DaemonUnavailable" type StatusContextValue = { status: Status | null; error: string | null; @@ -45,20 +43,14 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => { setStatus(s); setError(null); } catch (e) { - // DaemonFeed.Get returns a gRPC error when the socket itself is - // unreachable (daemon not running, missing socket, etc.); only - // the streaming path synthesizes a DaemonUnavailable status. - // Synthesize one here too so the overlay paints on cold start - // without a daemon — otherwise the whole UI stays blank since - // `isReady` would never flip and StatusProvider's short-circuit - // wouldn't render either children or the overlay. - setStatus({ status: "DaemonUnavailable" } as Status); + // Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise). + setStatus(Status.createFrom({ status: "DaemonUnavailable" })); setError(String(e)); } }, []); useEffect(() => { - void refresh(); + refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err)); const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { setStatus(ev.data); setError(null); @@ -72,23 +64,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => { const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; const isDaemonAvailable = isReady && !isDaemonUnavailable; - // Don't mount children until the first DaemonFeed.Get has resolved and the - // daemon is reachable. Consumers (ProfileContext, SettingsContext, …) - // can then assume any daemon RPC they make at mount will reach the - // socket — no per-context availability gating. When the daemon flips - // back to unavailable the children unmount and remount fresh once it - // returns. + const value = useMemo<StatusContextValue>( + () => ({ + status, + error, + refresh, + isReady, + isDaemonUnavailable, + isDaemonAvailable, + }), + [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable], + ); + return ( - <StatusContext.Provider - value={{ - status, - error, - refresh, - isReady, - isDaemonUnavailable, - isDaemonAvailable, - }} - > + <StatusContext.Provider value={value}> {isDaemonAvailable && children} <DaemonUnavailableOverlay /> </StatusContext.Provider> diff --git a/client/ui/frontend/src/contexts/ViewModeContext.tsx b/client/ui/frontend/src/contexts/ViewModeContext.tsx index 828956c19..c551e7e95 100644 --- a/client/ui/frontend/src/contexts/ViewModeContext.tsx +++ b/client/ui/frontend/src/contexts/ViewModeContext.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, type ReactNode, @@ -13,13 +14,8 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js"; export type ViewMode = "default" | "advanced"; -// Window widths per view. Height stays at whatever the window was first -// created with — we deliberately don't pass a fixed height to -// Window.SetSize because Wails' macOS implementation interprets it as the -// outer frame (windowSetSize → setFrame:), while the initial creation -// uses initWithContentRect:. The two differ by one title-bar height -// (~28px), so re-asserting 640 here would chop ~28px off the content -// area on the first switch and visually shift everything inside. +// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px +// title bar) while creation is content, so re-asserting a constant chops the content on first switch. export const VIEW_WIDTH: Record<ViewMode, number> = { default: 380, advanced: 900, @@ -33,18 +29,12 @@ type ViewModeContextValue = { const ViewModeContext = createContext<ViewModeContextValue | null>(null); export const ViewModeProvider = ({ children }: { children: ReactNode }) => { - const [viewMode, setMode] = useState<ViewMode>("default"); - // Mirror of viewMode for dedup inside the async setViewMode without - // adding the state to the callback's dep array (which would re-create - // the callback on every change). + const [mode, setMode] = useState<ViewMode>("default"); const modeRef = useRef<ViewMode>("default"); - // Hydrate from the persisted preference. The Go side has already sized - // the main window to match (see main.go), so this only catches the - // React state and dropdown checkmark up — no resize is triggered here. useEffect(() => { let cancelled = false; - void Preferences.Get() + Preferences.Get() .then((prefs) => { if (cancelled) return; const saved = prefs?.viewMode as ViewMode | undefined; @@ -59,31 +49,30 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => { }; }, []); - // Resize the window BEFORE flipping React state — otherwise the new - // layout (e.g., advanced-mode right panel mounting) paints into a - // window that hasn't grown yet, causing a brief flex-overflow that - // wobbles the connect toggle's position. Cost: one IPC roundtrip - // (~30ms) before the dropdown checkmark updates. + // Resize before flipping React state, else the layout paints into a window that hasn't grown yet. const setViewMode = useCallback((mode: ViewMode) => { if (modeRef.current === mode) return; modeRef.current = mode; - void (async () => { - // Reuse the live frame height instead of asserting a - // constant — keeps content area stable across switches - // (see VIEW_WIDTH comment above). + (async () => { const size = await Window.Size().catch(() => null); const width = VIEW_WIDTH[mode]; const height = size?.height ?? 640; await Window.SetSize(width, height).catch(() => {}); setMode(mode); - void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {}); - })(); + const pref = + mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault; + Preferences.SetViewMode(pref).catch((err: unknown) => + console.error("[ViewModeContext] SetViewMode failed", err), + ); + })().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err)); }, []); - return ( - <ViewModeContext.Provider value={{ viewMode, setViewMode }}> - {children} - </ViewModeContext.Provider> + + const value = useMemo<ViewModeContextValue>( + () => ({ viewMode: mode, setViewMode }), + [mode, setViewMode], ); + + return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>; }; export const useViewMode = () => { diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css index b8539cff2..84ddfdcfe 100644 --- a/client/ui/frontend/src/globals.css +++ b/client/ui/frontend/src/globals.css @@ -1,15 +1,15 @@ @font-face { - font-family: "Inter Variable"; - font-style: normal; - font-weight: 100 900; - src: url("./assets/fonts/inter-variable.ttf") format("truetype"); + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/inter-variable.ttf") format("truetype"); } @font-face { - font-family: "JetBrains Mono Variable"; - font-style: normal; - font-weight: 100 800; - src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-weight: 100 800; + src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); } @tailwind base; @@ -19,8 +19,8 @@ html, body, #root { - height: 100%; - overflow: hidden; + height: 100%; + overflow: hidden; } /* @@ -32,14 +32,14 @@ body, * DEFAULT) here keeps things consistent regardless of the OS backdrop. */ body { - @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; + @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; } .wails-draggable { - --wails-draggable: drag; - cursor: default; + --wails-draggable: drag; + cursor: default; } .wails-no-draggable { - --wails-draggable: no-drag; + --wails-draggable: no-drag; } diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index cbb59aa9c..14a18308b 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -2,35 +2,8 @@ import { useLayoutEffect, useRef } from "react"; import { Window } from "@wailsio/runtime"; import i18next from "@/lib/i18n"; -// useAutoSizeWindow resizes the current Wails window so its height matches -// the measured height of the content element the returned ref is attached -// to. Width stays fixed (Wails has no "fit-content-width" notion and the -// dialog-style session windows want a stable horizontal footprint). -// -// On first measurement the hook also calls Window.Show()/Focus() — the -// Go-side opens the window with Hidden: true so the user never sees the -// initial placeholder size snap to the measured size. Subsequent -// measurements (content changes after mount) only adjust the size. -// -// Re-measures via ResizeObserver so adding/removing content (e.g. the -// SessionExpiration title swapping at countdown zero) keeps the chrome -// tight to the content with no scrollbar. -// -// Also re-measures on i18next `languageChanged`. The ResizeObserver in -// theory catches the same reflow when translated strings replace each -// other (DE/HU strings often wrap to more lines than EN), but in practice -// the observer can settle on a stale size before React's commit and the -// font's glyph metrics finish updating. An explicit double-rAF after the -// language flip guarantees the final layout is the one we measure. -// -// `ready` (default true) gates Window.SetSize + Window.Show. Pass false -// while the caller is still resolving its initial content (e.g. waiting -// on an async probe) so the window stays Hidden instead of briefly -// rendering placeholder padding at the wrong size — Linux/GNOME in -// particular paints whatever the frame ends up at, and a transient -// half-height frame can leak through. Flip ready=true once the real -// content is in the DOM; the effect re-runs, measures the final size, -// and shows the window. +// Sizes the current Wails window to the measured content height (keeping `width`), +// then shows it. Re-applies on content resize and language change. export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) { const ref = useRef<T | null>(null); useLayoutEffect(() => { @@ -39,39 +12,25 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b let shown = false; let raf1 = 0; let raf2 = 0; + const showOnce = () => { + if (shown) return; + shown = true; + Window.Show().catch(() => {}); + Window.Focus().catch(() => {}); + }; const apply = () => { if (!ready) return; const h = Math.ceil(el.getBoundingClientRect().height); if (h <= 0) return; - // Wails Window.SetSize takes the *frame* size on every platform - // (Windows: SetWindowPos, macOS: setFrame:, Linux: GTK frame). - // The OS title bar lives inside the frame, so we have to add the - // chrome height before calling SetSize, or the title bar eats - // pixels from the bottom and the rendered content gets clipped. - // - // window.outerHeight / window.innerHeight are useless here: - // WebView2 (and WKWebView) report the WebView's own outer == inner - // because the WebView itself has no chrome — the OS title bar is - // outside the WebView's window object entirely. The only way to - // recover the chrome height is to compare the OS frame height - // (Wails-side Window.Size()) against the WebView viewport - // (window.innerHeight). - void Window.Size() + // Window.SetSize takes the frame size, so add the OS title-bar height or content clips. + Window.Size() .then((frame) => { const chrome = Math.max(0, frame.height - window.innerHeight); return Window.SetSize(width, h + chrome); }) - .then(() => { - if (shown) return; - shown = true; - void Window.Show().catch(() => {}); - void Window.Focus().catch(() => {}); - }) + .then(showOnce) .catch(() => {}); }; - // Double rAF: first frame lands after React commits the new - // translated strings, second frame lands after the browser has - // recomputed layout, so apply() sees the final box. const scheduleApply = () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); diff --git a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts index 82f50016e..ea67d8375 100644 --- a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts +++ b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts @@ -1,22 +1,15 @@ import { useEffect } from "react"; +import { isMacOS } from "@/lib/platform"; export type Shortcut = { - key: string; // e.g. "k", "Escape", "/" - cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux) + key: string; + cmd?: boolean; shift?: boolean; alt?: boolean; - // When true (default), preventDefault is called on a match. preventDefault?: boolean; }; -// Listens for a keyboard shortcut on the window and invokes `callback` on -// match. Disable conditionally via `enabled` to avoid stealing keys while a -// dialog/panel is in the foreground. -export const useKeyboardShortcut = ( - shortcut: Shortcut, - callback: () => void, - enabled = true, -) => { +export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => { useEffect(() => { if (!enabled) return; const onKey = (e: KeyboardEvent) => { @@ -28,8 +21,8 @@ export const useKeyboardShortcut = ( if (shortcut.preventDefault !== false) e.preventDefault(); callback(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); }, [ shortcut.key, shortcut.cmd, @@ -41,16 +34,13 @@ export const useKeyboardShortcut = ( ]); }; -// True on macOS — use the ⌘ glyph; otherwise show "Ctrl". -export const isMac = - typeof navigator !== "undefined" && - /Mac|iPhone|iPad|iPod/i.test(navigator.platform); - export const formatShortcut = (shortcut: Shortcut): string => { + // navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac. + const mac = isMacOS(); const parts: string[] = []; - if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl"); - if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift"); - if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt"); + if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl"); + if (shortcut.shift) parts.push(mac ? "⇧" : "Shift"); + if (shortcut.alt) parts.push(mac ? "⌥" : "Alt"); parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key); - return parts.join(isMac ? "" : "+"); + return parts.join(mac ? "" : "+"); }; diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts index c764b4e30..6981efd7c 100644 --- a/client/ui/frontend/src/hooks/useManagementUrl.ts +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -5,21 +5,18 @@ import { useConfirm } from "@/contexts/DialogContext.tsx"; export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; -// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment]. -// Host is domain, localhost, or IPv4. Used for syntactic validation only — -// reachability is checked separately via checkManagementUrlReachable. +// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4. +// Syntactic validation only — reachability is checked via checkManagementUrlReachable. export const URL_PATTERN = new RegExp( - "^(https?:\\/\\/)?" + - "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" + - "((\\d{1,3}\\.){3}\\d{1,3}))" + - "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + - "(\\?[;&a-z\\d%_.~+=-]*)?" + - "(\\#[-a-z\\d_]*)?$", + String.raw`^(https?:\/\/)?` + + String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` + + String.raw`((\d{1,3}\.){3}\d{1,3}))` + + String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` + + String.raw`(\?[;&a-z\d%_.~+=-]*)?` + + String.raw`(\#[-a-z\d_]*)?$`, "i", ); -// normalizeManagementUrl prefixes an https:// scheme when the user omits -// it. Empty input stays empty. export function normalizeManagementUrl(input: string): string { const trimmed = input.trim(); if (!trimmed) return ""; @@ -27,28 +24,18 @@ export function normalizeManagementUrl(input: string): string { return `https://${trimmed}`; } -// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not -// touch the network. export function isValidManagementUrl(input: string): boolean { const trimmed = input.trim(); if (!trimmed) return false; return URL_PATTERN.test(trimmed); } -// isCloudManagementUrl reports whether the stored URL is the NetBird -// Cloud default (or an empty/unset URL, which the daemon also treats as -// cloud-defaulting on first boot). export function isCloudManagementUrl(url: string): boolean { if (!url || url.trim() === "") return true; return url === CLOUD_MANAGEMENT_URL; } -// checkManagementUrlReachable does a best-effort no-cors GET against the -// URL with a short timeout. A resolved fetch (even opaque) means DNS + -// TCP + TLS landed; any rejection (network error, DNS, abort) is treated -// as unreachable. Self-hosted deployments behind internal-only DNS or -// with self-signed certs may return false positives — callers should -// surface this as a soft warning, not a hard block. +// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block. export async function checkManagementUrlReachable( url: string, timeoutMs: number = 5000, @@ -80,15 +67,10 @@ export function useManagementUrl() { const { t } = useTranslation(); const confirm = useConfirm(); const { config, saveField } = useSettings(); - const [mode, setModeState] = useState<ManagementMode>( - modeFromUrl(config.managementUrl), - ); + const [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl)); const [url, setUrl] = useState( config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl, ); - // Self-hosted reachability soft-check, mirrored from the onboarding / - // profile-creation flows: a failed probe is a non-blocking orange warning, - // and a second Save with the same URL goes through regardless. const [checking, setChecking] = useState(false); const [unreachable, setUnreachable] = useState(false); @@ -99,19 +81,12 @@ export function useManagementUrl() { } }, [config.managementUrl]); - // Clear the stale warning whenever the target changes. useEffect(() => { setUnreachable(false); - }, [url, mode]); + }, [url, modeState]); const setMode = async (next: ManagementMode) => { - if ( - next === ManagementMode.Cloud && - config.managementUrl !== CLOUD_MANAGEMENT_URL - ) { - // 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 via the in-app modal before applying. + if (next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL) { const ok = await confirm({ title: t("settings.general.management.switchCloudTitle"), description: t("settings.general.management.switchCloudMessage"), @@ -119,7 +94,9 @@ export function useManagementUrl() { }); if (!ok) return; setModeState(ManagementMode.Cloud); - void saveField("managementUrl", CLOUD_MANAGEMENT_URL); + saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) => + console.error("save managementUrl failed", err), + ); return; } setModeState(next); @@ -127,19 +104,14 @@ export function useManagementUrl() { const normalizedUrl = normalizeManagementUrl(url); const urlValid = isValidManagementUrl(url); - const targetUrl = - mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; + const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; const dirty = targetUrl !== config.managementUrl; - const showError = - mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; - const canSave = dirty && (mode === ManagementMode.Cloud || urlValid); - const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; + const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; + const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid); + const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; const save = async () => { - // Self-hosted: probe the server first. A failed probe surfaces a soft - // warning and bails; a second Save (unreachable already set) skips the - // re-check and saves anyway, so the user can override a false negative. - if (mode === ManagementMode.SelfHosted && !unreachable) { + if (modeState === ManagementMode.SelfHosted && !unreachable) { setChecking(true); const reachable = await checkManagementUrlReachable(targetUrl); setChecking(false); @@ -153,7 +125,7 @@ export function useManagementUrl() { }; return { - mode, + mode: modeState, setMode, url, setUrl, diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index e4711d5e2..a13b02bfb 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -5,13 +5,6 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx"; import { ProfileProvider } from "@/contexts/ProfileContext.tsx"; import { DialogProvider } from "@/contexts/DialogContext.tsx"; -// Shared shell for every in-window route (main + settings). Owns the daemon- -// availability gate (via StatusProvider) and the providers every page needs. -// Order matters: SettingsContext depends on ProfileContext; ClientVersionContext -// reads StatusContext events. -// -// Page-specific surface (the main Header, the settings draggable strip, -// view-mode + nav-section providers) lives inside the page components, not here. export const AppLayout = () => { return ( <div className={"relative flex h-full flex-col"}> diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx index 590763633..df28315c2 100644 --- a/client/ui/frontend/src/layouts/AppRightPanel.tsx +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -9,9 +9,6 @@ type Props = { className?: string; }; -// iOS-style push transition: incoming pane slides in from the right while -// the outgoing pane shifts slightly left. Same easing on both sides so -// they feel like one motion. const PANEL_TRANSITION = { duration: 0.32, ease: [0.32, 0.72, 0, 1] as [number, number, number, number], diff --git a/client/ui/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts index a5ef19350..e6a8be071 100644 --- a/client/ui/frontend/src/lib/cn.ts +++ b/client/ui/frontend/src/lib/cn.ts @@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } diff --git a/client/ui/frontend/src/lib/dialogs.ts b/client/ui/frontend/src/lib/dialogs.ts deleted file mode 100644 index 457da42e1..000000000 --- a/client/ui/frontend/src/lib/dialogs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { WindowManager } from "@bindings/services"; - -// Options for errorDialog. Kept as a {Title, Message} object so the many -// existing call sites read unchanged after the switch from the native OS -// MessageBox to the custom window below. -export type ErrorDialogOptions = { - Title: string; - Message: string; -}; - -// errorDialog surfaces a user-actionable failure. It opens the custom, -// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx -// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any -// more, despite the name. -// -// Why the native box is gone: on Windows a native MessageBox attached to a -// parent window disables that window (WS_DISABLED) for its lifetime, and the -// main window's WindowClosing hook hides instead of closing — the two raced -// and could leave the main window unable to process its close (X) button after -// an error was shown. The custom window has its own chrome and never touches -// another window's enabled state, so that class of bug is gone (and with it -// the old `Detached: true` Windows-only workaround, plus the warning/info/ -// question wrappers that nothing called). -// -// Title and message must already be localised. Resolves as soon as the window -// is opened (it does not block until the user dismisses it), so `await`ing -// callers continue immediately after the dialog appears. -export function errorDialog(options: ErrorDialogOptions): Promise<void> { - return WindowManager.OpenError(options.Title, options.Message); -} diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts index 5bd2fae77..92496ba84 100644 --- a/client/ui/frontend/src/lib/errors.ts +++ b/client/ui/frontend/src/lib/errors.ts @@ -1,40 +1,34 @@ -// Shared error formatter for native dialog bodies. -// -// The Go service layer (client/ui/services/connection.go classifyDaemonError) -// wraps daemon errors in a ClientError struct exposed to the TS side as -// {code, short, long}. Short is already localised (Go reads the current -// preferences.Store language and resolves "error.<code>" via i18n.Bundle). -// Long always carries the unwrapped raw daemon message so the operator can -// see the JWT / mgm stack when the short text is too generic. -// -// Wails wraps Go-returned errors as Error({message, cause, kind}) where -// .message holds the JSON-stringified payload and the structured object -// lives on .cause — Object.keys(err) is empty in that case. We therefore -// probe .cause first, then fall back to parsing .message as JSON, then -// to plain .message text for callers that still hand us a raw Error. -const extractClientError = (e: unknown): { short?: string; long?: string } | null => { +import { WindowManager } from "@bindings/services"; + +type ClientError = { short?: string; long?: string }; + +const asClientError = (obj: object): ClientError => { + const withCause = obj as { cause?: unknown }; + if (withCause.cause && typeof withCause.cause === "object") { + return withCause.cause; + } + return obj; +}; + +const parseMessageJson = (message: unknown): ClientError | null => { + if (typeof message !== "string") return null; + const m = message.trim(); + if (!m.startsWith("{") || !m.endsWith("}")) return null; + try { + const parsed: unknown = JSON.parse(m); + if (parsed && typeof parsed === "object") return asClientError(parsed); + } catch { + } + return null; +}; + +const extractClientError = (e: unknown): ClientError | null => { if (!e || typeof e !== "object") return null; const withCause = e as { cause?: unknown; message?: unknown }; if (withCause.cause && typeof withCause.cause === "object") { - return withCause.cause as { short?: string; long?: string }; + return withCause.cause; } - if (typeof withCause.message === "string") { - const m = withCause.message.trim(); - if (m.startsWith("{") && m.endsWith("}")) { - try { - const parsed = JSON.parse(m); - if (parsed && typeof parsed === "object") { - if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") { - return parsed.cause as { short?: string; long?: string }; - } - return parsed as { short?: string; long?: string }; - } - } catch { - // not JSON — fall through to plain-message handling - } - } - } - return null; + return parseMessageJson(withCause.message); }; export const formatErrorMessage = (e: unknown): string => { @@ -50,3 +44,12 @@ export const formatErrorMessage = (e: unknown): string => { if (e instanceof Error) return e.message; return String(e); }; + +export type ErrorDialogOptions = { + Title: string; + Message: string; +}; + +export function errorDialog(options: ErrorDialogOptions): Promise<void> { + return WindowManager.OpenError(options.Title, options.Message); +} diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts index 5f4b1e528..231967a38 100644 --- a/client/ui/frontend/src/lib/formatters.ts +++ b/client/ui/frontend/src/lib/formatters.ts @@ -1,19 +1,11 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => { - try { - if (bytes === 0) return "0 B"; + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); - return ( - parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + - " " + - sizes[i] - ); - } catch { - return "0 B"; - } + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; }; export const latencyColor = (ms: number): string => { @@ -22,10 +14,7 @@ export const latencyColor = (ms: number): string => { return "text-yellow-400"; }; -export const formatRelative = ( - unixSeconds: number, - nowMs: number = Date.now(), -): string | null => { +export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => { if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null; const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds)); if (diff < 60) return `${diff}s ago`; @@ -34,13 +23,22 @@ export const formatRelative = ( return `${Math.floor(diff / 86400)}d ago`; }; -// shortenDns drops the domain suffix off a DNS name, returning just the -// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain -// is operator-configurable so we keep everything before the first dot rather -// than matching against a known suffix. The full DNS name still lands on -// the clipboard via the copy helpers' explicit message prop. +// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix. export const shortenDns = (fqdn: string | undefined | null): string => { if (!fqdn) return ""; const dot = fqdn.indexOf("."); return dot === -1 ? fqdn : fqdn.slice(0, dot); }; + +// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows. +export const formatRemaining = (seconds: number): string => { + const s = Math.max(0, Math.trunc(seconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const secs = s % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + return `${pad(minutes)}:${pad(secs)}`; +}; diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index d1fbb0e19..e71ef4ad6 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -5,21 +5,7 @@ import { Events } from "@wailsio/runtime"; import { Preferences, I18n } from "@bindings/services"; import { LanguageCode } from "@bindings/i18n/models.js"; -// Vite glob-imports every shipped bundle at build time. The locales tree -// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray -// and the React app share one JSON source. Adding a language only -// requires dropping the new folder there 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. `server.fs.allow` in -// `vite.config.ts` whitelists the parent directory so the dev server -// serves the JSON. -// -// Each bundle is Chrome-extension JSON: every key maps to -// `{ message, description? }`. `description` exists only so Crowdin can -// show translator context — it's stripped here and i18next sees a flat -// key->message map exactly as before. +// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups. type BundleEntry = { message: string; description?: string }; const bundleModules = import.meta.glob<Record<string, BundleEntry>>( "../../../i18n/locales/*/common.json", @@ -28,7 +14,7 @@ const bundleModules = import.meta.glob<Record<string, BundleEntry>>( const resources: Record<string, { common: Record<string, string> }> = {}; for (const path in bundleModules) { - const match = path.match(/locales\/([^/]+)\/common\.json$/); + const match = /locales\/([^/]+)\/common\.json$/.exec(path); if (match) { const entries = bundleModules[path]; const messages: Record<string, string> = {}; @@ -39,11 +25,6 @@ for (const path in bundleModules) { } } -// detectBrowserLanguage walks navigator.language + navigator.languages -// and returns the first shipped bundle that matches. We try an exact -// case-insensitive match first (so "en-GB" picks the en-GB bundle when -// shipped), then fall back to the base code ("de" from "de-DE"). Returns -// null when nothing matches, so the caller can fall back to English. function detectBrowserLanguage(available: string[]): string | null { const tags = [navigator.language, ...(navigator.languages ?? [])].filter( (tag): tag is string => typeof tag === "string" && tag.length > 0, @@ -59,13 +40,7 @@ function detectBrowserLanguage(available: string[]): string | null { return null; } -// initI18n is awaited from app.tsx before the first render. The Go-side -// preferences.Store returns an empty language code when no preference has -// ever been persisted — that's the signal for first-run browser-locale -// detection. We pick a shipped bundle that matches navigator.language / -// navigator.languages (falling back to "en" when nothing matches) and -// fire-and-forget the persist via Preferences.SetLanguage so subsequent -// launches read the value back without re-detecting. +// An empty persisted language code is the Go-side signal for first run. export async function initI18n(): Promise<void> { const available = Object.keys(resources); let language = "en"; @@ -79,13 +54,10 @@ export async function initI18n(): Promise<void> { language = detectBrowserLanguage(available) ?? "en"; } } catch { - // Daemon / preferences store unreachable — fall through with "en". } if (firstRun) { - // Fire-and-forget: the chosen language already drives this session; - // persisting just locks it in so the next launch skips detection. - void Preferences.SetLanguage(language as LanguageCode).catch(() => {}); + Preferences.SetLanguage(language as LanguageCode).catch(() => {}); } await i18next.use(initReactI18next).init({ @@ -102,14 +74,12 @@ export async function initI18n(): Promise<void> { 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); + i18next.changeLanguage(next).catch((err: unknown) => { + console.error("changeLanguage failed", err); + }); } }); } diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts index 5a0a0a322..81483056f 100644 --- a/client/ui/frontend/src/lib/logs.ts +++ b/client/ui/frontend/src/lib/logs.ts @@ -1,9 +1,5 @@ import { UILog } from "@bindings/services"; -// Forwards browser console output and uncaught errors into the Go logrus -// pipeline. Originals still fire, so DevTools is unchanged; the Go -// --log-level does the gating. - type Level = "trace" | "debug" | "info" | "warn" | "error"; const METHOD_LEVELS: Record<string, Level> = { @@ -15,10 +11,15 @@ const METHOD_LEVELS: Record<string, Level> = { error: "error", }; -// Sources whose output is noise and shouldn't be forwarded. const IGNORED_SOURCES = new Set(["welcome.ts"]); +const RATE_LIMIT = 50; +const RATE_WINDOW_MS = 1000; + let installed = false; +let inForward = false; +let windowStart = 0; +let windowCount = 0; function format(args: unknown[]): string { return args @@ -34,27 +35,35 @@ function format(args: unknown[]): string { .join(" "); } -// First stack frame outside this module as "<file>:<line>" (best-effort; -// minified prod stacks degrade to chunk names). function callerSource(): string { const stack = new Error().stack; if (!stack) return ""; for (const line of stack.split("\n").slice(1)) { if (line.includes("/logs.ts")) continue; - const m = line.match(/([^/\\() ]+\.[a-z]+):(\d+):\d+/i); + const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line); if (m) return `${m[1]}:${m[2]}`; } return ""; } function forward(level: Level, args: unknown[]) { + if (inForward) return; + inForward = true; try { + const now = Date.now(); + if (now - windowStart >= RATE_WINDOW_MS) { + windowStart = now; + windowCount = 0; + } + if (++windowCount > RATE_LIMIT) return; + const source = callerSource(); if (IGNORED_SOURCES.has(source.split(":")[0])) return; - // Fire-and-forget; don't touch console here (would recurse). - void UILog.Log(level, source, format(args)); + // Don't touch console here — it would recurse back into forward(). + UILog.Log(level, source, format(args)).catch(() => {}); } catch { - // swallow + } finally { + inForward = false; } } @@ -71,10 +80,10 @@ export function initLogForwarding() { }; } - window.addEventListener("error", (e) => { + globalThis.addEventListener("error", (e) => { forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); }); - window.addEventListener("unhandledrejection", (e) => { + globalThis.addEventListener("unhandledrejection", (e) => { forward("error", ["unhandled promise rejection:", e.reason]); }); } diff --git a/client/ui/frontend/src/lib/mock.ts b/client/ui/frontend/src/lib/mock.ts deleted file mode 100644 index a0789bfda..000000000 --- a/client/ui/frontend/src/lib/mock.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { Network, PeerStatus } from "@bindings/services/models.js"; - -// Flip to true to override the live daemon data in the Peers / Resources / -// Exit Nodes tabs with the hand-crafted fixtures below. The fixtures are -// designed to surface overflow / truncation bugs (very long FQDNs, ICE -// endpoints, domain lists, network ids) and exercise the three connection -// states + relayed / P2P / Rosenpass variants. -export const MOCK_ENABLED = false; - -// Replace `real` with `mock` when MOCK_ENABLED. Pulled out so call sites read -// as "use the live X, or the mock if enabled" without per-site if/else noise. -export const mockOr = <T>(real: T, mock: T): T => (MOCK_ENABLED ? mock : real); - -const SECONDS = (s: number) => Math.floor(Date.now() / 1000) - s; -const MINUTES = (m: number) => SECONDS(m * 60); -const HOURS = (h: number) => MINUTES(h * 60); -const DAYS = (d: number) => HOURS(d * 24); - -export const mockPeers: PeerStatus[] = [ - // Kitchen-sink peer — every optional field populated at the same time so - // the detail panel renders every Row (latency + bytes + handshake + ICE - // local/remote + relay + networks + rosenpass + pubkey). Useful for - // eyeballing layout at maximum density. - new PeerStatus({ - ip: "100.64.0.1", - ipv6: "fd00:dead:beef::1", - pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=", - connStatus: "Connected", - connStatusUpdateUnix: SECONDS(4), - relayed: true, - localIceCandidateType: "prflx", - remoteIceCandidateType: "relay", - localIceCandidateEndpoint: "[2001:db8:abcd:0012:0000:0000:0000:0001]:51820", - remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", - fqdn: "everything-maxed-out-kitchen-sink-peer-with-long-name.subdomain.dev.example-company.netbird.cloud", - bytesRx: 87_654_321_098, - bytesTx: 43_210_987_654, - latencyMs: 213, - relayAddress: - "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment/with/many/parts", - lastHandshakeUnix: SECONDS(3), - rosenpassEnabled: true, - networks: [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "100.100.0.0/16", - "10.50.50.0/24", - "2001:db8::/32", - "203.0.113.0/24", - "198.51.100.0/24", - ], - }), - new PeerStatus({ - ip: "100.64.0.2", - pubKey: "MockKeyAlpha000000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: MINUTES(7), - relayed: false, - localIceCandidateType: "host", - remoteIceCandidateType: "srflx", - localIceCandidateEndpoint: "192.168.1.10:51820", - remoteIceCandidateEndpoint: "203.0.113.42:51820", - fqdn: "alpha.netbird.cloud", - bytesRx: 12_345_678, - bytesTx: 9_876_543, - latencyMs: 18, - relayAddress: "", - lastHandshakeUnix: SECONDS(12), - rosenpassEnabled: false, - networks: ["10.0.0.0/24"], - }), - new PeerStatus({ - ip: "100.64.0.3", - pubKey: "MockKeyLongFqdn000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: HOURS(2), - relayed: false, - localIceCandidateType: "srflx", - remoteIceCandidateType: "srflx", - localIceCandidateEndpoint: "198.51.100.7:41234", - remoteIceCandidateEndpoint: "203.0.113.99:51820", - fqdn: "very-long-hostname-with-many-segments-to-test-overflow-handling.subdomain.example-company.netbird.cloud", - bytesRx: 4_500_000_000, - bytesTx: 1_200_000_000, - latencyMs: 87, - relayAddress: "", - lastHandshakeUnix: SECONDS(45), - rosenpassEnabled: false, - networks: [], - }), - new PeerStatus({ - ip: "100.64.0.4", - pubKey: "MockKeyConnecting00000000000000000000000000=", - connStatus: "Connecting", - connStatusUpdateUnix: SECONDS(3), - relayed: false, - localIceCandidateType: "", - remoteIceCandidateType: "", - localIceCandidateEndpoint: "", - remoteIceCandidateEndpoint: "", - fqdn: "edge-server.netbird.cloud", - bytesRx: 0, - bytesTx: 0, - latencyMs: 0, - relayAddress: "", - lastHandshakeUnix: 0, - rosenpassEnabled: false, - networks: [], - }), - new PeerStatus({ - ip: "100.64.0.5", - pubKey: "MockKeyOfflineOld0000000000000000000000000=", - connStatus: "Idle", - connStatusUpdateUnix: DAYS(4), - relayed: false, - localIceCandidateType: "", - remoteIceCandidateType: "", - localIceCandidateEndpoint: "", - remoteIceCandidateEndpoint: "", - fqdn: "old-peer-offline.netbird.cloud", - bytesRx: 0, - bytesTx: 0, - latencyMs: 0, - relayAddress: "", - lastHandshakeUnix: DAYS(4), - rosenpassEnabled: false, - networks: [], - }), - new PeerStatus({ - ip: "100.64.0.6", - pubKey: "MockKeyRelayed00000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: MINUTES(30), - relayed: true, - localIceCandidateType: "relay", - remoteIceCandidateType: "relay", - localIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", - remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", - fqdn: "relayed-host-behind-strict-nat.corp.example.com", - bytesRx: 250_000, - bytesTx: 180_000, - latencyMs: 142, - relayAddress: "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment", - lastHandshakeUnix: SECONDS(8), - rosenpassEnabled: false, - networks: ["10.10.0.0/16", "192.168.50.0/24"], - }), - new PeerStatus({ - ip: "100.64.0.7", - pubKey: "MockKeyIPv6000000000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: HOURS(1), - relayed: false, - localIceCandidateType: "host", - remoteIceCandidateType: "host", - localIceCandidateEndpoint: "[2001:db8:85a3:0000:0000:8a2e:0370:7334]:51820", - remoteIceCandidateEndpoint: "[fe80::1ff:fe23:4567:890a]:51820", - fqdn: "ipv6-only-host.netbird.cloud", - bytesRx: 999_999, - bytesTx: 1_500_000, - latencyMs: 64, - relayAddress: "", - lastHandshakeUnix: SECONDS(22), - rosenpassEnabled: false, - networks: [], - }), - new PeerStatus({ - ip: "100.64.0.8", - pubKey: "MockKeyRosenpass0000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: MINUTES(15), - relayed: false, - localIceCandidateType: "prflx", - remoteIceCandidateType: "prflx", - localIceCandidateEndpoint: "10.0.0.50:51820", - remoteIceCandidateEndpoint: "203.0.113.200:51820", - fqdn: "rosenpass-secure.netbird.cloud", - bytesRx: 50_000, - bytesTx: 50_000, - latencyMs: 24, - relayAddress: "", - lastHandshakeUnix: SECONDS(5), - rosenpassEnabled: true, - networks: [], - }), - new PeerStatus({ - ip: "100.64.0.9", - pubKey: "MockKeyMultiNet00000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: HOURS(5), - relayed: false, - localIceCandidateType: "host", - remoteIceCandidateType: "srflx", - localIceCandidateEndpoint: "10.0.0.51:51820", - remoteIceCandidateEndpoint: "203.0.113.201:51820", - fqdn: "multi-network-router.netbird.cloud", - bytesRx: 12_000_000_000, - bytesTx: 8_000_000_000, - latencyMs: 31, - relayAddress: "", - lastHandshakeUnix: SECONDS(2), - rosenpassEnabled: false, - networks: [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "100.100.0.0/16", - "10.50.50.0/24", - "2001:db8::/32", - ], - }), - new PeerStatus({ - ip: "100.64.0.10", - pubKey: "MockKeyA000000000000000000000000000000000000=", - connStatus: "Idle", - connStatusUpdateUnix: MINUTES(45), - relayed: false, - localIceCandidateType: "", - remoteIceCandidateType: "", - localIceCandidateEndpoint: "", - remoteIceCandidateEndpoint: "", - fqdn: "a.nb", - bytesRx: 0, - bytesTx: 0, - latencyMs: 0, - relayAddress: "", - lastHandshakeUnix: MINUTES(45), - rosenpassEnabled: false, - networks: [], - }), - // IPv6-NetBird-IP peer: the daemon assigns a v6 ULA inside fd00::/8 when - // running on a v6-native overlay. Exercises the row layout when the IP - // column is wide. - new PeerStatus({ - ip: "fd00:1234:5678:abcd::42", - pubKey: "MockKeyV6Native0000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: MINUTES(20), - relayed: false, - localIceCandidateType: "host", - remoteIceCandidateType: "host", - localIceCandidateEndpoint: "[2001:db8:cafe:0001::10]:51820", - remoteIceCandidateEndpoint: "[2001:db8:cafe:0002::20]:51820", - fqdn: "ipv6-overlay-peer.netbird.cloud", - bytesRx: 800_000_000, - bytesTx: 950_000_000, - latencyMs: 41, - relayAddress: "", - lastHandshakeUnix: SECONDS(7), - rosenpassEnabled: false, - networks: ["2001:db8:1::/48", "2001:db8:2::/48", "fc00:dead:beef::/48"], - }), - // Dual-stack peer with mixed IPv4 / IPv6 ICE endpoints to test rows - // where local and remote columns differ in width. - new PeerStatus({ - ip: "100.64.0.11", - pubKey: "MockKeyDualStack0000000000000000000000000000=", - connStatus: "Connected", - connStatusUpdateUnix: MINUTES(10), - relayed: false, - localIceCandidateType: "host", - remoteIceCandidateType: "srflx", - localIceCandidateEndpoint: "10.0.0.99:51820", - remoteIceCandidateEndpoint: "[2606:4700:4700:0000:0000:0000:0000:1111]:51820", - fqdn: "dual-stack.netbird.cloud", - bytesRx: 320_000_000, - bytesTx: 410_000_000, - latencyMs: 28, - relayAddress: "", - lastHandshakeUnix: SECONDS(14), - rosenpassEnabled: false, - networks: ["10.20.30.0/24", "2001:db8:beef::/48"], - }), -]; - -// Resources / routed networks. Mixes the three resource types the UI knows -// about (host = /32 or /128, subnet, domain) plus a deliberately overlapping -// pair to exercise the "overlapping" badge in NetworkFilters. -export const mockNetworkRoutes: Network[] = [ - new Network({ - id: "host-jenkins", - range: "10.0.0.1/32", - selected: true, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "subnet-corp-lan", - range: "192.168.1.0/24", - selected: true, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "subnet-wide-internal", - range: "10.0.0.0/8", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "subnet-overlap-a", - range: "172.16.0.0/16", - selected: true, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "subnet-overlap-b", - range: "172.16.0.0/16", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "dns-example", - range: "invalid Prefix", - selected: true, - domains: ["example.com"], - resolvedIps: { "example.com": ["93.184.216.34"] }, - }), - new Network({ - id: "dns-very-long-internal-domain-with-many-segments", - range: "invalid Prefix", - selected: false, - domains: [ - "very-long-internal-service-name.dev.subdomain.example-company.internal", - "another-long-domain-for-overflow-testing.example-company.internal", - "third-long-domain-in-the-list.example-company.internal", - ], - resolvedIps: { - "very-long-internal-service-name.dev.subdomain.example-company.internal": [ - "10.20.30.40", - "10.20.30.41", - ], - }, - }), - new Network({ - id: "ipv6-host", - range: "2001:db8::1/128", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "ipv6-subnet-corp", - range: "2001:db8:abcd::/48", - selected: true, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "ipv6-subnet-large", - range: "fc00:dead:beef::/32", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "dns-dual-stack", - range: "invalid Prefix", - selected: true, - domains: ["dual-stack.internal.example.com"], - resolvedIps: { - "dual-stack.internal.example.com": [ - "10.20.30.40", - "10.20.30.41", - "2001:db8:abcd::40", - "2001:db8:abcd::41", - ], - }, - }), - new Network({ - id: "dns-ipv6-only", - range: "invalid Prefix", - selected: false, - domains: ["ipv6-only-service.example.com"], - resolvedIps: { - "ipv6-only-service.example.com": ["2606:4700:4700::1111", "2606:4700:4700::1001"], - }, - }), -]; - -// Exit nodes are radio-style (mutually exclusive in the UI). Include one -// selected and one absurdly-long-id to test row truncation. -export const mockExitNodes: Network[] = [ - new Network({ - id: "us-east-1", - range: "0.0.0.0/0", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "eu-central-frankfurt-primary", - range: "0.0.0.0/0", - selected: true, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "very-long-exit-node-region-identifier-with-multiple-segments-and-numbers-12345-test", - range: "0.0.0.0/0", - selected: false, - domains: [], - resolvedIps: {}, - }), - new Network({ - id: "ap-southeast-2", - range: "0.0.0.0/0", - selected: false, - domains: [], - resolvedIps: {}, - }), -]; diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts index 08184f45f..7a169b69e 100644 --- a/client/ui/frontend/src/lib/platform.ts +++ b/client/ui/frontend/src/lib/platform.ts @@ -1,42 +1,37 @@ import { System } from "@wailsio/runtime"; export type Platform = { - isWindows: boolean; - isMacOS: boolean; + isWindows: boolean; + isMacOS: boolean; }; let cached: Platform | null = null; export async function initPlatform(): Promise<void> { - if (cached) return; + if (cached) return; - // Sync getters read the page-injected `window._wails.environment`, which can - // be empty if the injection hasn't landed yet — keep them only as a fallback. - const syncIsMac = System.IsMac(); - const syncIsWindows = System.IsWindows(); + const syncIsMac = System.IsMac(); + const syncIsWindows = System.IsWindows(); - // The async Environment() call round-trips to the Go backend and is the - // authoritative source for OS. - let env: Awaited<ReturnType<typeof System.Environment>> | null = null; - try { - env = await System.Environment(); - } catch (e) { - console.error("[platform] System.Environment() threw:", e); - } + let env: Awaited<ReturnType<typeof System.Environment>> | null = null; + try { + env = await System.Environment(); + } catch (e) { + console.error("[platform] System.Environment() threw:", e); + } - // Prefer the async env.OS; fall back to the sync getters if it's missing. - const os = (env?.OS ?? "").toLowerCase(); - cached = { - isWindows: os ? os === "windows" : syncIsWindows, - isMacOS: os ? os === "darwin" : syncIsMac, - }; + const os = (env?.OS ?? "").toLowerCase(); + cached = { + isWindows: os ? os === "windows" : syncIsWindows, + isMacOS: os ? os === "darwin" : syncIsMac, + }; } function get(): Platform { - if (!cached) { - throw new Error("platform: initPlatform() must complete before sync getters are used"); - } - return cached; + if (!cached) { + throw new Error("platform: initPlatform() must complete before sync getters are used"); + } + return cached; } export const isWindows = (): boolean => get().isWindows; diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts new file mode 100644 index 000000000..70622f705 --- /dev/null +++ b/client/ui/frontend/src/lib/sorting.ts @@ -0,0 +1,27 @@ +// Stable, order-preserving reconciliation for lists that re-fetch from the daemon +// on every status push (peers, networks, profiles). Re-sorting on each refresh would +// make rows jump around under the user, so instead: +// - items already on screen keep their existing order (from `prev`), +// - items that vanished are dropped, +// - newly-arrived items are sorted among themselves (`compareFresh`) and appended. +// Net effect: the only visible movement is new rows landing at the bottom. +// +// Must stay pure and idempotent: callers write the returned `order` into a ref +// during render (useMemo), so a rerun must reproduce the first pass — never +// branch on run count or read external mutable state. +export function reconcileOrder<T>( + prev: string[], + items: T[], + keyOf: (item: T) => string, + compareFresh: (a: T, b: T) => number, +): { order: string[]; items: T[] } { + const byKey = new Map(items.map((i) => [keyOf(i), i])); + const kept = prev.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = items + .filter((i) => !known.has(keyOf(i))) + .sort(compareFresh) + .map(keyOf); + const order = [...kept, ...fresh]; + return { order, items: order.map((k) => byKey.get(k)!) }; +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx index 1b3be91f2..6ea8d9d4c 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx @@ -13,9 +13,7 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const TIMEOUT_MS = 15 * 60 * 1000; const POLL_INTERVAL_MS = 2000; -// Sustained gRPC failure during install is taken as success — the daemon -// gets restarted by the installer mid-flight, mirroring the legacy Fyne -// UI's branch in client/ui/update.go. +// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight). const DAEMON_DOWN_GRACE_MS = 5000; const WINDOW_WIDTH = 360; @@ -36,79 +34,87 @@ export default function UpdateInProgressDialog() { useEffect(() => { let cancelled = false; + let done = false; + let timer: ReturnType<typeof setTimeout> | null = null; const start = Date.now(); let firstUnreachableAt: number | null = null; - const timer = setInterval(async () => { - if (cancelled) return; + const poll = async () => { + if (cancelled || done) return; if (phaseRef.current.kind !== "running") return; if (Date.now() - start > TIMEOUT_MS) { - clearInterval(timer); + done = true; setPhase({ kind: "timeout" }); return; } try { const r = await UpdateSvc.GetInstallerResult(); + if (cancelled || done || phaseRef.current.kind !== "running") return; firstUnreachableAt = null; if (r.success) { - clearInterval(timer); - UpdateSvc.Quit(); + done = true; + UpdateSvc.Quit().catch(console.error); return; } if (r.errorMsg) { - clearInterval(timer); + done = true; setPhase(mapInstallError(r.errorMsg)); + return; } } catch { + if (cancelled || done || phaseRef.current.kind !== "running") return; const now = Date.now(); if (firstUnreachableAt === null) { firstUnreachableAt = now; } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { - clearInterval(timer); - UpdateSvc.Quit(); + done = true; + UpdateSvc.Quit().catch(console.error); + return; } } - }, POLL_INTERVAL_MS); + + if (!cancelled && !done) { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + timer = setTimeout(poll, POLL_INTERVAL_MS); return () => { cancelled = true; - clearInterval(timer); + if (timer) clearTimeout(timer); }; }, []); const isError = phase.kind !== "running"; const errorInfo = isError ? classifyPhase(phase, version, t) : null; + const updatingHeading = version + ? t("update.overlay.updatingVersion", { version }) + : t("update.overlay.updating"); return ( <ConfirmDialog ref={contentRef}> {isError ? ( - <SquareIcon - icon={XCircle} - className={"bg-red-500 [&_svg]:text-white"} - /> + <SquareIcon icon={XCircle} className={"bg-red-500 [&_svg]:text-white"} /> ) : ( <SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} /> )} <div className={"flex flex-col items-center gap-2"}> <DialogHeading className={"text-balance"}> - {isError - ? errorInfo!.title - : version - ? t("update.overlay.updatingVersion", { version }) - : t("update.overlay.updating")} + {errorInfo ? errorInfo.title : updatingHeading} </DialogHeading> <DialogDescription> - {isError ? ( + {errorInfo ? ( <> - {errorInfo!.description} - {errorInfo!.message && ( + {errorInfo.description} + {errorInfo.message && ( <> <br /> <span className={"first-letter:uppercase"}> - {errorInfo!.message} + {errorInfo.message} </span> </> )} @@ -126,9 +132,7 @@ export default function UpdateInProgressDialog() { variant={"secondary"} size={"md"} className={"w-full"} - onClick={() => - WindowManager.CloseInstallProgress().catch(console.error) - } + onClick={() => WindowManager.CloseInstallProgress().catch(console.error)} > {t("common.close")} </Button> diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx index 24310d9eb..3ed982dfd 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -9,7 +9,9 @@ import { cn } from "@/lib/cn"; const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; function openUrl(url: string) { - void Browser.OpenURL(url).catch(() => window.open(url, "_blank")); + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); } export function UpdateVersionCard() { @@ -62,7 +64,7 @@ export function UpdateVersionCard() { ); } -function Card({ children, className }: { children: ReactNode; className?: string }) { +function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) { return ( <div className={cn( @@ -75,11 +77,11 @@ function Card({ children, className }: { children: ReactNode; className?: string ); } -function Title({ children }: { children: ReactNode }) { +function Title({ children }: Readonly<{ children: ReactNode }>) { return <p className={"text-sm font-semibold"}>{children}</p>; } -function Link({ url, children }: { url: string; children: ReactNode }) { +function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) { return ( <button type={"button"} diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 6677ad1bd..b27f6921a 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -13,16 +13,6 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const WINDOW_WIDTH = 380; -// ErrorDialog is the app's error surface — a frameless, always-on-top -// NetBird-chromed window opened by WindowManager.OpenError(title, message), -// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old -// native OS MessageBox. Title and message arrive as query params (see -// services/windowmanager.go errorDialogURL); both are caller-localised. The -// title is also the window's chrome title ("NetBird - <title>", set Go-side); -// it's repeated as the heading here so it stays visible on macOS, where the -// hidden-inset title bar doesn't render the chrome title. The single Close -// button (and the Escape key) dismisses the window via WindowManager.CloseError -// — the Go side destroys it on close. export default function ErrorDialog() { const { t } = useTranslation(); const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH); @@ -35,15 +25,12 @@ export default function ErrorDialog() { WindowManager.CloseError().catch(console.error); }, []); - // Escape closes — keyboard-accessible cancellation, matching the native - // dialog's behaviour. The primary button is autoFocused below so Enter - // also dismisses. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); }, [close]); return ( diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index 7bee1702f..faaba0648 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; -import { errorDialog } from "@/lib/dialogs.ts"; import { Loader2 } from "lucide-react"; import { Connection } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; @@ -12,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; const EVENT_CANCEL = "browser-login:cancel"; const WINDOW_WIDTH = 360; @@ -34,12 +33,7 @@ export default function LoginWaitingForBrowserDialog() { [t], ); - // Open the system browser only after the dialog has mounted (which - // means useAutoSizeWindow has called Window.Show). startLogin used to - // fire OpenURL itself but the browser typically beat React's mount - // and landed on top of the still-hidden NetBird popup. The ref guard - // keeps StrictMode's intentional double-invoke in dev (and any future - // remount) from launching two browser tabs. + // Open the browser only after mount, or it lands on top of the still-hidden popup. useEffect(() => { if (!uri || openedRef.current) return; openedRef.current = true; @@ -52,20 +46,17 @@ export default function LoginWaitingForBrowserDialog() { }, [uri, reportOpenFailure]); const cancel = useCallback(() => { - void Events.Emit(EVENT_CANCEL); + Events.Emit(EVENT_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel", err), + ); }, []); return ( <ConfirmDialog ref={contentRef}> - <SquareIcon - icon={Loader2} - className={"[&_svg]:animate-spin"} - /> + <SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} /> <div className={"flex flex-col items-center gap-2"}> - <DialogHeading className={"text-balance"}> - {t("browserLogin.title")} - </DialogHeading> + <DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading> <DialogDescription> {t("browserLogin.notSeeing")}{" "} <button diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index b58b32296..c6faef404 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -3,70 +3,29 @@ import { useTranslation } from "react-i18next"; import { Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; -import { errorDialog } from "@/lib/dialogs.ts"; import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; import { useProfile } from "@/contexts/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; -import { formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { TruncatedText } from "@/components/TruncatedText"; import { shortenDns } from "@/lib/formatters"; +import { contentTop } from "@/components/empty-state/EmptyState"; import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; import * as Popover from "@radix-ui/react-popover"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; -// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close -// button (Go side) and by the in-dialog Cancel button. startLogin uses it -// to break the WaitSSOLogin race so the daemon doesn't hang on a stale -// device code. const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; -// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle -// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side. -// The tray emits it from menu items so the React UI (which owns the SSO -// orchestration and the browser-login window) takes over. const EVENT_TRIGGER_LOGIN = "trigger-login"; -// loginInFlight is a module-level guard. SSO login involves multiple async -// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent -// call would race on the daemon's pending device code and on the popup -// window's singleton, leading to confusing UX. Calls past the first are -// dropped silently — the first invocation owns the flow until it settles. let loginInFlight = false; -// startLogin drives the daemon's SSO login end-to-end: -// 1. Connection.Login — daemon returns a verification URI if SSO is needed. -// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup. -// 3. Race WaitSSOLogin vs the user clicking Cancel. -// 4. On success: Connection.Up. -// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon -// drops the abandoned device code (avoids an Idle blink on the tray). -// -// Errors that aren't user cancellations surface via errorDialog. Concurrent -// calls are dropped via loginInFlight. The BrowserLogin window is closed in -// all exit paths so a stray popup doesn't outlive the flow. -// startLogin drives the SSO flow. onSettled is invoked exactly once, the -// instant the flow itself is over (success, cancel, or error) — BEFORE the -// error dialog is shown. Every guard that gates re-arming the login path -// (the module-level loginInFlight here, and the caller's React-level -// loginGuard via onSettled) must be released at that point, never gated on -// the dialog. -// -// Why the dialog must be outside the guards: the native Windows MessageBox -// disables its parent for its whole lifetime, and the main window's -// WindowClosing hook hides instead of closing — the two race and the dialog -// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's -// release awaited the dialog, that guard would stay held for as long as the -// box is open (or forever if it hangs), and every later Connect / tray -// trigger-login would be silently dropped at the guard check until the -// client is restarted. That was the original "can't log in again until -// restart" bug. +// onSettled (re-arm guards) must fire before the error dialog, never gated on it: +// a hanging dialog would silently drop every later login until restart. async function startLogin(onSettled?: () => void): Promise<void> { if (loginInFlight) { - // The caller's guard must still be released — it was set before this - // call. Without this the React-level loginGuard would wedge on a - // dropped concurrent invocation. onSettled?.(); return; } @@ -117,7 +76,7 @@ async function startLogin(onSettled?: () => void): Promise<void> { if (cancelled) { waitPromise.cancel?.(); - void waitPromise.catch(() => {}); + waitPromise.catch(() => {}); return; } } @@ -128,9 +87,6 @@ async function startLogin(onSettled?: () => void): Promise<void> { if (!cancelled) loginError = e; } finally { offCancel?.(); - // Release every guard before any UI work below — never gate re-arming - // the login path on a dialog that can hang. loginInFlight is ours; - // onSettled releases the caller's React-level loginGuard. loginInFlight = false; onSettled?.(); } @@ -150,8 +106,6 @@ enum ConnectionState { Disconnecting = "disconnecting", } -// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map — -// connState collapses them into Connecting or Disconnected upstream. const STATUS_KEY: Record<ConnectionState, string> = { [ConnectionState.Disconnected]: "connect.status.disconnected", [ConnectionState.Connecting]: "connect.status.connecting", @@ -161,8 +115,6 @@ const STATUS_KEY: Record<ConnectionState, string> = { const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); -// Re-enable the switch after this long in a transitioning state so the user -// can force a Connection.Down on a stuck Connecting/Disconnecting flow. const FORCE_TOGGLE_DELAY_MS = 7000; const errorMessage = formatErrorMessage; @@ -176,37 +128,18 @@ export const MainConnectionStatusSwitch = () => { const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); const unreachable = daemonState === "DaemonUnavailable"; - // Tracks an in-flight user action so we can show a transitional label - // and disable the switch without lying about the daemon's actual state. - // - // "connect" — user clicked Up; waiting for daemon to settle - // "logging-in" — SSO flow is driving the daemon (Login → browser → - // Up). Keeps the switch in "Connecting" while the - // daemon flaps NeedsLogin → Idle → NeedsLogin → - // Connecting that Login's internal Down causes. - // "disconnect" — user clicked Down; waiting for daemon to settle type Action = "connect" | "logging-in" | "disconnect" | null; const [action, setAction] = useState<Action>(null); - // Guards startLogin from being fired twice in parallel (effect path + - // tray trigger-login + handleSwitch). startLogin's module-level - // loginInFlight already drops the second daemon call, but its - // Promise would resolve immediately and the .finally clear our - // "logging-in" latch while the first flow is still running. const loginGuard = useRef(false); const driveLogin = useCallback(() => { if (loginGuard.current) return; loginGuard.current = true; setAction("logging-in"); - // Release the React-level guard via onSettled — fired the instant the - // flow ends, before startLogin's error dialog. Gating it on the full - // startLogin() promise would keep loginGuard wedged for the whole - // dialog lifetime, leaving the tray's trigger-login dropped at the - // guard check until the client is restarted. void startLogin(() => { loginGuard.current = false; setAction(null); - void refresh(); + refresh().catch((err: unknown) => console.error("refresh after login failed", err)); }); }, [refresh]); @@ -227,11 +160,6 @@ export const MainConnectionStatusSwitch = () => { case "LoginFailed": case "SessionExpired": case "DaemonUnavailable": - // NeedsLogin / SessionExpired without an in-flight user - // action read as Disconnected — the switch only flips to - // Connecting once the user (or the tray's trigger-login) - // kicks off the SSO flow, which sets action = "logging-in" - // and is handled by the guard above. return ConnectionState.Disconnected; default: return ConnectionState.Disconnected; @@ -254,11 +182,6 @@ export const MainConnectionStatusSwitch = () => { Message: errorMessage(e), }); } - // Don't clear action here on success — the daemon's first status - // push (Connecting / NeedsLogin / ...) may land after Up returns, - // and clearing eagerly would let connState fall back to - // Disconnected for one render. The effect below clears the latch - // once daemonState catches up. }; const disconnect = async () => { @@ -274,23 +197,10 @@ export const MainConnectionStatusSwitch = () => { Message: errorMessage(e), }); } - // See connect() above — clear via the effect, not eagerly. }; - // Tracks whether the daemon has entered Connecting during the - // current "connect" action. Lets us distinguish "still waiting for - // the daemon to start" (Idle → Idle) from "the connect flow was - // cancelled externally" (Connecting → Idle, e.g. tray Disconnect - // while the UI was Connecting). Reset whenever action returns to - // null. const sawConnectingRef = useRef(false); - // Release the action latch when the daemon settles on a terminal - // state for the user's intent — and, in the connect → NeedsLogin - // case, hand off to driveLogin so the user doesn't have to click - // the switch a second time. "logging-in" is cleared by driveLogin's - // .finally, not here: Login's internal Down makes the daemon flap - // through Idle, which would otherwise look like a terminal state. useEffect(() => { if (action === null) { sawConnectingRef.current = false; @@ -308,10 +218,6 @@ export const MainConnectionStatusSwitch = () => { setAction(null); return; } - // Cancelled externally (e.g. tray Disconnect during our - // Connecting): the daemon went back to Idle after we'd - // observed Connecting. Clear the latch so the UI stops - // showing Connecting forever. if (sawConnectingRef.current && daemonState === "Idle") { setAction(null); } @@ -324,11 +230,6 @@ export const MainConnectionStatusSwitch = () => { } }, [action, daemonState, needsLogin, unreachable, driveLogin]); - // The tray clicks Connect via its own gRPC call. When the daemon flips - // to NeedsLogin afterwards, the tray emits trigger-login so the React - // UI (which owns the SSO orchestration and the browser-login window) - // takes over. driveLogin's loginGuard handles concurrent tray + - // switch clicks. useEffect(() => { const off = Events.On(EVENT_TRIGGER_LOGIN, () => { driveLogin(); @@ -359,9 +260,6 @@ export const MainConnectionStatusSwitch = () => { const isOn = connState === ConnectionState.Connected || connState === ConnectionState.Connecting; - // When the daemon hangs in Connecting/Disconnecting, give the user an - // escape hatch: after the delay, the switch becomes clickable again so a - // tap fires Connection.Down (plus cancels any in-flight SSO flow). const [canForceCancel, setCanForceCancel] = useState(false); useEffect(() => { if (!isTransitioning) { @@ -374,7 +272,9 @@ export const MainConnectionStatusSwitch = () => { const forceCancel = async () => { if (action === "logging-in") { - void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL); + Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel failed", err), + ); } WindowManager.CloseBrowserLogin().catch(() => {}); setAction("disconnect"); @@ -397,14 +297,8 @@ export const MainConnectionStatusSwitch = () => { return ( <div - className={cn( - // Anchored from the top so the FQDN/IP lines below the toggle - // can grow into a popover-aware layout without shifting the - // toggle itself (justify-center would slide everything up - // when the IP line is hidden during Disconnected). - "flex flex-col h-full w-full items-center gap-4", - "relative top-[11.7rem]", - )} + className={cn("flex flex-col h-full w-full items-center gap-4", "relative")} + style={{ top: contentTop("11.7rem") }} > <img src={netbirdFullLogo} @@ -451,9 +345,6 @@ export const MainConnectionStatusSwitch = () => { ); }; -// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has -// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens -// a popover containing both v4 and v6, each independently click-to-copy. const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { const [open, setOpen] = useState(false); const hasV6 = !!ipv6; @@ -489,10 +380,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole <button type={"button"} className={cn( - // relative so the chevron can be absolutely - // positioned alongside without widening the trigger - // — keeps the IP text centred in its parent and - // lets the popover centre cleanly on it. "group relative inline-flex items-center outline-none cursor-default", "transition-colors", )} @@ -540,9 +427,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole ); }; -// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors -// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy -// icon on the right that flips to a checkmark briefly after a successful copy. const IpRow = ({ value }: { value: string }) => { const [copied, setCopied] = useState(false); const handleClick = async () => { @@ -551,9 +435,7 @@ const IpRow = ({ value }: { value: string }) => { await navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 500); - } catch { - // ignore - } + } catch {} }; return ( <button diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 355ed0155..f13ce0d46 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -8,15 +8,13 @@ import { cn } from "@/lib/cn"; import { TruncatedText } from "@/components/TruncatedText"; import { useNetworks } from "@/contexts/NetworksContext"; import { useStatus } from "@/contexts/StatusContext"; -import { mockExitNodes, mockOr } from "@/lib/mock"; const NONE_VALUE = "__none__"; export const MainExitNodeSwitcher = () => { const { t } = useTranslation(); const { status } = useStatus(); - const { exitNodes: realExitNodes, toggleExitNode } = useNetworks(); - const exitNodes = mockOr(realExitNodes, mockExitNodes); + const { exitNodes, toggleExitNode } = useNetworks(); const active = exitNodes.find((n) => n.selected) ?? null; const isConnected = status?.status === "Connected"; const hasAny = exitNodes.length > 0; @@ -27,19 +25,23 @@ export const MainExitNodeSwitcher = () => { const handleSelect = (next: string) => { setOpen(false); if (next === NONE_VALUE) { - if (active) void toggleExitNode(active.id, true); + if (active) + toggleExitNode(active.id, true).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); return; } - if (active && active.id === next) return; - void toggleExitNode(next, false); + if (active?.id === next) return; + toggleExitNode(next, false).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); }; const title = active ? active.id : t("exitNodes.card.title"); - const description = !hasAny - ? t("exitNodes.empty.title") - : active - ? t("exitNodes.card.statusActive") - : t("exitNodes.card.statusInactive"); + const activeDescription = active + ? t("exitNodes.card.statusActive") + : t("exitNodes.card.statusInactive"); + const description = hasAny ? activeDescription : t("exitNodes.empty.title"); return ( <Popover.Root open={open} onOpenChange={setOpen}> diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index 8d9787e89..1550167a4 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -36,22 +36,18 @@ export const MainHeader = () => { const openSettings = useCallback(() => { setMenuOpen(false); - void WindowManager.OpenSettings("").catch(() => {}); + WindowManager.OpenSettings("").catch(() => {}); }, []); - // Mirror the tray's Settings accelerator so the keystroke works while - // the main window has focus too. The tray's SetAccelerator paints the - // glyph on macOS/Linux but only fires the menu item — it can't reach the - // webview's input loop, hence the parallel React-side listener. useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings); const openAbout = () => { setMenuOpen(false); - void WindowManager.OpenSettings("about").catch(() => {}); + WindowManager.OpenSettings("about").catch(() => {}); }; const openManageProfiles = () => { - void WindowManager.OpenSettings("profiles").catch(() => {}); + WindowManager.OpenSettings("profiles").catch(() => {}); }; const selectMode = (mode: ViewMode) => { @@ -130,16 +126,6 @@ export const MainHeader = () => { </div> ); - // The inner grid is locked to 356px (the default-mode content width: - // 380px window − 12px px-3 each side). It stays left-anchored regardless - // of window size, so the profile keeps the exact same absolute X - // position when the user flips to advanced view. The settings button is - // pulled out as an absolute, right-anchored element so it tracks the - // window's right edge in both modes. - // Header height matches the Settings window's top traffic-light strip - // so the right panel ends up the same height in both windows. The h-10 - // of the inner buttons (profile trigger, more-vertical) defines the - // natural height; the strip in SettingsLayout is sized to mirror it. return ( <div className={cn( @@ -147,8 +133,7 @@ export const MainHeader = () => { "flex items-center h-12 top-3", )} > - {/* Windows gets a narrower width to compensate for the OS window frame/border that Wails - counts differently than macOS, so the visible content area lines up on both platforms. + {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. See https://github.com/wailsapp/wails/issues/3260 */} <div className={cn( diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index 30ce65c74..e9af6e5fe 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -13,7 +13,7 @@ import { Networks } from "@/modules/main/advanced/networks/Networks"; import { NetworksProvider } from "@/contexts/NetworksContext"; import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext"; import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel"; -import {isWindows} from "@/lib/platform.ts"; +import { isWindows } from "@/lib/platform.ts"; export const MainPage = () => { return ( @@ -34,10 +34,14 @@ const MainBody = () => { return ( <div className={"wails-draggable flex flex-1 min-h-0"}> - {/* Windows gets a narrower width to compensate for the OS window frame/border that Wails - counts differently than macOS, so the visible content area lines up on both platforms. + {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. See https://github.com/wailsapp/wails/issues/3260 */} - <div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}> + <div + className={cn( + "relative flex flex-col items-center shrink-0 ", + isWindows() ? "w-[364px]" : "w-[380px]", + )} + > <MainConnectionStatusSwitch /> <div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}> <MainExitNodeSwitcher /> diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 15f769a53..de84e184a 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -19,7 +19,7 @@ type Props = { }; export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [open, setOpen] = useState(false); const filters: { value: NetworkFilter; label: string }[] = [ { value: "all", label: t("networks.filter.all") }, @@ -34,7 +34,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => }; return ( - <DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}> + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger disabled={disabled} className={cn( @@ -62,21 +62,10 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => > <span className={"flex-1 truncate"}> {f.label}{" "} - <span className={"tabular-nums"}> - ({counts[f.value]}) - </span> + <span className={"tabular-nums"}>({counts[f.value]})</span> </span> - <span - className={ - "w-4 shrink-0 flex items-center justify-center" - } - > - {checked && ( - <CheckIcon - size={14} - className={"text-netbird"} - /> - )} + <span className={"w-4 shrink-0 flex items-center justify-center"}> + {checked && <CheckIcon size={14} className={"text-netbird"} />} </span> </DropdownMenuItem> ); diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index f858a70a3..61be7262c 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area"; import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; import type { Network } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; @@ -12,37 +13,26 @@ import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; -import { mockNetworkRoutes, mockOr } from "@/lib/mock"; import { NetworkFilter, NetworkFilters } from "./NetworkFilters"; -// The daemon stringifies route.Network via netip.Prefix.String(). For -// DNS-based routes the prefix is the zero value, which Go renders as -// "invalid Prefix". Those rows render their domain + resolved IPs instead. +// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". const INVALID_PREFIX = "invalid Prefix"; const isDnsRoute = (n: Network): boolean => n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX); -// Mirror management's NetworkResourceType (resource.go GetResourceType): -// a CIDR is a host when its prefix length equals the address width -// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with -// domains attached are domain resources. type ResourceType = "host" | "subnet" | "domain"; const isHostCidr = (cidr: string): boolean => { const [addr, bitsStr] = cidr.split("/"); if (!addr || !bitsStr) return false; const bits = Number(bitsStr); - // IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'. const isV6 = addr.includes(":"); return isV6 ? bits === 128 : bits === 32; }; const resourceTypeOf = (n: Network): ResourceType => { if (isDnsRoute(n)) return "domain"; - // n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs - // come comma-joined, but those are filtered out upstream — guard - // defensively by inspecting only the first segment. const primary = n.range.split(",")[0].trim(); return isHostCidr(primary) ? "host" : "subnet"; }; @@ -53,9 +43,6 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => { return NetworkIcon; }; -// Map every range string -> ids of CIDR routes that share it. Domain routes -// are skipped (they overlap on domain, not prefix). Single-entry buckets -// aren't overlaps. const buildOverlapMap = ( routes: { id: string; range: string; domains: string[] }[], ): Map<string, string[]> => { @@ -77,8 +64,7 @@ export const Networks = () => { const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; - const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); - const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes); + const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); const [search, setSearch] = useState(""); const [filter, setFilter] = useState<NetworkFilter>("all"); const searchRef = useRef<HTMLInputElement>(null); @@ -106,26 +92,19 @@ export const Networks = () => { [networkRoutes, overlapById], ); - // Initial order: active-first, then by id. After that, positions are sticky - // — toggling a row doesn't move it, and newly discovered routes append at - // the end (sorted active-first / by-id among themselves). The ref carries - // the previous order across renders so the reconciliation is synchronous - // with networkRoutes updates (no useEffect lag → no visual hop). const orderRef = useRef<string[]>([]); const ordered = useMemo(() => { - const byId = new Map(networkRoutes.map((r) => [r.id, r])); - const kept = orderRef.current.filter((id) => byId.has(id)); - const known = new Set(kept); - const fresh = networkRoutes - .filter((r) => !known.has(r.id)) - .sort((a, b) => { + const { order, items } = reconcileOrder( + orderRef.current, + networkRoutes, + (r) => r.id, + (a, b) => { if (a.selected !== b.selected) return a.selected ? -1 : 1; return a.id.localeCompare(b.id); - }) - .map((r) => r.id); - const next = [...kept, ...fresh]; - orderRef.current = next; - return next.map((id) => byId.get(id)!); + }, + ); + orderRef.current = order; + return items; }, [networkRoutes]); const filtered = useMemo(() => { @@ -158,13 +137,15 @@ export const Networks = () => { const onBulkClick = () => { if (filtered.length === 0) return; if (allSelected) { - void setNetworksSelected( + setNetworksSelected( filtered.map((r) => r.id), false, - ); + ).catch((err: unknown) => console.error("disable all networks failed", err)); } else { const ids = filtered.filter((r) => !r.selected).map((r) => r.id); - void setNetworksSelected(ids, true); + setNetworksSelected(ids, true).catch((err: unknown) => + console.error("enable all networks failed", err), + ); } }; @@ -247,17 +228,26 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => { {data.map((n) => ( <li key={n.id} - onClick={() => onToggle(n.id, n.selected)} className={cn( - "group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2", + "group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2", "hover:bg-nb-gray-900/40 transition-colors", - "wails-no-draggable cursor-pointer", + "wails-no-draggable", )} > + <button + type={"button"} + aria-label={n.id} + onClick={() => onToggle(n.id, n.selected)} + className={"absolute inset-0 cursor-pointer"} + /> <ResourceIconBadge type={resourceTypeOf(n)} /> - <div className={"min-w-0 flex-1 flex flex-col leading-tight"}> + <div + className={ + "min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none" + } + > <div> - <CopyToClipboard message={n.id}> + <CopyToClipboard message={n.id} className={"pointer-events-auto"}> <TruncatedText text={n.id} className={ @@ -268,7 +258,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => { </div> <Subtitle network={n} /> </div> - <div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}> + <div className={"shrink-0 self-center relative"}> <NetworkToggle checked={n.selected} onChange={() => onToggle(n.id, n.selected)} @@ -388,25 +378,28 @@ type ToggleProps = { mixed?: boolean; }; -const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => ( - <button - type={"button"} - role={"switch"} - aria-checked={mixed ? "mixed" : checked} - aria-label={label} - onClick={onChange} - className={cn( - "shrink-0 inline-flex h-5 w-9 items-center rounded-full", - "transition-colors cursor-pointer wails-no-draggable", - checked || mixed ? "bg-netbird" : "bg-nb-gray-700", - mixed && "opacity-60", - )} - > - <span +const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => { + const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + return ( + <button + type={"button"} + role={"switch"} + aria-checked={mixed ? "mixed" : checked} + aria-label={label} + onClick={onChange} className={cn( - "inline-block h-4 w-4 rounded-full bg-white transition-transform", - mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5", + "shrink-0 inline-flex h-5 w-9 items-center rounded-full", + "transition-colors cursor-pointer wails-no-draggable", + checked || mixed ? "bg-netbird" : "bg-nb-gray-700", + mixed && "opacity-60", )} - /> - </button> -); + > + <span + className={cn( + "inline-block h-4 w-4 rounded-full bg-white transition-transform", + mixed ? "translate-x-2.5" : checkedTranslate, + )} + /> + </button> + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 26cff6f9f..b21b34427 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -30,7 +30,6 @@ import { TruncatedText } from "@/components/TruncatedText"; import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; -import { mockOr, mockPeers } from "@/lib/mock"; import { peerStatusLabelKey } from "./Peers"; const DEFAULT_TRANSITION: Transition = { @@ -60,12 +59,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { selected, setSelected } = usePeerDetail(); const { status, refresh } = useStatus(); - // Keep `selected` in sync with the live peer list so the panel reflects - // status / latency / byte updates without re-opening. If the peer - // disappears, close the panel. useEffect(() => { if (!selected) return; - const peers = mockOr(status?.peers ?? [], mockPeers); + const peers = status?.peers ?? []; const fresh = peers.find((p) => p.pubKey === selected.pubKey); if (!fresh) { setSelected(null); @@ -74,11 +70,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { if (fresh !== selected) setSelected(fresh); }, [status, selected, setSelected]); - // Re-render every second so the relative timestamps in PeerDetails - // ("Xs ago", "Xm ago") tick. The daemon updates latency/bytes/handshake - // silently without pushing a fresh status snapshot — see - // status.go UpdateLatency / UpdateWireGuardPeerState — so without this - // the displayed age would freeze for a stably-Connected peer. + // Daemon updates latency/bytes/handshake without pushing a fresh status + // snapshot, so tick locally to keep relative timestamps live. const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!selected) return; @@ -90,9 +83,6 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const onRefresh = useCallback(async () => { if (refreshing) return; setRefreshing(true); - // Refresh over the unix socket usually completes in <50ms, faster - // than the spin animation can show. Hold the spinning state for at - // least one full rotation so the click feels responsive. const MIN_SPIN_MS = 600; const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS)); try { @@ -102,14 +92,13 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { } }, [refresh, refreshing]); - // Esc closes the panel. useEffect(() => { if (!selected) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSelected(null); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); }, [selected, setSelected]); return ( @@ -371,10 +360,6 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { ); }; -// Single "View {n}" badge with a chevron that opens a click popover listing -// each routed resource on its own line with a click-to-copy entry. Avoids -// the repetitive "first item + N more" pattern given the row already has a -// "Resources" label and Layers icon. const ResourcesValue = ({ networks }: { networks: string[] }) => ( <ResourcesPopover networks={networks} /> ); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx index b42ac4e14..30ebe55c4 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -19,7 +19,7 @@ type Props = { }; export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [open, setOpen] = useState(false); const filters: { value: StatusFilter; label: string }[] = [ { value: "all", label: t("peers.filter.all") }, @@ -34,7 +34,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { }; return ( - <DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}> + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger disabled={disabled} className={cn( diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index cc17cd9ff..84b4f05e3 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area"; import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react"; import type { PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { SearchInput } from "@/components/inputs/SearchInput"; import { EmptyState } from "@/components/empty-state/EmptyState"; @@ -13,7 +14,6 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; -import { mockOr, mockPeers } from "@/lib/mock"; import { PeerFilters, StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; @@ -29,8 +29,6 @@ const dotClass = (connStatus: string): string => { } }; -// The daemon reports "Idle" for not-connected peers; surface it as -// "Disconnected" in the UI. Connected / Connecting pass through. export const peerStatusLabelKey = (connStatus: string): string => { switch (connStatus) { case "Connected": @@ -49,15 +47,12 @@ export const Peers = () => { const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); const searchRef = useRef<HTMLInputElement>(null); - // Peers is only mounted in advanced view (see pages/Main.tsx), so a - // mount-time focus is equivalent to "focus when the user toggles into - // advanced view". useEffect(() => { searchRef.current?.focus(); }, []); const isConnected = status?.status === "Connected"; - const peers = mockOr(status?.peers ?? [], mockPeers); + const peers = status?.peers ?? []; const counts = useMemo<Record<StatusFilter, number>>(() => { const online = peers.filter((p) => isOnline(p.connStatus)).length; @@ -68,34 +63,21 @@ export const Peers = () => { }; }, [peers]); - // Initial order: online-first, then alphabetically by fqdn / ip. Once - // peers have settled, positions become sticky — a peer flipping - // Connected→Connecting→Idle no longer jumps groups. Newly discovered - // peers append at the end (sorted online-first / by-name among - // themselves). Mirrors the networks-list and exit-nodes-list orderRef - // pattern. - // - // Stay in live-sort mode until every peer has reached a stable state - // (Connected or Idle). The daemon emits all peers as "Connecting" right - // after Up, which collapses the online-first sort into pure - // alphabetical — committing then would lock that incorrect order and - // the list would stay alphabetical even after every peer becomes - // Connected. Once nothing is Connecting we commit and go sticky. + // Stay in live-sort until every peer is stable. Right after Up the daemon + // emits all peers as "Connecting"; committing then would lock that + // alphabetical-only order forever. const orderRef = useRef<string[]>([]); const stickyRef = useRef(false); const ordered = useMemo(() => { - const sortOnlineFirst = (list: PeerStatus[]) => - [...list].sort((a, b) => { - const aOnline = isOnline(a.connStatus); - const bOnline = isOnline(b.connStatus); - if (aOnline !== bOnline) return aOnline ? -1 : 1; - const aName = (a.fqdn || a.ip).toLowerCase(); - const bName = (b.fqdn || b.ip).toLowerCase(); - return aName.localeCompare(bName); - }); + const compare = (a: PeerStatus, b: PeerStatus) => { + const aOnline = isOnline(a.connStatus); + const bOnline = isOnline(b.connStatus); + if (aOnline !== bOnline) return aOnline ? -1 : 1; + const aName = (a.fqdn || a.ip).toLowerCase(); + const bName = (b.fqdn || b.ip).toLowerCase(); + return aName.localeCompare(bName); + }; - // Reset on empty (Disconnect → reconnect) so the next session - // re-sorts from scratch instead of replaying the stale orderRef. if (peers.length === 0) { orderRef.current = []; stickyRef.current = false; @@ -103,7 +85,7 @@ export const Peers = () => { } if (!stickyRef.current) { - const sorted = sortOnlineFirst(peers); + const sorted = [...peers].sort(compare); if (peers.every((p) => p.connStatus !== "Connecting")) { orderRef.current = sorted.map((p) => p.pubKey); stickyRef.current = true; @@ -111,15 +93,9 @@ export const Peers = () => { return sorted; } - const byKey = new Map(peers.map((p) => [p.pubKey, p])); - const kept = orderRef.current.filter((k) => byKey.has(k)); - const known = new Set(kept); - const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map( - (p) => p.pubKey, - ); - const next = [...kept, ...fresh]; - orderRef.current = next; - return next.map((k) => byKey.get(k)!); + const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare); + orderRef.current = order; + return items; }, [peers]); const filtered = useMemo(() => { @@ -190,24 +166,36 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { return ( <li key={peer.pubKey} - onClick={() => setSelected(peer)} className={cn( - "group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2", + "group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2", "hover:bg-nb-gray-900/40 transition-colors", - "wails-no-draggable cursor-default", + "wails-no-draggable", )} > + <button + type={"button"} + aria-label={shortenDns(peer.fqdn)} + onClick={() => setSelected(peer)} + className={"absolute inset-0 cursor-default"} + /> <Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}> <span className={cn( - "h-2 w-2 rounded-full shrink-0 mt-2", + "h-2 w-2 rounded-full shrink-0 mt-2 relative", dotClass(peer.connStatus), )} /> </Tooltip> - <div className={"min-w-0 flex-1 flex flex-col leading-tight"}> + <div + className={ + "min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none" + } + > <div> - <CopyToClipboard message={peer.fqdn}> + <CopyToClipboard + message={peer.fqdn} + className={"pointer-events-auto"} + > <TruncatedText text={shortenDns(peer.fqdn)} className={ @@ -217,7 +205,10 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { </CopyToClipboard> </div> <div> - <CopyToClipboard message={peer.ip}> + <CopyToClipboard + message={peer.ip} + className={"pointer-events-auto"} + > <span className={"text-xs font-mono text-nb-gray-400 truncate"}> {peer.ip} </span> @@ -227,7 +218,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { {isConnected && peer.latencyMs > 0 && ( <span className={cn( - "shrink-0 self-center text-xs tabular-nums", + "shrink-0 self-center text-xs tabular-nums relative pointer-events-none", latencyColor(peer.latencyMs), )} > @@ -237,7 +228,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { <ChevronRightIcon size={16} className={cn( - "shrink-0 self-center text-nb-gray-300", + "shrink-0 self-center text-nb-gray-300 relative pointer-events-none", "opacity-0 group-hover:opacity-100 transition-opacity", )} /> diff --git a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx index e9fd8ff36..b5f161b8f 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx @@ -19,10 +19,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/cn"; -// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical -// just like "test" does. The list is scanned in order, so more-specific -// tokens (e.g. "staging" before "stage") should come first when they share -// roots. +// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage"). const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ [/(default|personal)/i, UserCircle], [/(work|business|office|company|corp|corporate)/i, Briefcase], diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index 49fbccb23..dfcf2712b 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -18,19 +18,11 @@ import { type Props = { open: boolean; onOpenChange: (open: boolean) => void; - // onCreate receives the sanitized profile name and the management URL the - // user picked (the cloud default for Cloud mode, the normalized self- - // hosted URL otherwise). onCreate: (name: string, managementUrl: string) => void; }; -// Mirror of the daemon's profilemanager.sanitizeProfileName rule -// (client/internal/profilemanager/profilemanager.go): only letters, digits, -// `_` and `-` survive on the Go side. We additionally lowercase and convert -// spaces to `-` so what the user sees in the input is exactly what the -// daemon will store — otherwise the daemon silently sanitizes ("my profile" -// → "myprofile") while the UI keeps the raw name in flight, which spawns a -// ghost row and breaks subsequent delete. +// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight +// raw name diverges from what's stored, spawning a ghost row and breaking delete. const sanitizeProfileInput = (value: string): string => value .toLowerCase() @@ -46,9 +38,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState<string | null>(null); - // unreachable: soft warning. A second submit with the same URL proceeds - // anyway (matches the onboarding management step's behaviour for self- - // hosted servers behind internal DNS / VPN). const [unreachable, setUnreachable] = useState(false); const [checking, setChecking] = useState(false); const urlRef = useRef<HTMLInputElement>(null); @@ -65,8 +54,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => } }, [open]); - // Reset the URL warnings whenever the user edits the URL or flips mode — - // otherwise a stale warning lingers next to a just-corrected value. useEffect(() => { setUrlError(null); setUnreachable(false); @@ -100,9 +87,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => setChecking(true); const reachable = await checkManagementUrlReachable(target); setChecking(false); - // First failed check: soft warning + bail. A second submit with the - // same URL skips re-checking (unreachable still true) so the user can - // proceed if they're sure. if (!reachable && !unreachable) { setUnreachable(true); return; @@ -117,16 +101,14 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => if (nameError) setNameError(null); }; - // Live syntactic feedback: flag a non-empty, malformed URL as the user - // types instead of waiting for submit. Empty is not an error yet (handled - // on submit); the unreachable soft-warning only applies once syntax is OK. const trimmedUrl = url.trim(); const showUrlSyntaxError = - mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl); + mode === ManagementMode.SelfHosted && + trimmedUrl !== "" && + !isValidManagementUrl(trimmedUrl); const urlInputError = showUrlSyntaxError ? t("settings.general.management.urlError") : (urlError ?? undefined); - // Soft, non-blocking caveat (orange) — only when the URL is otherwise OK. const urlInputWarning = !urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined; @@ -178,7 +160,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => <Input ref={urlRef} autoFocus - placeholder={t("settings.general.management.urlPlaceholder")} + placeholder={t( + "settings.general.management.urlPlaceholder", + )} value={url} onChange={(e) => setUrl(e.target.value)} error={urlInputError} diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index 1bc42b5b5..b042aea87 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -1,6 +1,5 @@ import { forwardRef, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { errorDialog } from "@/lib/dialogs.ts"; import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Command } from "cmdk"; @@ -10,7 +9,7 @@ import type { Profile } from "@bindings/services/models.js"; import { Tooltip } from "@/components/Tooltip"; import { useProfile } from "@/contexts/ProfileContext"; import { cn } from "@/lib/cn"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; type ProfileDropdownProps = { onManageProfiles?: () => void; @@ -59,79 +58,77 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { const displayName = activeProfile || t("profile.selector.loading"); return ( - <> - <Popover.Root open={open} onOpenChange={setOpen}> - <Popover.Trigger asChild className={"wails-no-draggable"}> - <ProfileTriggerButton name={displayName} /> - </Popover.Trigger> - <Popover.Portal> - <Popover.Content - align="center" - sideOffset={8} - collisionPadding={12} - onOpenAutoFocus={(e) => e.preventDefault()} - className={cn( - "z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable", - "data-[state=open]:animate-in data-[state=closed]:animate-out", - "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", - "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", - "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + <Popover.Root open={open} onOpenChange={setOpen}> + <Popover.Trigger asChild className={"wails-no-draggable"}> + <ProfileTriggerButton name={displayName} /> + </Popover.Trigger> + <Popover.Portal> + <Popover.Content + align="center" + sideOffset={8} + collisionPadding={12} + onOpenAutoFocus={(e) => e.preventDefault()} + className={cn( + "z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + <Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}> + {sortedProfiles.length > 0 && ( + <> + <ScrollArea.Root type="auto" className="overflow-hidden -mx-1"> + <ScrollArea.Viewport className="max-h-60 px-1"> + <Command.List> + {sortedProfiles.map((profile) => ( + <ProfileRow + key={profile.name} + profile={profile} + isActive={profile.name === activeProfile} + onSelect={handleSelect} + /> + ))} + </Command.List> + </ScrollArea.Viewport> + <ScrollArea.Scrollbar + orientation="vertical" + className={cn( + "flex select-none touch-none transition-colors", + "w-1.5 bg-transparent", + )} + > + <ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" /> + </ScrollArea.Scrollbar> + </ScrollArea.Root> + <div className="-mx-1 h-px bg-nb-gray-910" /> + </> )} - > - <Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}> - {sortedProfiles.length > 0 && ( - <> - <ScrollArea.Root type="auto" className="overflow-hidden -mx-1"> - <ScrollArea.Viewport className="max-h-60 px-1"> - <Command.List> - {sortedProfiles.map((profile) => ( - <ProfileRow - key={profile.name} - profile={profile} - isActive={profile.name === activeProfile} - onSelect={handleSelect} - /> - ))} - </Command.List> - </ScrollArea.Viewport> - <ScrollArea.Scrollbar - orientation="vertical" - className={cn( - "flex select-none touch-none transition-colors", - "w-1.5 bg-transparent", - )} - > - <ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" /> - </ScrollArea.Scrollbar> - </ScrollArea.Root> - <div className="-mx-1 h-px bg-nb-gray-910" /> - </> - )} - <div className={"pt-1"}> - <Command.Item - value={MANAGE_VALUE} - onSelect={handleManage} - disabled={!onManageProfiles} - className={cn( - "flex items-center gap-2 px-2 py-1.5", - "rounded-md outline-none cursor-default text-sm", - "data-[selected=true]:bg-nb-gray-900", - "data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none", - )} - > - <Settings2 size={14} className="shrink-0" /> - <span className="truncate flex-1"> - {t("profile.dropdown.manageProfiles")} - </span> - </Command.Item> - </div> - </Command> - </Popover.Content> - </Popover.Portal> - </Popover.Root> - </> + <div className={"pt-1"}> + <Command.Item + value={MANAGE_VALUE} + onSelect={handleManage} + disabled={!onManageProfiles} + className={cn( + "flex items-center gap-2 px-2 py-1.5", + "rounded-md outline-none cursor-default text-sm", + "data-[selected=true]:bg-nb-gray-900", + "data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none", + )} + > + <Settings2 size={14} className="shrink-0" /> + <span className="truncate flex-1"> + {t("profile.dropdown.manageProfiles")} + </span> + </Command.Item> + </div> + </Command> + </Popover.Content> + </Popover.Portal> + </Popover.Root> ); }; @@ -186,7 +183,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { > <div className="flex flex-col min-w-0 flex-1 leading-tight"> <span className="truncate">{profile.name}</span> - {showEmail && <TruncatedEmail email={profile.email!} />} + {showEmail && <TruncatedEmail email={profile.email} />} </div> {isActive && ( <Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} /> diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index 7385d39a0..47193f0b1 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -1,6 +1,5 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { errorDialog } from "@/lib/dialogs.ts"; import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react"; import type { Profile } from "@bindings/services/models.js"; import { Badge } from "@/components/Badge"; @@ -17,7 +16,8 @@ import { SetConfigParams } from "@bindings/services/models.js"; import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; import { cn } from "@/lib/cn"; -import { formatErrorMessage } from "@/lib/errors"; +import { reconcileOrder } from "@/lib/sorting"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; const DEFAULT_PROFILE = "default"; @@ -38,38 +38,22 @@ export function ProfilesTab() { const [newOpen, setNewOpen] = useState(false); const [busy, setBusy] = useState(false); - // The display order is established once — the active profile first, then - // the rest alphabetically — and then held stable for the lifetime of the - // window. Switching profiles must only flip the "active" badge, never - // reorder the rows (otherwise the row the user just clicked jumps to the - // top under their cursor). New profiles append at the end; removed ones - // drop out. `orderRef` is the source of truth for row order; the active - // badge is derived live from `activeProfile`. + // Order is held stable so switching only flips the badge, never reorders rows + // (else the clicked row jumps to the top under the cursor). const orderRef = useRef<string[]>([]); const ordered = useMemo(() => { - const present = new Set(profiles.map((p) => p.name)); - if (orderRef.current.length === 0) { - // First population: active-first, then alphabetical. - orderRef.current = [...profiles] - .sort((a, b) => { - if (a.name === activeProfile) return -1; - if (b.name === activeProfile) return 1; - return a.name.localeCompare(b.name); - }) - .map((p) => p.name); - } else { - // Preserve the established order; drop removed, append added. - const kept = orderRef.current.filter((n) => present.has(n)); - const added = profiles - .map((p) => p.name) - .filter((n) => !orderRef.current.includes(n)) - .sort((a, b) => a.localeCompare(b)); - orderRef.current = [...kept, ...added]; - } - const byName = new Map(profiles.map((p) => [p.name, p])); - return orderRef.current - .map((n) => byName.get(n)) - .filter((p): p is Profile => p !== undefined); + const { order, items } = reconcileOrder( + orderRef.current, + profiles, + (p) => p.name, + (a, b) => { + if (a.name === activeProfile) return -1; + if (b.name === activeProfile) return 1; + return a.name.localeCompare(b.name); + }, + ); + orderRef.current = order; + return items; }, [profiles, activeProfile]); const guarded = async (title: string, fn: () => Promise<void>) => { @@ -120,27 +104,17 @@ export function ProfilesTab() { }; const handleCreate = async (name: string, managementUrl: string) => { - try { + await guarded(i18next.t("profile.error.createTitle"), async () => { await addProfile(name); - // Only persist a management URL for self-hosted; a fresh profile - // already defaults to NetBird Cloud, so writing the cloud URL - // would be a no-op. Do it before switching so any reconnect the - // switch triggers already targets the right deployment. SetConfig - // is keyed by profile name, so it writes the new profile even - // though it isn't active yet (adminUrl left empty — the daemon - // keeps its loaded value). + // SetConfig is keyed by profile name, so it writes the not-yet-active + // profile. Write before switching so any reconnect targets the right deployment. if (managementUrl !== CLOUD_MANAGEMENT_URL) { await SettingsSvc.SetConfig( new SetConfigParams({ profileName: name, username, managementUrl }), ); } await switchProfile(name); - } catch (e) { - await errorDialog({ - Title: i18next.t("profile.error.createTitle"), - Message: formatErrorMessage(e), - }); - } + }); }; return ( @@ -193,7 +167,11 @@ export function ProfilesTab() { </SettingsBottomBar> </SectionGroup> - <ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} /> + <ProfileCreationModal + open={newOpen} + onOpenChange={setNewOpen} + onCreate={handleCreate} + /> </div> ); } @@ -230,12 +208,16 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro /> <div className={"flex flex-col min-w-0 flex-1 leading-tight"}> <div className={"flex items-center gap-2 min-w-0"}> - <span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}> + <span + className={ + "truncate font-medium text-nb-gray-100 select-text cursor-text" + } + > {profile.name} </span> {isActive && <Badge>{t("settings.profiles.active")}</Badge>} </div> - {showEmail && <TruncatedEmail email={profile.email!} />} + {showEmail && <TruncatedEmail email={profile.email} />} </div> </div> </td> @@ -265,7 +247,10 @@ const TruncatedEmail = ({ email }: { email: string }) => { }, [email]); const span = ( - <span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}> + <span + ref={ref} + className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"} + > {email} </span> ); @@ -294,11 +279,10 @@ const RowActions = ({ }: RowActionsProps) => { const { t } = useTranslation(); const deleteDisabled = isDefault || isActive; - const deleteLabel = isDefault - ? t("profile.delete.disabledDefault") - : isActive - ? t("profile.delete.disabledActive") - : t("profile.selector.delete"); + const nonDefaultDeleteLabel = isActive + ? t("profile.delete.disabledActive") + : t("profile.selector.delete"); + const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel; return ( <div className={"inline-flex items-center gap-1"}> <ActionIconButton @@ -329,10 +313,8 @@ type ActionIconButtonProps = { icon: typeof CircleMinus; onClick: () => void; variant?: "default" | "danger"; - /** When true the button still occupies space (preserves row layout) - * but is invisible and non-interactive. */ + /** Occupies space but invisible and non-interactive (preserves row layout). */ hidden?: boolean; - /** When true the button is visible but non-interactive (greyed out). */ disabled?: boolean; }; @@ -359,7 +341,8 @@ const ActionIconButton = ({ ? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10" : "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900", hidden && "opacity-0 pointer-events-none", - disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent", + disabled && + "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent", )} > <Icon size={16} /> @@ -368,9 +351,7 @@ const ActionIconButton = ({ if (hidden) return button; return ( <Tooltip - content={ - <span className={"block max-w-[260px] leading-snug"}>{label}</span> - } + content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>} side={"top"} > {button} diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 3ec4423c5..680eb5bec 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; -import { errorDialog } from "@/lib/dialogs.ts"; import { AlertCircleIcon, ClockIcon } from "lucide-react"; import { Button } from "@/components/buttons/Button"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; @@ -12,30 +11,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; const WINDOW_WIDTH = 360; -// Below this, the situation is genuinely "soon" and the title/description -// uses the urgent wording. Above it (e.g. opened with hours remaining), the -// "later" variant drops the urgency cue so it doesn't read absurdly. const SOON_THRESHOLD_SECONDS = 60 * 60; -// Renders the countdown with only the units that matter: mm:ss under an -// hour, hh:mm:ss under a day, dd:hh:mm:ss otherwise. Two-digit zero pad -// throughout so columns don't jump as digits roll over. -function formatRemaining(seconds: number): string { - const s = Math.max(0, seconds | 0); - const days = Math.floor(s / 86400); - const hours = Math.floor((s % 86400) / 3600); - const minutes = Math.floor((s % 3600) / 60); - const secs = s % 60; - const pad = (n: number) => String(n).padStart(2, "0"); - if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`; - if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; - return `${pad(minutes)}:${pad(secs)}`; -} - export default function SessionExpirationDialog() { const { t } = useTranslation(); const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH); @@ -49,27 +31,31 @@ export default function SessionExpirationDialog() { const [remaining, setRemaining] = useState(initialSeconds); const [busy, setBusy] = useState(false); + const busyRef = useRef(busy); + busyRef.current = busy; const expired = remaining <= 0; const soon = remaining <= SOON_THRESHOLD_SECONDS; + const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater"); + const activeDescription = soon + ? t("sessionExpiration.description") + : t("sessionExpiration.descriptionLater"); useEffect(() => { setRemaining(initialSeconds); }, [initialSeconds]); useEffect(() => { - if (remaining <= 0) return; - const id = window.setInterval(() => { + const id = globalThis.setInterval(() => { setRemaining((s) => (s <= 1 ? 0 : s - 1)); }, 1000); - return () => window.clearInterval(id); - }, [remaining]); + return () => globalThis.clearInterval(id); + }, [initialSeconds]); - // Auto-close when the daemon flips back to Connected — covers extend - // flows started from outside this window (tray notification action, - // another UI surface) so the user isn't left staring at a stale dialog. + // Suppressed while `busy`: the tunnel stays up so Connected re-fires for + // unrelated reasons (peer/route changes), and closing would abort our own WaitExtend. useEffect(() => { const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { - if (ev?.data?.status === "Connected") { + if (!busyRef.current && ev?.data?.status === "Connected") { WindowManager.CloseSessionExpiration().catch(console.error); } }); @@ -78,11 +64,6 @@ export default function SessionExpirationDialog() { }; }, []); - // Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow, - // opens the browser for the user to sign in, blocks on the daemon until - // the new deadline arrives. Tunnel stays up; success simply closes the - // dialog, failure surfaces a native error dialog and leaves this one - // open so the user can retry or logout. const stay = useCallback(async () => { if (busy) return; setBusy(true); @@ -101,13 +82,8 @@ export default function SessionExpirationDialog() { userCode: start.userCode, }); if (result.preempted) { - // Another UI surface (e.g. the tray "Extend now" - // notification action) started a flow for the same - // deadline and took over. Keep the dialog open so the - // user can re-trigger if the other flow also fails; - // a successful extend elsewhere refreshes the deadline - // and this window auto-closes when it's no longer - // relevant. + // Another surface took over this deadline's flow; keep the dialog + // open to retry. A successful extend elsewhere auto-closes this window. return; } WindowManager.CloseSessionExpiration().catch(console.error); @@ -152,18 +128,10 @@ export default function SessionExpirationDialog() { <div className={"flex flex-col items-center gap-1"}> <DialogHeading> - {expired - ? t("sessionExpiration.expired") - : soon - ? t("sessionExpiration.title") - : t("sessionExpiration.titleLater")} + {expired ? t("sessionExpiration.expired") : activeTitle} </DialogHeading> <DialogDescription> - {expired - ? t("sessionExpiration.expiredDescription") - : soon - ? t("sessionExpiration.description") - : t("sessionExpiration.descriptionLater")} + {expired ? t("sessionExpiration.expiredDescription") : activeDescription} </DialogDescription> </div> @@ -187,9 +155,7 @@ export default function SessionExpirationDialog() { onClick={stay} disabled={busy} > - {expired - ? t("sessionExpiration.authenticate") - : t("sessionExpiration.stay")} + {expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")} </Button> <Button variant={"secondary"} diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 313e70633..1701ba53f 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -1,15 +1,29 @@ +import type { ComponentType, SVGProps } from "react"; import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; -import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react"; import netbirdFull from "@/assets/logos/netbird-full.svg"; + +// Brand glyphs from simpleicons.org (lucide deprecated its brand icons). +const GithubIcon = (props: SVGProps<SVGSVGElement>) => ( + <svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}> + <path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/> + </svg> +); +const SlackIcon = (props: SVGProps<SVGSVGElement>) => ( + <svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}> + <path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/> + </svg> +); import { useSettings } from "@/contexts/SettingsContext.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; function openUrl(url: string) { - void Browser.OpenURL(url).catch(() => window.open(url, "_blank")); + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); } export function SettingsAbout() { @@ -20,16 +34,23 @@ export function SettingsAbout() { const handleVersionClick = useAccentTrigger(); - const COMMUNITY_LINKS: { label: string; url: string; Icon: LucideIcon }[] = [ + const COMMUNITY_LINKS: { + label: string; + url: string; + Icon: ComponentType<SVGProps<SVGSVGElement>>; + iconClassName?: string; + }[] = [ { label: t("settings.about.community.github"), url: "https://github.com/netbirdio/netbird", - Icon: Github, + Icon: GithubIcon, + iconClassName: "h-3 w-3", }, { label: t("settings.about.community.slack"), url: "https://docs.netbird.io/slack-url", - Icon: Slack, + Icon: SlackIcon, + iconClassName: "h-3 w-3", }, { label: t("settings.about.community.forum"), @@ -63,7 +84,8 @@ export function SettingsAbout() { > <img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} /> <div className={"flex flex-col items-center gap-0.5 text-center"}> - <p + <button + type={"button"} className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"} onClick={handleVersionClick} > @@ -77,7 +99,7 @@ export function SettingsAbout() { ) : ( t("settings.about.client", { version: daemonVersion }) )} - </p> + </button> <p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}> {guiVersion === "development" ? ( <span> @@ -100,7 +122,7 @@ export function SettingsAbout() { <div className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"} > - {COMMUNITY_LINKS.map(({ label, url, Icon }) => ( + {COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => ( <button key={url} type={"button"} @@ -109,7 +131,7 @@ export function SettingsAbout() { "inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition" } > - <Icon className={"h-3.5 w-3.5"} /> + <Icon className={iconClassName ?? "h-3.5 w-3.5"} /> <span>{label}</span> </button> ))} diff --git a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx index 38324e2cf..08e3eac97 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx @@ -35,7 +35,7 @@ function triggerAccent() { root.render(<Accent onDone={cleanup} />); } -function Accent({ onDone }: { onDone: () => void }) { +function Accent({ onDone }: Readonly<{ onDone: () => void }>) { const canvasRef = useRef<HTMLCanvasElement>(null); const [visible, setVisible] = useState(false); @@ -94,14 +94,14 @@ function Accent({ onDone }: { onDone: () => void }) { }; raf = requestAnimationFrame(draw); - const timeout = window.setTimeout(() => { + const timeout = globalThis.setTimeout(() => { setVisible(false); - window.setTimeout(onDone, 500); + globalThis.setTimeout(onDone, 500); }, 9000); return () => { cancelAnimationFrame(raf); - window.clearTimeout(timeout); + globalThis.clearTimeout(timeout); window.removeEventListener("resize", resize); }; }, [onDone]); diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index 51d4d8e71..38c177a52 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { System } from "@wailsio/runtime"; import Button from "@/components/buttons/Button"; @@ -8,23 +8,16 @@ import { Label } from "@/components/typography/Label"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; import { useSettings } from "@/contexts/SettingsContext.tsx"; -// macOS: the Darwin utun control socket parses the digits after "utun" as the -// unit number, so the daemon (and the CLI's parseInterfaceName in -// client/cmd/up.go) only accepts utun<N>. -// Linux/Windows: no daemon-side validation; the Linux kernel caps names at -// IFNAMSIZ-1 = 15 chars and the safe charset across both is [A-Za-z0-9._-]. +// macOS daemon/CLI only accept utun<N> (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars. const IS_MAC = System.IsMac(); const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/; const INTERFACE_NAME_ERROR_KEY = IS_MAC ? "settings.advanced.interfaceName.errorMac" : "settings.advanced.interfaceName.error"; -// Port 0 means "let the daemon pick a random free port" (see the hint text). +// Port 0 lets the daemon pick a random free port. const PORT_MIN = 0; const PORT_MAX = 65535; -// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host -// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for -// IPv6 the daemon still needs 1280 on the path (RFC 8200), but that is not -// the validator's job to enforce. +// Mirrors client/iface/iface.go MinMTU / MaxMTU. const MTU_MIN = 576; const MTU_MAX = 8192; @@ -40,6 +33,15 @@ export function SettingsAdvanced() { }); const [saving, setSaving] = useState(false); + useEffect(() => { + setValues({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + preSharedKey: config.preSharedKey, + }); + }, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]); + const errors = useMemo(() => { const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {}; if (!INTERFACE_NAME_RE.test(values.interfaceName)) { @@ -55,11 +57,7 @@ export function SettingsAdvanced() { max: PORT_MAX, }); } - if ( - !Number.isInteger(values.mtu) || - values.mtu < MTU_MIN || - values.mtu > MTU_MAX - ) { + if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) { out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX }); } return out; @@ -89,9 +87,7 @@ export function SettingsAdvanced() { label={t("settings.advanced.interfaceName.label")} value={values.interfaceName} error={errors.interfaceName} - onChange={(e) => - setValues((v) => ({ ...v, interfaceName: e.target.value })) - } + onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))} /> <div className={"grid grid-cols-2 gap-4"}> <div> @@ -118,9 +114,7 @@ export function SettingsAdvanced() { max={MTU_MAX} value={values.mtu} error={errors.mtu} - onChange={(e) => - setValues((v) => ({ ...v, mtu: Number(e.target.value) })) - } + onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))} /> </div> </SectionGroup> @@ -128,17 +122,13 @@ export function SettingsAdvanced() { <SectionGroup title={t("settings.advanced.section.security")}> <div> <Label as={"div"}>{t("settings.advanced.psk.label")}</Label> - <HelpText> - {t("settings.advanced.psk.help")} - </HelpText> + <HelpText>{t("settings.advanced.psk.help")}</HelpText> <Input type={"password"} showPasswordToggle placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="} value={values.preSharedKey} - onChange={(e) => - setValues((v) => ({ ...v, preSharedKey: e.target.value })) - } + onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))} /> </div> </SectionGroup> diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx index 41360708c..d51dd9649 100644 --- a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -15,25 +15,13 @@ export function SettingsGeneral() { const { t } = useTranslation(); const { config, setField } = useSettings(); const { autostart, setAutostartEnabled } = useAutostartSetting(); - const { - mode, - setMode, - setUrl, - displayUrl, - showError, - canSave, - save, - checking, - unreachable, - } = useManagementUrl(); + const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = + useManagementUrl(); const inputRef = useRef<HTMLInputElement>(null); const prevMode = useRef(mode); useEffect(() => { - if ( - prevMode.current === ManagementMode.Cloud && - mode === ManagementMode.SelfHosted - ) { + if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) { inputRef.current?.focus(); } prevMode.current = mode; @@ -71,9 +59,7 @@ export function SettingsGeneral() { <div className={"flex items-start gap-3"}> <div className={"flex-1 min-w-0"}> <Label as={"div"}>{t("settings.general.management.label")}</Label> - <HelpText> - {t("settings.general.management.help")} - </HelpText> + <HelpText>{t("settings.general.management.help")}</HelpText> </div> <ManagementServerSwitch value={mode} onChange={setMode} /> </div> diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index 8f04c165e..ed0817d59 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -26,49 +26,49 @@ export const SettingsNavigation = () => { return ( <div className={"flex flex-col w-52 shrink-0 items-center select-none"}> - <VerticalTabs.List> - <VerticalTabs.Trigger - value={"general"} - icon={SlidersHorizontalIcon} - title={t("settings.tabs.general")} - /> - <VerticalTabs.Trigger - value={"network"} - icon={NetworkIcon} - title={t("settings.tabs.network")} - /> - <VerticalTabs.Trigger - value={"security"} - icon={ShieldIcon} - title={t("settings.tabs.security")} - /> - <VerticalTabs.Trigger - value={"profiles"} - icon={UserCircleIcon} - title={t("settings.tabs.profiles")} - /> - <VerticalTabs.Trigger - value={"ssh"} - icon={SquareTerminalIcon} - title={t("settings.tabs.ssh")} - /> - <VerticalTabs.Trigger - value={"advanced"} - icon={BoltIcon} - title={t("settings.tabs.advanced")} - /> - <VerticalTabs.Trigger - value={"troubleshooting"} - icon={LifeBuoyIcon} - title={t("settings.tabs.troubleshooting")} - /> - <VerticalTabs.Trigger - value={"about"} - icon={InfoIcon} - title={t("settings.tabs.about")} - adornment={aboutAdornment} - /> - </VerticalTabs.List> + <VerticalTabs.List> + <VerticalTabs.Trigger + value={"general"} + icon={SlidersHorizontalIcon} + title={t("settings.tabs.general")} + /> + <VerticalTabs.Trigger + value={"network"} + icon={NetworkIcon} + title={t("settings.tabs.network")} + /> + <VerticalTabs.Trigger + value={"security"} + icon={ShieldIcon} + title={t("settings.tabs.security")} + /> + <VerticalTabs.Trigger + value={"profiles"} + icon={UserCircleIcon} + title={t("settings.tabs.profiles")} + /> + <VerticalTabs.Trigger + value={"ssh"} + icon={SquareTerminalIcon} + title={t("settings.tabs.ssh")} + /> + <VerticalTabs.Trigger + value={"advanced"} + icon={BoltIcon} + title={t("settings.tabs.advanced")} + /> + <VerticalTabs.Trigger + value={"troubleshooting"} + icon={LifeBuoyIcon} + title={t("settings.tabs.troubleshooting")} + /> + <VerticalTabs.Trigger + value={"about"} + icon={InfoIcon} + title={t("settings.tabs.about")} + adornment={aboutAdornment} + /> + </VerticalTabs.List> </div> ); }; diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index 6c3950d37..0f2475551 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -7,10 +7,7 @@ import { isMacOS } from "@/lib/platform"; import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; import { VerticalTabs } from "@/components/VerticalTabs.tsx"; import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx"; -import { - AutostartSettingsProvider, - SettingsProvider, -} from "@/contexts/SettingsContext.tsx"; +import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx"; import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; @@ -22,21 +19,6 @@ import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; const EVENT_SETTINGS_OPEN = "netbird:settings:open"; -// The settings window mounts once at app startup (hidden) and stays at the -// single URL `/#/settings` forever — no SetURL between opens, so the -// `AppLayout` provider stack never re-mounts and we never see the -// `SettingsSkeleton` flash mid-reload. Tab is local state, driven by: -// - the `netbird:settings:open` Wails event from `WindowManager.OpenSettings` -// (sets the target tab, then Go calls `Show`/`Focus`); and -// - the same event with payload `"general"` from the close hook, so the -// window is already on General the next time Show fires (common case). -// In-window navigation state (e.g. the update-available header jump to About) -// still wins for that one render. -// -// The `h-12` draggable strip at the top accounts for the macOS -// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light -// buttons float over invisible title bar) and mirrors the main window's -// Header height so AppRightPanel ends up the same height in both windows. export const SettingsPage = () => { const location = useLocation(); const navState = location.state as { tab?: string } | null; @@ -55,72 +37,63 @@ export const SettingsPage = () => { return ( <> {isMacOS() ? ( - <div - className={ - "wails-draggable cursor-default select-none h-12 shrink-0" - } - /> + <div className={"wails-draggable cursor-default select-none h-12 shrink-0"} /> ) : ( <div className={"h-px shrink-0 bg-nb-gray-920/0"} /> )} - <VerticalTabs - value={active} - onValueChange={setActive} - > + <VerticalTabs value={active} onValueChange={setActive}> <SettingsNavigation /> <AppRightPanel> <AutostartSettingsProvider> - <ScrollArea.Root - key={active} - type={"auto"} - className={"flex-1 min-h-0 overflow-hidden"} - > - <ScrollArea.Viewport className={"h-full w-full"}> - <div className={"py-8 px-7"}> - <SettingsProvider> - <VerticalTabs.Content value={"general"}> - <SettingsGeneral /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"network"}> - <SettingsNetwork /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"security"}> - <SettingsSecurity /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"profiles"}> - <ProfilesTab /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"ssh"}> - <SettingsSSH /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"advanced"}> - <SettingsAdvanced /> - </VerticalTabs.Content> - <VerticalTabs.Content - value={"troubleshooting"} - > - <SettingsTroubleshooting /> - </VerticalTabs.Content> - <VerticalTabs.Content value={"about"}> - <SettingsAbout /> - </VerticalTabs.Content> - </SettingsProvider> - </div> - </ScrollArea.Viewport> - <ScrollArea.Scrollbar - orientation={"vertical"} - className={cn( - "flex select-none touch-none transition-colors", - "w-1.5 bg-transparent py-1", - )} + <ScrollArea.Root + key={active} + type={"auto"} + className={"flex-1 min-h-0 overflow-hidden"} > - <ScrollArea.Thumb - className={ - "flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" - } - /> - </ScrollArea.Scrollbar> - </ScrollArea.Root> + <ScrollArea.Viewport className={"h-full w-full"}> + <div className={"py-8 px-7"}> + <SettingsProvider> + <VerticalTabs.Content value={"general"}> + <SettingsGeneral /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"network"}> + <SettingsNetwork /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"security"}> + <SettingsSecurity /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"profiles"}> + <ProfilesTab /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"ssh"}> + <SettingsSSH /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"advanced"}> + <SettingsAdvanced /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"troubleshooting"}> + <SettingsTroubleshooting /> + </VerticalTabs.Content> + <VerticalTabs.Content value={"about"}> + <SettingsAbout /> + </VerticalTabs.Content> + </SettingsProvider> + </div> + </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> </AutostartSettingsProvider> </AppRightPanel> </VerticalTabs> diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index acb4565cd..18c1a4682 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -50,7 +50,10 @@ export function SettingsSSH() { /> </SectionGroup> - <SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}> + <SectionGroup + title={t("settings.ssh.section.capabilities")} + disabled={!isSSHServerEnabled} + > <FancyToggleSwitch value={config.enableSshRoot} onChange={(v) => setField("enableSshRoot", v)} @@ -77,7 +80,10 @@ export function SettingsSSH() { /> </SectionGroup> - <SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}> + <SectionGroup + title={t("settings.ssh.section.authentication")} + disabled={!isSSHServerEnabled} + > <FancyToggleSwitch value={!config.disableSshAuth} onChange={(v) => setField("disableSshAuth", !v)} @@ -92,9 +98,7 @@ export function SettingsSSH() { > <div className={"flex-1 max-w-md"}> <Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label> - <HelpText margin={false}> - {t("settings.ssh.jwtTtl.help")} - </HelpText> + <HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText> </div> <div className={"w-40 shrink-0"}> <Input diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx index cfaa40246..49809a7cb 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSection.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -18,10 +18,6 @@ export const SectionGroup = ({ </section> ); -// SettingsBottomBar renders the floating action bar at the bottom of a -// settings tab (Save Changes / Add Profile / Create Bundle). It pairs the -// absolutely positioned bar with an in-flow spacer of the same height so -// scrollable content above doesn't end up hidden behind the bar. export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( <> <div className={"h-[4rem] shrink-0"} aria-hidden /> diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx index f6cbe0b52..50288e3cc 100644 --- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -38,11 +38,7 @@ export function SettingsTroubleshooting() { if (stage.kind === "done") { return ( - <DoneResult - result={stage.result} - uploaded={stage.uploadAttempted} - onClose={reset} - /> + <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} /> ); } if (stage.kind !== "idle") { @@ -115,7 +111,7 @@ export function SettingsTroubleshooting() { ); } -function CenteredPanel({ children }: { children: ReactNode }) { +function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) { return ( <div className={ @@ -127,7 +123,10 @@ function CenteredPanel({ children }: { children: ReactNode }) { ); } -function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) { +function ProgressSection({ + stage, + onCancel, +}: Readonly<{ stage: DebugStage; onCancel: () => void }>) { const { t } = useTranslation(); const cancelling = stage.kind === "cancelling"; return ( @@ -135,9 +134,7 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () <SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} /> <div className={"flex flex-col items-center gap-2 max-w-xs"}> - <DialogHeading className={"text-balance"}> - {stageLabel(stage, t)} - </DialogHeading> + <DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading> <DialogDescription> {t("settings.troubleshooting.progress.description")} </DialogDescription> @@ -163,17 +160,19 @@ function DoneResult({ result, uploaded, onClose, -}: { +}: Readonly<{ result: DebugBundleResult; uploaded: boolean; onClose: () => void; -}) { +}>) { const { t } = useTranslation(); const showKey = uploaded && Boolean(result.uploadedKey); const uploadFailed = uploaded && !result.uploadedKey; const onRevealPath = () => { if (!result.path) return; - void DebugSvc.RevealFile(result.path).catch(() => {}); + DebugSvc.RevealFile(result.path).catch((err: unknown) => + console.error("reveal debug bundle file", err), + ); }; return ( <CenteredPanel> @@ -252,12 +251,7 @@ function DoneResult({ </Button> ) )} - <Button - variant={"secondary"} - size={"md"} - className={"w-full"} - onClick={onClose} - > + <Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}> {t("common.close")} </Button> </DialogActions> @@ -265,7 +259,10 @@ function DoneResult({ ); } -const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => { +const stageLabel = ( + stage: DebugStage, + t: (key: string, options?: Record<string, unknown>) => string, +): string => { switch (stage.kind) { case "preparing-trace": return t("settings.troubleshooting.stage.preparingTrace"); diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx index b1bcaafcd..cff0e4de5 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -8,8 +8,7 @@ import { import { SetConfigParams } from "@bindings/services/models.js"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { errorDialog } from "@/lib/dialogs"; -import { formatErrorMessage } from "@/lib/errors"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; import i18next from "@/lib/i18n"; import { isCloudManagementUrl } from "@/hooks/useManagementUrl"; import { WelcomeStepTray } from "./WelcomeStepTray"; @@ -17,18 +16,8 @@ import { WelcomeStepManagement } from "./WelcomeStepManagement"; const WINDOW_WIDTH = 360; -// WelcomeStep is the orchestrator's state machine. The transitions: -// tray → management (if eligible) → finish -// tray → finish (otherwise) -// Login itself is no longer part of onboarding — once the welcome window -// closes the user lands in the main window and clicks Connect there. type WelcomeStep = "tray" | "management"; -// shouldShowManagementStep asks the user about Cloud vs self-hosted only -// on a pristine setup — default profile, no email recorded (no successful -// login yet), and the management URL is either unset or already the cloud -// default. Any other state means the user (or a previous run) already -// made a deliberate choice and we shouldn't second-guess it. function shouldShowManagementStep( activeProfile: string, email: string, @@ -39,10 +28,6 @@ function shouldShowManagementStep( return isCloudManagementUrl(managementUrl); } -// initial flow snapshot resolved at mount. Held in component state so the -// step-2 management input can hydrate from initialUrl, and so the -// "should we even show step 2" check is computed once (the user can't -// change profile / URL from inside the welcome window). type InitialState = { profileName: string; username: string; @@ -54,24 +39,12 @@ export default function WelcomeDialog() { const [step, setStep] = useState<WelcomeStep>("tray"); const [initial, setInitial] = useState<InitialState | null>(null); const [closing, setClosing] = useState(false); - // ready=false until the daemon probe resolves — keeps the window - // Hidden so neither the empty padding-only frame (Linux/GNOME paints - // through) nor a placeholder div leaks onto screen. const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null); - // Probe daemon state on mount: who's the active profile, do they - // have an email recorded, and what management URL is configured? - // Errors fall through to "skip the management step" so a daemon - // hiccup never blocks onboarding entirely. useEffect(() => { let cancelled = false; (async () => { try { - // Resolve username + active profile first so GetConfig + List - // can target the actual profile (passing empty strings would - // work today since the daemon falls back to the default - // profile, but being explicit shields us from future - // changes to that fallback). const [username, active] = await Promise.all([ ProfilesSvc.Username(), ProfilesSvc.GetActive(), @@ -97,8 +70,6 @@ export default function WelcomeDialog() { } catch (e) { console.error("welcome: initial probe failed", e); if (cancelled) return; - // Conservative fallback: skip the management step rather - // than block onboarding behind a daemon hiccup. setInitial({ profileName: "default", username: "", @@ -112,10 +83,6 @@ export default function WelcomeDialog() { }; }, []); - // finish persists the onboarding flag, opens the main window so the - // user has somewhere to land, and closes the welcome window. Called - // at the end of every successful flow (tray-only and tray→management - // alike). The Connect button in the main window picks up from here. const finish = useCallback(async () => { if (closing) return; setClosing(true); @@ -148,10 +115,7 @@ export default function WelcomeDialog() { async (url: string) => { if (!initial) return; try { - // SetConfig is a partial update — pointer fields left - // undefined are preserved (services/settings.go). We only - // touch managementUrl; adminUrl stays empty here because - // the daemon already has its own value loaded. + // SetConfig is a partial update — undefined fields are preserved Go-side. await SettingsSvc.SetConfig( new SetConfigParams({ profileName: initial.profileName, diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx index 7a260c46d..37843a2fd 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx @@ -18,16 +18,14 @@ import { cn } from "@/lib/cn.ts"; import { isMacOS } from "@/lib/platform.ts"; type WelcomeStepManagementProps = { - // initialUrl is the management URL the daemon is already configured - // with (empty / cloud-default both render as Cloud selected). initialUrl: string; - // onContinue is invoked with the URL the user wants to persist. The - // parent owns the actual Settings.SetConfig call so the dialog stays - // free of context dependencies. onContinue: (url: string) => Promise<void>; }; -export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) { +export function WelcomeStepManagement({ + initialUrl, + onContinue, +}: Readonly<WelcomeStepManagementProps>) { const { t } = useTranslation(); const startsCloud = isCloudManagementUrl(initialUrl); const [mode, setMode] = useState<ManagementMode>( @@ -35,21 +33,13 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan ); const [url, setUrl] = useState(startsCloud ? "" : initialUrl); const [syntaxError, setSyntaxError] = useState<string | null>(null); - // unreachable: soft warning. Continue stays enabled — user can confirm - // they typed it right and proceed (matches self-hosted-behind-internal- - // DNS / VPN scenarios where the in-app fetch would false-negative). const [unreachable, setUnreachable] = useState(false); const [checking, setChecking] = useState(false); const trimmedUrl = url.trim(); const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl); - // Continue is no longer disabled for an empty / invalid self-hosted - // URL; a Continue click in that state focuses the input and renders - // an inline error so the user actively notices what's missing. const inputRef = useRef<HTMLInputElement | null>(null); - // Reset inline error/warning whenever the user edits the URL or flips - // mode — otherwise the warning lingers next to a just-corrected value. useEffect(() => { setSyntaxError(null); setUnreachable(false); @@ -58,9 +48,6 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan const handleContinue = useCallback(async () => { if (checking) return; if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) { - // Empty or syntactically invalid URL — Continue stays enabled - // so the click registers; surface the error inline and focus - // the input so the user has somewhere to fix it. setSyntaxError(t("welcome.management.urlInvalid")); inputRef.current?.focus(); return; @@ -69,14 +56,11 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizeManagementUrl(trimmedUrl); - if (mode === ManagementMode.SelfHosted) { + if (mode === ManagementMode.SelfHosted && !unreachable) { setChecking(true); const reachable = await checkManagementUrlReachable(target); setChecking(false); - // First failed check: show soft warning + bail. A second click - // with the same URL skips the check (unreachable still true) - // so the user can proceed if they're sure. - if (!reachable && !unreachable) { + if (!reachable) { setUnreachable(true); return; } @@ -84,14 +68,10 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan try { await onContinue(target); } catch (e) { - // Parent surfaces save errors via errorDialog; keep a console - // breadcrumb but don't double-render. console.error("save management url:", e); } }, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]); - // Syntax problems are hard errors (red); an unreachable-but-valid URL is - // a soft, non-blocking caveat (orange). const inputError = syntaxError ?? undefined; const inputWarning = useMemo( () => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined), diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 6702b0d90..20f686659 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -8,12 +8,7 @@ import trayScreenshotDarwin from "@/assets/img/tray-darwin.png"; import trayScreenshotWindows from "@/assets/img/tray-windows.png"; import trayScreenshotLinux from "@/assets/img/tray-linux.png"; -// trayScreenshotForOS picks the marketing screenshot that shows the -// NetBird tray icon in its native menu/task bar — so the onboarding pitch -// matches the chrome the user will actually be hunting for. Evaluated -// inside the component so initPlatform() has finished by the time -// isMacOS/isWindows run (the static imports above only load the bytes, -// no platform check). +// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows. function trayScreenshotForOS(): string { if (isMacOS()) return trayScreenshotDarwin; if (isWindows()) return trayScreenshotWindows; @@ -24,7 +19,7 @@ type WelcomeStepTrayProps = { onContinue: () => void; }; -export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) { +export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) { const { t } = useTranslation(); const trayScreenshot = trayScreenshotForOS(); From 9049974f261eed25dfdd9eb91fc781c6cf928a38 Mon Sep 17 00:00:00 2001 From: Eduard Gert <kontakt@eduardgert.de> Date: Tue, 9 Jun 2026 16:33:30 +0200 Subject: [PATCH 4/7] lint --- client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 84b4f05e3..0e46055e6 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -103,10 +103,7 @@ export const Peers = () => { return ordered.filter((p) => { if (statusFilter === "online" && !isOnline(p.connStatus)) return false; if (statusFilter === "offline" && isOnline(p.connStatus)) return false; - if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) { - return false; - } - return true; + return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q); }); }, [ordered, search, statusFilter]); From 4e1e7f95188561ddf66b0de293727f995263e19c Mon Sep 17 00:00:00 2001 From: Eduard Gert <kontakt@eduardgert.de> Date: Tue, 9 Jun 2026 18:04:59 +0200 Subject: [PATCH 5/7] add common languages --- client/ui/frontend/CLAUDE.md | 4 +- client/ui/i18n/TRANSLATING.md | 133 +++ client/ui/i18n/locales/_index.json | 8 +- client/ui/i18n/locales/de/common.json | 170 +-- client/ui/i18n/locales/es/common.json | 1262 ++++++++++++++++++++++ client/ui/i18n/locales/fr/common.json | 1262 ++++++++++++++++++++++ client/ui/i18n/locales/hu/common.json | 206 ++-- client/ui/i18n/locales/it/common.json | 1262 ++++++++++++++++++++++ client/ui/i18n/locales/pt/common.json | 1262 ++++++++++++++++++++++ client/ui/i18n/locales/ru/common.json | 1262 ++++++++++++++++++++++ client/ui/i18n/locales/zh-CN/common.json | 1262 ++++++++++++++++++++++ 11 files changed, 7903 insertions(+), 190 deletions(-) create mode 100644 client/ui/i18n/TRANSLATING.md create mode 100644 client/ui/i18n/locales/es/common.json create mode 100644 client/ui/i18n/locales/fr/common.json create mode 100644 client/ui/i18n/locales/it/common.json create mode 100644 client/ui/i18n/locales/pt/common.json create mode 100644 client/ui/i18n/locales/ru/common.json create mode 100644 client/ui/i18n/locales/zh-CN/common.json diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 1c5b85797..bd2de0a10 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -138,7 +138,9 @@ Outside React (module-scope event handlers, error titles) import the instance di **Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI). -**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json`, append the row to `_index.json`, and drop the matching `<code>.svg` into `src/assets/flags/1x1/` (source from the dashboard repo's same-name folder). `LanguagePicker.tsx` eager-globs that flags directory at build time, so **only check in flags for languages we actually ship**. `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale. +**Translating bundles.** `client/ui/i18n/TRANSLATING.md` is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/`\n`/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change. + +**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` (follow `TRANSLATING.md`) and append the row to `_index.json`. No flag asset is needed — `LanguagePicker.tsx` deliberately ships no flags ("flags represent countries, not languages"). `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale. **What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated. diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md new file mode 100644 index 000000000..bcf29ce83 --- /dev/null +++ b/client/ui/i18n/TRANSLATING.md @@ -0,0 +1,133 @@ +# Translating the NetBird UI + +A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). + +**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."* + +> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. + +--- + +## What NetBird is + +A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard. + +**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute. + +**The vocabulary you'll meet:** + +| Term | What it means here | +|---|---| +| **Peer** | A device on the network (laptop, server, phone) | +| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") | +| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway | +| **Profile** | A saved connection identity you can switch between | +| **Daemon** | The background service the UI talks to | +| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) | +| **Relay** | Forwards traffic when two peers can't connect directly | +| **Rosenpass** | Post-quantum security layered over WireGuard® | +| **Handshake** | The periodic WireGuard® key sync between peers | + +--- + +## The files + +``` +i18n/locales/_index.json shipped-language list +i18n/locales/en/common.json source of truth — message + description +i18n/locales/<code>/common.json a target — message only +``` + +Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**. + +| ✅ Do | ❌ Don't | +|---|---| +| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) | +| Put **only `message`** in target bundles | Copy `description` into a target bundle | +| Give every key a non-empty `message` | Leave keys missing or empty | +| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON | + +--- + +## Hard rules — get these exactly right + +These are the usual ways a translation *breaks the app*, not just reads oddly. + +| ✅ Do | ❌ Don't | +|---|---| +| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) | +| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder | +| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) | +| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags | + +**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply. + +**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form. + +--- + +## Glossary + +**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers. + +When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack. + +**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`. + +**Tier C — judgment.** One rule decides every term: + +> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it. + +Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it. + +Two checks before you commit a term: + +- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google). +- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it. + +--- + +## Style + +| ✅ Do | ❌ Don't | +|---|---| +| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address | +| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight | +| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) | +| Translate a term the **same way everywhere** | Vary wording for the same concept across screens | + +Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them. + +A few habits that keep a bundle reading like one product rather than a word-for-word port: + +- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque. +- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling. +- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected. +- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously. + +--- + +## Procedure + +**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`). + +**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check. + +--- + +## QA before you finish + +- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields +- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept +- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language) +- [ ] Buttons & tray short · locale punctuation and capitalization applied +- [ ] New language added to `_index.json` +- [ ] **Tested in the running app** ↓ + +--- + +## Test it in the app + +A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. + +How to run the app and switch language: see `../CLAUDE.md` and `../frontend/CLAUDE.md`. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 4fa5369a5..58b5c484f 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -2,6 +2,12 @@ "languages": [ {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, {"code": "de", "displayName": "Deutsch", "englishName": "German"}, - {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"} + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}, + {"code": "ru", "displayName": "Русский", "englishName": "Russian"}, + {"code": "es", "displayName": "Español", "englishName": "Spanish"}, + {"code": "fr", "displayName": "Français", "englishName": "French"}, + {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, + {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"} ] } diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 4cf31358e..435c46cf7 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -3,7 +3,7 @@ "message": "NetBird" }, "tray.status.disconnected": { - "message": "Getrennt" + "message": "Nicht verbunden" }, "tray.status.daemonUnavailable": { "message": "Nicht aktiv" @@ -15,7 +15,7 @@ "message": "Verbunden" }, "tray.status.connecting": { - "message": "Verbinde" + "message": "Wird verbunden" }, "tray.status.needsLogin": { "message": "Anmeldung erforderlich" @@ -60,7 +60,7 @@ "message": "Trennen" }, "tray.menu.exitNode": { - "message": "Exit-Node" + "message": "Exit Node" }, "tray.menu.networks": { "message": "Ressourcen" @@ -72,7 +72,7 @@ "message": "Profile verwalten" }, "tray.menu.settings": { - "message": "Einstellungen..." + "message": "Einstellungen …" }, "tray.menu.debugBundle": { "message": "Debug-Paket erstellen" @@ -96,7 +96,7 @@ "message": "Version {version} installieren" }, "tray.menu.guiVersion": { - "message": "Oberfläche: {version}" + "message": "GUI: {version}" }, "tray.menu.daemonVersion": { "message": "Daemon: {version}" @@ -129,7 +129,7 @@ "message": "Wechsel zu {profile} fehlgeschlagen" }, "notify.error.exitNode": { - "message": "Exit-Node {name} konnte nicht aktualisiert werden" + "message": "Exit Node {name} konnte nicht aktualisiert werden" }, "notify.sessionExpired.title": { "message": "NetBird-Sitzung abgelaufen" @@ -162,10 +162,10 @@ "message": "Ihre Sitzung wurde erneuert." }, "notify.sessionDeadlineRejected.title": { - "message": "Sitzungsfrist abgelehnt" + "message": "Ungültige Sitzungsablaufzeit" }, "notify.sessionDeadlineRejected.body": { - "message": "Der Server hat eine ungültige Sitzungsfrist übermittelt. Bitte melden Sie sich erneut an." + "message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an." }, "common.cancel": { "message": "Abbrechen" @@ -222,22 +222,22 @@ "message": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter." }, "notConnected.title": { - "message": "Getrennt" + "message": "Nicht verbunden" }, "notConnected.description": { "message": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen." }, "connect.status.disconnected": { - "message": "Getrennt" + "message": "Nicht verbunden" }, "connect.status.connecting": { - "message": "Verbindet…" + "message": "Wird verbunden…" }, "connect.status.connected": { "message": "Verbunden" }, "connect.status.disconnecting": { - "message": "Trennt…" + "message": "Wird getrennt…" }, "connect.status.daemonUnavailable": { "message": "Daemon nicht verfügbar" @@ -267,7 +267,7 @@ "message": "{active} von {total} aktiv" }, "nav.exitNode.title": { - "message": "Exit-Nodes" + "message": "Exit Nodes" }, "nav.exitNode.none": { "message": "Nicht aktiv" @@ -281,18 +281,6 @@ "header.togglePanel": { "message": "Seitenleiste umschalten" }, - "header.menu.settings": { - "message": "Einstellungen …" - }, - "header.menu.defaultView": { - "message": "Standardansicht" - }, - "header.menu.advancedView": { - "message": "Erweiterte Ansicht" - }, - "header.menu.updateAvailable": { - "message": "Update verfügbar" - }, "profile.selector.loading": { "message": "Lädt…" }, @@ -323,24 +311,6 @@ "profile.selector.switchTo": { "message": "Zu diesem Profil wechseln" }, - "profile.dropdown.activeProfile": { - "message": "Aktives Profil" - }, - "profile.dropdown.switchProfile": { - "message": "Profil wechseln" - }, - "profile.dropdown.noEmail": { - "message": "Andere" - }, - "profile.dropdown.addProfile": { - "message": "Profil hinzufügen" - }, - "profile.dropdown.manageProfiles": { - "message": "Profile verwalten" - }, - "profile.dropdown.settings": { - "message": "Einstellungen" - }, "profile.dialog.title": { "message": "Neues Profil" }, @@ -353,18 +323,30 @@ "profile.dialog.placeholder": { "message": "z. B. Arbeit" }, - "profile.dialog.required": { - "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" - }, "profile.dialog.submit": { "message": "Profil hinzufügen" }, + "profile.dialog.required": { + "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" + }, "profile.dialog.managementHelp": { "message": "NetBird Cloud oder Ihr eigener Server." }, "profile.dialog.urlUnreachable": { "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist." }, + "header.menu.settings": { + "message": "Einstellungen …" + }, + "header.menu.defaultView": { + "message": "Standardansicht" + }, + "header.menu.advancedView": { + "message": "Erweiterte Ansicht" + }, + "header.menu.updateAvailable": { + "message": "Update verfügbar" + }, "profile.switch.title": { "message": "Zu Profil \"{name}\" wechseln?" }, @@ -410,6 +392,42 @@ "profile.error.loadTitle": { "message": "Laden der Profile fehlgeschlagen" }, + "profile.dropdown.activeProfile": { + "message": "Aktives Profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profil wechseln" + }, + "profile.dropdown.noEmail": { + "message": "Andere" + }, + "profile.dropdown.addProfile": { + "message": "Profil hinzufügen" + }, + "profile.dropdown.manageProfiles": { + "message": "Profile verwalten" + }, + "profile.dropdown.settings": { + "message": "Einstellungen" + }, + "settings.profiles.section.profiles": { + "message": "Profile" + }, + "settings.profiles.intro": { + "message": "Verwalten Sie mehrere NetBird-Profile parallel, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." + }, + "settings.profiles.addProfile": { + "message": "Profil hinzufügen" + }, + "settings.profiles.active": { + "message": "Aktiv" + }, + "settings.profiles.emptyTitle": { + "message": "Keine Profile" + }, + "settings.profiles.emptyDescription": { + "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." + }, "settings.error.loadTitle": { "message": "Laden der Einstellungen fehlgeschlagen" }, @@ -428,12 +446,12 @@ "settings.tabs.security": { "message": "Sicherheit" }, - "settings.tabs.ssh": { - "message": "SSH" - }, "settings.tabs.profiles": { "message": "Profile" }, + "settings.tabs.ssh": { + "message": "SSH" + }, "settings.tabs.advanced": { "message": "Erweitert" }, @@ -446,24 +464,6 @@ "settings.tabs.updateAvailable": { "message": "Update verfügbar" }, - "settings.profiles.section.profiles": { - "message": "Profile" - }, - "settings.profiles.intro": { - "message": "Halten Sie separate NetBird-Identitäten nebeneinander, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." - }, - "settings.profiles.addProfile": { - "message": "Profil hinzufügen" - }, - "settings.profiles.active": { - "message": "Aktiv" - }, - "settings.profiles.emptyTitle": { - "message": "Keine Profile" - }, - "settings.profiles.emptyDescription": { - "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." - }, "settings.general.section.general": { "message": "Allgemein" }, @@ -540,7 +540,7 @@ "message": "Routing & DNS" }, "settings.network.lazy.label": { - "message": "Verzögerte Verbindungen" + "message": "Lazy-Verbindungen" }, "settings.network.lazy.help": { "message": "Statt durchgehend aktive Verbindungen zu halten, aktiviert NetBird sie bei Bedarf anhand von Aktivität oder Signalisierung." @@ -597,7 +597,7 @@ "message": "Quantenresistenz aktivieren" }, "settings.security.rosenpass.help": { - "message": "Einen post-quanten Schlüsselaustausch via Rosenpass zusätzlich zu WireGuard® hinzufügen." + "message": "Einen Post-Quanten-Schlüsselaustausch über Rosenpass zusätzlich zu WireGuard® hinzufügen." }, "settings.security.rosenpassPermissive.label": { "message": "Permissiven Modus aktivieren" @@ -669,7 +669,7 @@ "message": "Name" }, "settings.advanced.interfaceName.error": { - "message": "Verwende 1–15 Buchstaben, Ziffern, Punkt, Bindestrich oder Unterstrich." + "message": "Verwenden Sie 1–15 Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche." }, "settings.advanced.interfaceName.errorMac": { "message": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100)." @@ -678,7 +678,7 @@ "message": "Port" }, "settings.advanced.port.error": { - "message": "Gib einen Port zwischen {min} und {max} ein." + "message": "Geben Sie einen Port zwischen {min} und {max} ein." }, "settings.advanced.port.help": { "message": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet." @@ -687,13 +687,13 @@ "message": "MTU" }, "settings.advanced.mtu.error": { - "message": "Gib eine MTU zwischen {min} und {max} ein." + "message": "Geben Sie einen MTU-Wert zwischen {min} und {max} ein." }, "settings.advanced.psk.label": { "message": "Pre-shared Key" }, "settings.advanced.psk.help": { - "message": "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." + "message": "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": { "message": "Debug-Paket" @@ -735,7 +735,7 @@ "message": "Minute(n)" }, "settings.troubleshooting.create": { - "message": "Paket erstellen" + "message": "Debug-Paket erstellen" }, "settings.troubleshooting.progress.description": { "message": "Logs, Systemdetails und Verbindungszustand werden gesammelt. Dies dauert in der Regel einen Moment — lassen Sie dieses Fenster geöffnet, bis es abgeschlossen ist." @@ -801,10 +801,10 @@ "message": "[Entwicklung]" }, "settings.about.gui": { - "message": "Oberfläche v{version}" + "message": "GUI v{version}" }, "settings.about.guiName": { - "message": "Oberfläche" + "message": "GUI" }, "settings.about.copyright": { "message": "© {year} NetBird. Alle Rechte vorbehalten." @@ -864,10 +864,10 @@ "message": "NetBird sucht im Hintergrund nach Updates." }, "update.card.changelog": { - "message": "Änderungsprotokoll" + "message": "Changelog" }, "update.card.onLatestVersion": { - "message": "Du verwendest die neueste Version" + "message": "Sie verwenden die neueste Version" }, "update.header.tooltip": { "message": "Update verfügbar" @@ -924,7 +924,7 @@ "message": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version." }, "update.page.status.running": { - "message": "Aktualisiert" + "message": "Wird aktualisiert" }, "update.page.status.timeout": { "message": "Zeitüberschreitung beim Update. Bitte erneut versuchen." @@ -948,7 +948,7 @@ "message": "Bitte schließen Sie dieses Fenster nicht." }, "update.page.updating": { - "message": "Aktualisiert…" + "message": "Wird aktualisiert…" }, "update.page.complete": { "message": "Update abgeschlossen" @@ -990,7 +990,7 @@ "message": "NetBird einrichten" }, "welcome.management.description": { - "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben." + "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie „Self-hosted“, wenn Sie einen eigenen NetBird-Server haben." }, "welcome.management.cloud.title": { "message": "NetBird Cloud" @@ -999,7 +999,7 @@ "message": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig." }, "welcome.management.selfHosted.title": { - "message": "Selbst gehostet" + "message": "Self-hosted" }, "welcome.management.selfHosted.description": { "message": "Verbindung zu Ihrem eigenen Management-Server." @@ -1137,10 +1137,10 @@ "message": "Verbunden" }, "peers.status.connecting": { - "message": "Verbinde" + "message": "Wird verbunden" }, "peers.status.disconnected": { - "message": "Getrennt" + "message": "Nicht verbunden" }, "peers.details.relayAddress": { "message": "Relay" @@ -1248,7 +1248,7 @@ "message": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." }, "error.invalid_setup_key": { - "message": "Der Setup-Schlüssel fehlt oder ist ungültig." + "message": "Der Setup-Key fehlt oder ist ungültig." }, "error.permission_denied": { "message": "Die Anmeldung wurde vom Server abgelehnt." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json new file mode 100644 index 000000000..5186747a4 --- /dev/null +++ b/client/ui/i18n/locales/es/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "No está en ejecución" + }, + "tray.status.error": { + "message": "Error" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requerido" + }, + "tray.status.loginFailed": { + "message": "Error al iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión expirada" + }, + "tray.session.expiresIn": { + "message": "La sesión expira en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de salida" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfiles" + }, + "tray.menu.manageProfiles": { + "message": "Gestionar perfiles" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de diagnóstico" + }, + "tray.menu.about": { + "message": "Ayuda y soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Solucionar problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar la última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar la versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Salir de NetBird" + }, + "notify.update.title": { + "message": "Actualización de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} está disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Su administrador requiere esta actualización." + }, + "notify.error.title": { + "message": "Error" + }, + "notify.error.connect": { + "message": "Error al conectar" + }, + "notify.error.disconnect": { + "message": "Error al desconectar" + }, + "notify.error.switchProfile": { + "message": "Error al cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Error al actualizar el nodo de salida {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión de NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "Su sesión de NetBird ha expirado. Inicie sesión de nuevo." + }, + "notify.sessionWarning.title": { + "message": "La sesión expira pronto" + }, + "notify.sessionWarning.body": { + "message": "Su sesión de NetBird expira en {remaining}. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Su sesión de NetBird está a punto de expirar. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.extend": { + "message": "Renovar ahora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Error al renovar la sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "Su sesión se ha renovado." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Plazo de sesión rechazado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Guardar" + }, + "common.saveChanges": { + "message": "Guardar cambios" + }, + "common.saving": { + "message": "Guardando…" + }, + "common.close": { + "message": "Cerrar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Mostrar u ocultar la contraseña" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Disminuir" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Añadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "No se encontraron resultados" + }, + "common.noResults.description": { + "message": "No se encontraron resultados. Pruebe con otro término de búsqueda o cambie los filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conéctese primero a NetBird para ver información detallada sobre sus peers, recursos de red y nodos de salida." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon no disponible" + }, + "connect.status.loginRequired": { + "message": "Inicio de sesión requerido" + }, + "connect.error.loginTitle": { + "message": "Error al iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Error al conectar" + }, + "connect.error.disconnectTitle": { + "message": "Error al desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de salida" + }, + "nav.exitNode.none": { + "message": "Inactivo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Mostrar u ocultar el panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sin perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nombre..." + }, + "profile.selector.emptyTitle": { + "message": "No se encontraron perfiles" + }, + "profile.selector.emptyDescription": { + "message": "Pruebe con otro término de búsqueda o cree un perfil nuevo." + }, + "profile.selector.newProfile": { + "message": "Nuevo perfil" + }, + "profile.selector.moreOptions": { + "message": "Más opciones" + }, + "profile.selector.deregister": { + "message": "Anular registro" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.dialog.title": { + "message": "Introduzca el nombre del perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nombre del perfil" + }, + "profile.dialog.description": { + "message": "Asigne un nombre fácil de identificar a su perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ej. trabajo" + }, + "profile.dialog.submit": { + "message": "Añadir perfil" + }, + "profile.dialog.required": { + "message": "Introduzca un nombre de perfil, p. ej. trabajo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud o su propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o añada el perfil de todos modos si está seguro de que es correcta." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización disponible" + }, + "profile.switch.title": { + "message": "¿Cambiar el perfil a «{name}»?" + }, + "profile.switch.message": { + "message": "¿Seguro que desea cambiar de perfil?\nSu perfil actual se desconectará." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "¿Anular el registro del perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "¿Seguro que desea anular el registro de este perfil?\nDeberá iniciar sesión de nuevo para usarlo." + }, + "profile.deregister.confirm": { + "message": "Anular registro" + }, + "profile.delete.title": { + "message": "¿Eliminar el perfil «{name}»?" + }, + "profile.delete.message": { + "message": "¿Seguro que desea eliminar este perfil?\nEsta acción no se puede deshacer." + }, + "profile.delete.disabledActive": { + "message": "Los perfiles activos no se pueden eliminar. Cambie a otro antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "El perfil predeterminado no se puede eliminar." + }, + "profile.error.switchTitle": { + "message": "Error al cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Error al anular el registro del perfil" + }, + "profile.error.deleteTitle": { + "message": "Error al eliminar el perfil" + }, + "profile.error.createTitle": { + "message": "Error al crear el perfil" + }, + "profile.error.loadTitle": { + "message": "Error al cargar los perfiles" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Otro" + }, + "profile.dropdown.addProfile": { + "message": "Añadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestionar perfiles" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfiles" + }, + "settings.profiles.intro": { + "message": "Mantenga identidades de NetBird independientes en paralelo, por ejemplo cuentas de trabajo y personales, o distintos servidores de gestión. Añada, anule el registro o elimine perfiles a continuación." + }, + "settings.profiles.addProfile": { + "message": "Añadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sin perfiles" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de gestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Error al cargar la configuración" + }, + "settings.error.saveTitle": { + "message": "Error al guardar la configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Error en el paquete de diagnóstico" + }, + "settings.tabs.general": { + "message": "General" + }, + "settings.tabs.network": { + "message": "Red" + }, + "settings.tabs.security": { + "message": "Seguridad" + }, + "settings.tabs.profiles": { + "message": "Perfiles" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Solución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización disponible" + }, + "settings.general.section.general": { + "message": "General" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar al iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece una conexión automáticamente cuando se inicia el servicio." + }, + "settings.general.notifications.label": { + "message": "Notificaciones de escritorio" + }, + "settings.general.notifications.help": { + "message": "Muestra notificaciones de escritorio para nuevas actualizaciones y eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar la interfaz de NetBird al iniciar sesión" + }, + "settings.general.autostart.help": { + "message": "Inicia la interfaz de NetBird automáticamente al iniciar sesión. Esto afecta solo a la interfaz gráfica, no al servicio en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Error al cambiar el inicio automático" + }, + "settings.general.language.label": { + "message": "Idioma de la interfaz" + }, + "settings.general.language.help": { + "message": "Elija el idioma de la interfaz de NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.management.label": { + "message": "Servidor de gestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud o a su propio servidor de gestión autoalojado. Los cambios reconectarán el cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoalojado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o guarde de todos modos si está seguro de que es correcta." + }, + "settings.general.management.switchCloudTitle": { + "message": "¿Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Esto desconecta su servidor autoalojado.\nEs posible que deba iniciar sesión de nuevo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividad" + }, + "settings.network.section.routingDns": { + "message": "Enrutamiento y DNS" + }, + "settings.network.lazy.label": { + "message": "Conexiones bajo demanda" + }, + "settings.network.lazy.help": { + "message": "En lugar de mantener conexiones permanentes, NetBird las activa bajo demanda según la actividad o la señalización." + }, + "settings.network.monitor.label": { + "message": "Reconectar al cambiar de red" + }, + "settings.network.monitor.help": { + "message": "Monitoriza la red y reconecta automáticamente ante cambios como cambios de Wi-Fi, cambios de Ethernet o la reanudación tras la suspensión." + }, + "settings.network.dns.label": { + "message": "Habilitar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica la configuración de DNS gestionada por NetBird al resolutor del host." + }, + "settings.network.clientRoutes.label": { + "message": "Habilitar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas de otros peers para alcanzar sus redes." + }, + "settings.network.serverRoutes.label": { + "message": "Habilitar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia las rutas locales de este host a otros peers." + }, + "settings.network.ipv6.label": { + "message": "Habilitar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa direccionamiento IPv6 para la red superpuesta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafuegos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rechaza conexiones no solicitadas de peers hacia este dispositivo y cualquier red que enrute. El tráfico saliente no se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a la LAN" + }, + "settings.security.blockLan.help": { + "message": "Impide que los peers alcancen su red local o sus dispositivos cuando este dispositivo enruta su tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Habilitar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Añade un intercambio de claves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Habilitar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexiones con peers sin compatibilidad con resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Habilitar el servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Ejecuta el servidor SSH de NetBird en este host para que otros peers puedan conectarse a él." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión como root" + }, + "settings.ssh.root.help": { + "message": "Permite que los peers inicien sesión como usuario root. Desactívelo para exigir una cuenta sin privilegios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfiere archivos de forma segura mediante clientes SFTP o SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de puertos local" + }, + "settings.ssh.localForward.help": { + "message": "Permite que los peers conectados tunelicen puertos locales hacia servicios accesibles desde este host." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de puertos remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que los peers conectados expongan puertos de este host de vuelta a su propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Habilitar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH contra su IdP para la identidad del usuario y la auditoría. Desactívelo para basarse solo en las políticas de ACL de red, útil cuando no hay ningún IdP disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL de la caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Cuánto tiempo almacena en caché este cliente un JWT antes de volver a solicitarlo en conexiones SSH salientes. Establézcalo en 0 para desactivar la caché y autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interfaz" + }, + "settings.advanced.section.security": { + "message": "Seguridad" + }, + "settings.advanced.interfaceName.label": { + "message": "Nombre" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, puntos, guiones o guiones bajos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe empezar por \"utun\" seguido de un número (p. ej. utun100)." + }, + "settings.advanced.port.label": { + "message": "Puerto" + }, + "settings.advanced.port.error": { + "message": "Introduzca un puerto entre {min} y {max}." + }, + "settings.advanced.port.help": { + "message": "Si se establece en 0, se usará un puerto libre aleatorio." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduzca un valor de MTU entre {min} y {max}." + }, + "settings.advanced.psk.label": { + "message": "Clave precompartida" + }, + "settings.advanced.psk.help": { + "message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de diagnóstico" + }, + "settings.troubleshooting.intro": { + "message": "Un paquete de diagnóstico ayuda al soporte de NetBird a investigar problemas de conexión. <br /> Es un archivo .zip con registros, detalles del sistema e información de depuración de su dispositivo." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información sensible" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir información del sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluye el OS, el kernel, las interfaces de red y las tablas de enrutamiento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir el paquete a los servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Sube el paquete de forma segura y devuelve una clave de subida. Comparta la clave con el soporte de NetBird por GitHub o Slack en lugar de adjuntar el archivo directamente." + }, + "settings.troubleshooting.trace.label": { + "message": "Capturar registros de seguimiento" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva el registro a TRACE y reinicia NetBird para capturar los registros de conexión. El nivel anterior se restaura después de generar el paquete." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración de la captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Cuánto tiempo capturar registros de seguimiento antes de generar el paquete." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recopilando registros, detalles del sistema y estado de la conexión. Esto suele tardar un momento; mantenga esta ventana abierta hasta que termine." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "¡Paquete de diagnóstico subido correctamente!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete guardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta la clave de subida de abajo con el soporte de NetBird. También se guardó una copia local en su dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Su paquete de diagnóstico se ha guardado localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar clave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir carpeta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir ubicación del archivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Error en la subida: {reason} El paquete sigue guardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Error en la subida. El paquete sigue guardado localmente." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Cambiando al registro de seguimiento…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando registros: {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Restaurando el nivel de registro anterior…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generando el paquete de diagnóstico…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subiendo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Desarrollo]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos los derechos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidad" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Términos del servicio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Más tarde" + }, + "update.banner.installNow": { + "message": "Instalar ahora" + }, + "update.card.versionAvailableDownload": { + "message": "La versión {version} está disponible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "La versión {version} está disponible para instalar." + }, + "update.card.whatsNew": { + "message": "¿Qué hay de nuevo?" + }, + "update.card.installNow": { + "message": "Instalar ahora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizaciones en segundo plano." + }, + "update.card.changelog": { + "message": "Registro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Tiene la última versión" + }, + "update.header.tooltip": { + "message": "Actualización disponible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird a v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Hay una versión más reciente disponible y se está instalando. NetBird se reiniciará automáticamente cuando finalice la actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "La actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "La instalación de {target} tardó demasiado y no finalizó." + }, + "update.overlay.error.canceledTitle": { + "message": "La actualización se detuvo" + }, + "update.overlay.error.canceledDescription": { + "message": "La actualización a {target} se canceló antes de finalizar." + }, + "update.overlay.error.failTitle": { + "message": "No se pudo instalar la actualización" + }, + "update.overlay.error.failDescription": { + "message": "No se pudo instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "error desconocido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nueva versión" + }, + "update.error.loadStateTitle": { + "message": "Error al cargar el estado de la actualización" + }, + "update.error.triggerTitle": { + "message": "Error al iniciar la actualización" + }, + "update.page.versionLine": { + "message": "Actualizando el cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando el cliente." + }, + "update.page.outdated": { + "message": "La versión de su cliente es anterior a la versión de actualización automática configurada en Management." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Se agotó el tiempo de la actualización. Inténtelo de nuevo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Error en la actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "error de actualización desconocido" + }, + "update.page.failedTitle": { + "message": "Error en la actualización" + }, + "update.page.timeoutMessage": { + "message": "Se agotó el tiempo de la actualización." + }, + "update.page.dontClose": { + "message": "No cierre esta ventana." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Error en la actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Inicio de sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de expirar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Bienvenido a NetBird" + }, + "window.title.error": { + "message": "Error" + }, + "welcome.title": { + "message": "Busque NetBird en su bandeja del sistema" + }, + "welcome.description": { + "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Atrás" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Haga clic en Continuar para empezar, o elija Autoalojado si tiene su propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use nuestro servicio alojado. No requiere configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoalojado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese a su propio servidor de gestión." + }, + "welcome.management.urlLabel": { + "message": "URL del servidor de gestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o su red y, a continuación, continúe si está seguro de que es correcta." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Continúe en su navegador para completar el inicio de sesión" + }, + "browserLogin.notSeeing": { + "message": "¿No ve la pestaña del navegador?" + }, + "browserLogin.tryAgain": { + "message": "Reintentar" + }, + "browserLogin.openFailedTitle": { + "message": "Error al abrir el navegador" + }, + "sessionExpiration.title": { + "message": "La sesión expira pronto" + }, + "sessionExpiration.titleLater": { + "message": "Su sesión expirará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo se desconectará pronto. Renuévela iniciando sesión en el navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Iniciar sesión en el navegador mantiene este dispositivo conectado a su red." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Cerrar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese iniciando sesión en el navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Cerrar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Error al renovar la sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Error al cerrar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nombre o IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En línea" + }, + "peers.filter.offline": { + "message": "Sin conexión" + }, + "peers.empty.title": { + "message": "No hay peers disponibles" + }, + "peers.empty.description": { + "message": "No tiene ningún peer disponible o no tiene acceso a ninguno de ellos." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Clave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de la conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Justo ahora" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitida" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass habilitado" + }, + "networks.search.placeholder": { + "message": "Buscar por red o dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Solapados" + }, + "networks.empty.title": { + "message": "No hay recursos disponibles" + }, + "networks.empty.description": { + "message": "No tiene ningún recurso de red disponible o no tiene acceso a ninguno de ellos." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "No seleccionado" + }, + "networks.ips.heading": { + "message": "IP resueltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} activos" + }, + "networks.bulk.enableAll": { + "message": "Habilitar todos" + }, + "networks.bulk.disableAll": { + "message": "Deshabilitar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de salida" + }, + "exitNodes.none": { + "message": "Ninguno" + }, + "exitNodes.empty.title": { + "message": "No hay nodos de salida disponibles" + }, + "exitNodes.empty.description": { + "message": "No se ha compartido ningún nodo de salida con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de salida" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ninguno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sin nodo de salida" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "El servicio de NetBird no está en ejecución" + }, + "daemon.unavailable.description": { + "message": "La aplicación se reconectará automáticamente cuando el servicio esté en ejecución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "error.jwt_clock_skew": { + "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." + }, + "error.jwt_expired": { + "message": "Su token de inicio de sesión ha expirado. Inicie sesión de nuevo." + }, + "error.jwt_signature_invalid": { + "message": "Error al iniciar sesión: la firma del token no es válida. Póngase en contacto con su administrador." + }, + "error.session_expired": { + "message": "Su sesión ha expirado. Inicie sesión de nuevo." + }, + "error.invalid_setup_key": { + "message": "La clave de instalación falta o no es válida." + }, + "error.permission_denied": { + "message": "El servidor rechazó el inicio de sesión." + }, + "error.daemon_unreachable": { + "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." + }, + "error.unknown": { + "message": "La operación falló." + } +} diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json new file mode 100644 index 000000000..ae181b6d2 --- /dev/null +++ b/client/ui/i18n/locales/fr/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Déconnecté" + }, + "tray.status.daemonUnavailable": { + "message": "Non démarré" + }, + "tray.status.error": { + "message": "Erreur" + }, + "tray.status.connected": { + "message": "Connecté" + }, + "tray.status.connecting": { + "message": "Connexion" + }, + "tray.status.needsLogin": { + "message": "Connexion requise" + }, + "tray.status.loginFailed": { + "message": "Échec de la connexion" + }, + "tray.status.sessionExpired": { + "message": "Session expirée" + }, + "tray.session.expiresIn": { + "message": "La session expire dans {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "moins d’une minute" + }, + "tray.session.unit.minute": { + "message": "1 minute" + }, + "tray.session.unit.minutes": { + "message": "{count} minutes" + }, + "tray.session.unit.hour": { + "message": "1 heure" + }, + "tray.session.unit.hours": { + "message": "{count} heures" + }, + "tray.session.unit.day": { + "message": "1 jour" + }, + "tray.session.unit.days": { + "message": "{count} jours" + }, + "tray.menu.open": { + "message": "Ouvrir NetBird" + }, + "tray.menu.connect": { + "message": "Se connecter" + }, + "tray.menu.disconnect": { + "message": "Se déconnecter" + }, + "tray.menu.exitNode": { + "message": "Nœud de sortie" + }, + "tray.menu.networks": { + "message": "Ressources" + }, + "tray.menu.profiles": { + "message": "Profils" + }, + "tray.menu.manageProfiles": { + "message": "Gérer les profils" + }, + "tray.menu.settings": { + "message": "Paramètres..." + }, + "tray.menu.debugBundle": { + "message": "Créer un lot de diagnostic" + }, + "tray.menu.about": { + "message": "Aide et assistance" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentation" + }, + "tray.menu.troubleshoot": { + "message": "Dépannage" + }, + "tray.menu.downloadLatest": { + "message": "Télécharger la dernière version" + }, + "tray.menu.installVersion": { + "message": "Installer la version {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI : {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon : {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Quitter NetBird" + }, + "notify.update.title": { + "message": "Mise à jour de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} est disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Votre administrateur impose cette mise à jour." + }, + "notify.error.title": { + "message": "Erreur" + }, + "notify.error.connect": { + "message": "Échec de la connexion" + }, + "notify.error.disconnect": { + "message": "Échec de la déconnexion" + }, + "notify.error.switchProfile": { + "message": "Échec du passage à {profile}" + }, + "notify.error.exitNode": { + "message": "Échec de la mise à jour du nœud de sortie {name}" + }, + "notify.sessionExpired.title": { + "message": "Session NetBird expirée" + }, + "notify.sessionExpired.body": { + "message": "Votre session NetBird a expiré. Veuillez vous reconnecter." + }, + "notify.sessionWarning.title": { + "message": "La session expire bientôt" + }, + "notify.sessionWarning.body": { + "message": "Votre session NetBird expire dans {remaining}. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Votre session NetBird est sur le point d’expirer. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.extend": { + "message": "Prolonger" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignorer" + }, + "notify.sessionWarning.failed": { + "message": "Échec de la prolongation de la session NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Session NetBird prolongée" + }, + "notify.sessionWarning.successBody": { + "message": "Votre session a été renouvelée." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Échéance de session rejetée" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter." + }, + "common.cancel": { + "message": "Annuler" + }, + "common.save": { + "message": "Enregistrer" + }, + "common.saveChanges": { + "message": "Enregistrer les modifications" + }, + "common.saving": { + "message": "Enregistrement…" + }, + "common.close": { + "message": "Fermer" + }, + "common.copy": { + "message": "Copier" + }, + "common.togglePasswordVisibility": { + "message": "Afficher ou masquer le mot de passe" + }, + "common.increase": { + "message": "Augmenter" + }, + "common.decrease": { + "message": "Diminuer" + }, + "common.delete": { + "message": "Supprimer" + }, + "common.create": { + "message": "Créer" + }, + "common.add": { + "message": "Ajouter" + }, + "common.remove": { + "message": "Retirer" + }, + "common.refresh": { + "message": "Actualiser" + }, + "common.loading": { + "message": "Chargement…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Aucun résultat trouvé" + }, + "common.noResults.description": { + "message": "Nous n’avons trouvé aucun résultat. Essayez un autre terme de recherche ou modifiez vos filtres." + }, + "notConnected.title": { + "message": "Déconnecté" + }, + "notConnected.description": { + "message": "Connectez-vous d’abord à NetBird pour consulter les informations détaillées sur vos pairs, vos ressources réseau et vos nœuds de sortie." + }, + "connect.status.disconnected": { + "message": "Déconnecté" + }, + "connect.status.connecting": { + "message": "Connexion..." + }, + "connect.status.connected": { + "message": "Connecté" + }, + "connect.status.disconnecting": { + "message": "Déconnexion..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponible" + }, + "connect.status.loginRequired": { + "message": "Connexion requise" + }, + "connect.error.loginTitle": { + "message": "Échec de la connexion" + }, + "connect.error.connectTitle": { + "message": "Échec de la connexion" + }, + "connect.error.disconnectTitle": { + "message": "Échec de la déconnexion" + }, + "nav.peers.title": { + "message": "Pairs" + }, + "nav.peers.description": { + "message": "{connected} sur {total} connectés" + }, + "nav.resources.title": { + "message": "Ressources" + }, + "nav.resources.description": { + "message": "{active} sur {total} actives" + }, + "nav.exitNode.title": { + "message": "Nœuds de sortie" + }, + "nav.exitNode.none": { + "message": "Inactif" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Ouvrir les paramètres" + }, + "header.togglePanel": { + "message": "Afficher ou masquer le panneau latéral" + }, + "profile.selector.loading": { + "message": "Chargement..." + }, + "profile.selector.noProfile": { + "message": "Aucun profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Rechercher un profil par nom..." + }, + "profile.selector.emptyTitle": { + "message": "Aucun profil trouvé" + }, + "profile.selector.emptyDescription": { + "message": "Essayez un autre terme de recherche ou créez un nouveau profil." + }, + "profile.selector.newProfile": { + "message": "Nouveau profil" + }, + "profile.selector.moreOptions": { + "message": "Plus d’options" + }, + "profile.selector.deregister": { + "message": "Désinscrire" + }, + "profile.selector.delete": { + "message": "Supprimer" + }, + "profile.selector.switchTo": { + "message": "Basculer vers ce profil" + }, + "profile.dialog.title": { + "message": "Saisir le nom du profil" + }, + "profile.dialog.nameLabel": { + "message": "Nom du profil" + }, + "profile.dialog.description": { + "message": "Choisissez un nom facilement identifiable pour votre profil." + }, + "profile.dialog.placeholder": { + "message": "ex. travail" + }, + "profile.dialog.submit": { + "message": "Ajouter un profil" + }, + "profile.dialog.required": { + "message": "Veuillez saisir un nom de profil, ex. travail, domicile" + }, + "profile.dialog.managementHelp": { + "message": "Utilisez NetBird Cloud ou votre propre serveur." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou ajoutez le profil quand même si vous êtes sûr qu’elle est correcte." + }, + "header.menu.settings": { + "message": "Paramètres..." + }, + "header.menu.defaultView": { + "message": "Vue par défaut" + }, + "header.menu.advancedView": { + "message": "Vue avancée" + }, + "header.menu.updateAvailable": { + "message": "Mise à jour disponible" + }, + "profile.switch.title": { + "message": "Basculer vers le profil « {name} » ?" + }, + "profile.switch.message": { + "message": "Voulez-vous vraiment changer de profil ?\nVotre profil actuel sera déconnecté." + }, + "profile.switch.confirm": { + "message": "Confirmer" + }, + "profile.deregister.title": { + "message": "Désinscrire le profil « {name} » ?" + }, + "profile.deregister.message": { + "message": "Voulez-vous vraiment désinscrire ce profil ?\nVous devrez vous reconnecter pour l’utiliser." + }, + "profile.deregister.confirm": { + "message": "Désinscrire" + }, + "profile.delete.title": { + "message": "Supprimer le profil « {name} » ?" + }, + "profile.delete.message": { + "message": "Voulez-vous vraiment supprimer ce profil ?\nCette action est irréversible." + }, + "profile.delete.disabledActive": { + "message": "Les profils actifs ne peuvent pas être supprimés. Basculez vers un autre profil avant de supprimer celui-ci." + }, + "profile.delete.disabledDefault": { + "message": "Le profil par défaut ne peut pas être supprimé." + }, + "profile.error.switchTitle": { + "message": "Échec du changement de profil" + }, + "profile.error.deregisterTitle": { + "message": "Échec de la désinscription du profil" + }, + "profile.error.deleteTitle": { + "message": "Échec de la suppression du profil" + }, + "profile.error.createTitle": { + "message": "Échec de la création du profil" + }, + "profile.error.loadTitle": { + "message": "Échec du chargement des profils" + }, + "profile.dropdown.activeProfile": { + "message": "Profil actif" + }, + "profile.dropdown.switchProfile": { + "message": "Changer de profil" + }, + "profile.dropdown.noEmail": { + "message": "Autre" + }, + "profile.dropdown.addProfile": { + "message": "Ajouter un profil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gérer les profils" + }, + "profile.dropdown.settings": { + "message": "Paramètres" + }, + "settings.profiles.section.profiles": { + "message": "Profils" + }, + "settings.profiles.intro": { + "message": "Conservez plusieurs identités NetBird côte à côte, par exemple des comptes professionnels et personnels, ou différents serveurs de gestion. Ajoutez, désinscrivez ou supprimez des profils ci-dessous." + }, + "settings.profiles.addProfile": { + "message": "Ajouter un profil" + }, + "settings.profiles.active": { + "message": "Actif" + }, + "settings.profiles.emptyTitle": { + "message": "Aucun profil" + }, + "settings.profiles.emptyDescription": { + "message": "Créez un profil pour vous connecter à un serveur de gestion NetBird." + }, + "settings.error.loadTitle": { + "message": "Échec du chargement des paramètres" + }, + "settings.error.saveTitle": { + "message": "Échec de l’enregistrement des paramètres" + }, + "settings.error.debugBundleTitle": { + "message": "Échec du lot de diagnostic" + }, + "settings.tabs.general": { + "message": "Général" + }, + "settings.tabs.network": { + "message": "Réseau" + }, + "settings.tabs.security": { + "message": "Sécurité" + }, + "settings.tabs.profiles": { + "message": "Profils" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avancé" + }, + "settings.tabs.troubleshooting": { + "message": "Dépannage" + }, + "settings.tabs.about": { + "message": "À propos" + }, + "settings.tabs.updateAvailable": { + "message": "Mise à jour disponible" + }, + "settings.general.section.general": { + "message": "Général" + }, + "settings.general.section.connection": { + "message": "Connexion" + }, + "settings.general.connectOnStartup.label": { + "message": "Se connecter au démarrage" + }, + "settings.general.connectOnStartup.help": { + "message": "Établir automatiquement une connexion au démarrage du service." + }, + "settings.general.notifications.label": { + "message": "Notifications du bureau" + }, + "settings.general.notifications.help": { + "message": "Afficher des notifications du bureau pour les nouvelles mises à jour et les événements de connexion." + }, + "settings.general.autostart.label": { + "message": "Lancer l’interface NetBird à l’ouverture de session" + }, + "settings.general.autostart.help": { + "message": "Démarrer automatiquement l’interface NetBird à l’ouverture de votre session. Cela n’affecte que l’interface graphique, pas le service en arrière-plan." + }, + "settings.general.autostart.errorTitle": { + "message": "Échec de la modification du démarrage automatique" + }, + "settings.general.language.label": { + "message": "Langue d’affichage" + }, + "settings.general.language.help": { + "message": "Choisissez la langue de l’interface NetBird." + }, + "settings.general.language.search": { + "message": "Rechercher une langue…" + }, + "settings.general.language.empty": { + "message": "Aucune langue ne correspond." + }, + "settings.general.management.label": { + "message": "Serveur de gestion" + }, + "settings.general.management.help": { + "message": "Connectez-vous à NetBird Cloud ou à votre propre serveur de gestion auto-hébergé. Les modifications reconnecteront le client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hébergé" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou enregistrez quand même si vous êtes sûr qu’elle est correcte." + }, + "settings.general.management.switchCloudTitle": { + "message": "Basculer vers NetBird Cloud ?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Cela déconnecte votre serveur auto-hébergé.\nVous devrez peut-être vous reconnecter." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Basculer vers Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connectivité" + }, + "settings.network.section.routingDns": { + "message": "Routage et DNS" + }, + "settings.network.lazy.label": { + "message": "Connexions à la demande" + }, + "settings.network.lazy.help": { + "message": "Au lieu de maintenir des connexions permanentes, NetBird les active à la demande en fonction de l’activité ou de la signalisation." + }, + "settings.network.monitor.label": { + "message": "Reconnecter en cas de changement de réseau" + }, + "settings.network.monitor.help": { + "message": "Surveiller le réseau et se reconnecter automatiquement lors de changements tels qu’un basculement Wi-Fi, une modification Ethernet ou une reprise après la mise en veille." + }, + "settings.network.dns.label": { + "message": "Activer le DNS" + }, + "settings.network.dns.help": { + "message": "Appliquer les paramètres DNS gérés par NetBird au résolveur de l’hôte." + }, + "settings.network.clientRoutes.label": { + "message": "Activer les routes client" + }, + "settings.network.clientRoutes.help": { + "message": "Accepter les routes d’autres pairs pour atteindre leurs réseaux." + }, + "settings.network.serverRoutes.label": { + "message": "Activer les routes serveur" + }, + "settings.network.serverRoutes.help": { + "message": "Annoncer les routes locales de cet hôte aux autres pairs." + }, + "settings.network.ipv6.label": { + "message": "Activer IPv6" + }, + "settings.network.ipv6.help": { + "message": "Utiliser l’adressage IPv6 pour le réseau overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Pare-feu" + }, + "settings.security.section.encryption": { + "message": "Chiffrement" + }, + "settings.security.blockInbound.label": { + "message": "Bloquer le trafic entrant" + }, + "settings.security.blockInbound.help": { + "message": "Rejeter les connexions non sollicitées des pairs vers cet appareil et les réseaux qu’il route. Le trafic sortant n’est pas affecté." + }, + "settings.security.blockLan.label": { + "message": "Bloquer l’accès au LAN" + }, + "settings.security.blockLan.help": { + "message": "Empêcher les pairs d’atteindre votre réseau local ou ses appareils lorsque cet appareil route leur trafic." + }, + "settings.security.rosenpass.label": { + "message": "Activer la résistance quantique" + }, + "settings.security.rosenpass.help": { + "message": "Ajouter un échange de clés post-quantique via Rosenpass au-dessus de WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activer le mode permissif" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Autoriser les connexions vers des pairs sans prise en charge de la résistance quantique." + }, + "settings.ssh.section.server": { + "message": "Serveur" + }, + "settings.ssh.section.capabilities": { + "message": "Fonctionnalités" + }, + "settings.ssh.section.authentication": { + "message": "Authentification" + }, + "settings.ssh.server.label": { + "message": "Activer le serveur SSH" + }, + "settings.ssh.server.help": { + "message": "Exécuter le serveur SSH NetBird sur cet hôte afin que d’autres pairs puissent s’y connecter." + }, + "settings.ssh.root.label": { + "message": "Autoriser la connexion en root" + }, + "settings.ssh.root.help": { + "message": "Permettre aux pairs de se connecter en tant qu’utilisateur root. Désactivez pour exiger un compte non privilégié." + }, + "settings.ssh.sftp.label": { + "message": "Autoriser SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transférer des fichiers en toute sécurité à l’aide de clients SFTP ou SCP natifs." + }, + "settings.ssh.localForward.label": { + "message": "Redirection de port locale" + }, + "settings.ssh.localForward.help": { + "message": "Permettre aux pairs connectés de tunneliser des ports locaux vers des services accessibles depuis cet hôte." + }, + "settings.ssh.remoteForward.label": { + "message": "Redirection de port distante" + }, + "settings.ssh.remoteForward.help": { + "message": "Permettre aux pairs connectés d’exposer des ports de cet hôte vers leur propre machine." + }, + "settings.ssh.jwt.label": { + "message": "Activer l’authentification JWT" + }, + "settings.ssh.jwt.help": { + "message": "Vérifier chaque session SSH auprès de votre IdP pour l’identité de l’utilisateur et l’audit. Désactivez pour vous appuyer uniquement sur les politiques ACL réseau, utile lorsqu’aucun IdP n’est disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL du cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Durée pendant laquelle ce client met en cache un JWT avant de redemander lors des connexions SSH sortantes. Définissez 0 pour désactiver la mise en cache et vous authentifier à chaque connexion." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Sécurité" + }, + "settings.advanced.interfaceName.label": { + "message": "Nom" + }, + "settings.advanced.interfaceName.error": { + "message": "Utilisez 1 à 15 lettres, chiffres, points, tirets ou traits de soulignement." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Doit commencer par « utun » suivi d’un nombre (ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Saisissez un port compris entre {min} et {max}." + }, + "settings.advanced.port.help": { + "message": "Si la valeur est 0, un port libre aléatoire sera utilisé." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Saisissez une valeur MTU comprise entre {min} et {max}." + }, + "settings.advanced.psk.label": { + "message": "Clé pré-partagée" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente d’une clé d’installation NetBird. Vous ne communiquerez qu’avec les pairs utilisant la même clé pré-partagée." + }, + "settings.troubleshooting.section.title": { + "message": "Lot de diagnostic" + }, + "settings.troubleshooting.intro": { + "message": "Un lot de diagnostic aide l’assistance NetBird à étudier les problèmes de connexion. <br /> C’est un fichier .zip contenant des journaux, des détails système et des informations de diagnostic provenant de votre appareil." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymiser les informations sensibles" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Inclure les informations système" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclure l’OS, le noyau, les interfaces réseau et les tables de routage." + }, + "settings.troubleshooting.upload.label": { + "message": "Envoyer le lot aux serveurs NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Envoie le lot de manière sécurisée et renvoie une clé d’envoi. Partagez la clé avec l’assistance NetBird via GitHub ou Slack plutôt que de joindre directement le fichier." + }, + "settings.troubleshooting.trace.label": { + "message": "Capturer les journaux trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Augmente la journalisation au niveau TRACE et redémarre NetBird pour capturer les journaux de connexion. Le niveau précédent est rétabli une fois le lot généré." + }, + "settings.troubleshooting.duration.label": { + "message": "Durée de capture" + }, + "settings.troubleshooting.duration.help": { + "message": "Durée de capture des journaux trace avant de générer le lot." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Créer le lot" + }, + "settings.troubleshooting.progress.description": { + "message": "Collecte des journaux, des détails système et de l’état de connexion. Cela prend généralement un instant — gardez cette fenêtre ouverte jusqu’à la fin." + }, + "settings.troubleshooting.cancelling": { + "message": "Annulation…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Lot de diagnostic envoyé avec succès !" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Lot enregistré" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Partagez la clé d’envoi ci-dessous avec l’assistance NetBird. Une copie locale a également été enregistrée sur votre appareil." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Votre lot de diagnostic a été enregistré localement." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copier la clé" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ouvrir le dossier" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Ouvrir l’emplacement du fichier" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Échec de l’envoi : {reason} Le lot reste enregistré localement." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Échec de l’envoi. Le lot reste enregistré localement." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Passage à la journalisation trace…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnexion de NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capture des journaux — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Rétablissement du niveau de journalisation précédent…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Génération du lot de diagnostic…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Envoi vers NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annulation…" + }, + "settings.about.client": { + "message": "Client NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Client NetBird" + }, + "settings.about.development": { + "message": "[Développement]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tous droits réservés." + }, + "settings.about.links.imprint": { + "message": "Mentions légales" + }, + "settings.about.links.privacy": { + "message": "Confidentialité" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Conditions d’utilisation" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentation" + }, + "settings.about.community.feedback": { + "message": "Retour d’expérience" + }, + "update.banner.message": { + "message": "NetBird {version} est prêt à être installé." + }, + "update.banner.later": { + "message": "Plus tard" + }, + "update.banner.installNow": { + "message": "Installer maintenant" + }, + "update.card.versionAvailableDownload": { + "message": "La version {version} est disponible au téléchargement." + }, + "update.card.versionAvailableInstall": { + "message": "La version {version} est disponible à l’installation." + }, + "update.card.whatsNew": { + "message": "Quoi de neuf ?" + }, + "update.card.installNow": { + "message": "Installer maintenant" + }, + "update.card.getInstaller": { + "message": "Télécharger" + }, + "update.card.autoCheckInterval": { + "message": "NetBird vérifie les mises à jour en arrière-plan." + }, + "update.card.changelog": { + "message": "Journal des modifications" + }, + "update.card.onLatestVersion": { + "message": "Vous utilisez la dernière version" + }, + "update.header.tooltip": { + "message": "Mise à jour disponible" + }, + "update.overlay.updatingVersion": { + "message": "Mise à jour de NetBird vers v{version}" + }, + "update.overlay.updating": { + "message": "Mise à jour de NetBird" + }, + "update.overlay.description": { + "message": "Une version plus récente est disponible et en cours d’installation. NetBird redémarrera automatiquement une fois la mise à jour terminée." + }, + "update.overlay.error.timeoutTitle": { + "message": "La mise à jour prend trop de temps" + }, + "update.overlay.error.timeoutDescription": { + "message": "L’installation de {target} a pris trop de temps et ne s’est pas terminée." + }, + "update.overlay.error.canceledTitle": { + "message": "La mise à jour a été interrompue" + }, + "update.overlay.error.canceledDescription": { + "message": "La mise à jour vers {target} a été annulée avant de se terminer." + }, + "update.overlay.error.failTitle": { + "message": "Impossible d’installer la mise à jour" + }, + "update.overlay.error.failDescription": { + "message": "Impossible d’installer {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erreur inconnue" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nouvelle version" + }, + "update.error.loadStateTitle": { + "message": "Échec du chargement de l’état de mise à jour" + }, + "update.error.triggerTitle": { + "message": "Échec du démarrage de la mise à jour" + }, + "update.page.versionLine": { + "message": "Mise à jour du client vers : {version}." + }, + "update.page.versionLineGeneric": { + "message": "Mise à jour du client." + }, + "update.page.outdated": { + "message": "La version de votre client est antérieure à la version de mise à jour automatique définie dans Management." + }, + "update.page.status.running": { + "message": "Mise à jour en cours" + }, + "update.page.status.timeout": { + "message": "La mise à jour a expiré. Veuillez réessayer." + }, + "update.page.status.canceled": { + "message": "Mise à jour annulée." + }, + "update.page.status.failed": { + "message": "Échec de la mise à jour : {message}" + }, + "update.page.status.unknownError": { + "message": "erreur de mise à jour inconnue" + }, + "update.page.failedTitle": { + "message": "Échec de la mise à jour" + }, + "update.page.timeoutMessage": { + "message": "La mise à jour a expiré." + }, + "update.page.dontClose": { + "message": "Veuillez ne pas fermer cette fenêtre." + }, + "update.page.updating": { + "message": "Mise à jour…" + }, + "update.page.complete": { + "message": "Mise à jour terminée" + }, + "update.page.failed": { + "message": "Échec de la mise à jour" + }, + "window.title.settings": { + "message": "Paramètres" + }, + "window.title.signIn": { + "message": "Connexion" + }, + "window.title.sessionExpiration": { + "message": "Expiration de session" + }, + "window.title.updating": { + "message": "Mise à jour" + }, + "window.title.welcome": { + "message": "Bienvenue dans NetBird" + }, + "window.title.error": { + "message": "Erreur" + }, + "welcome.title": { + "message": "Cherchez NetBird dans votre barre d’état système" + }, + "welcome.description": { + "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.continue": { + "message": "Continuer" + }, + "welcome.back": { + "message": "Retour" + }, + "welcome.management.title": { + "message": "Configurer NetBird" + }, + "welcome.management.description": { + "message": "Cliquez sur Continuer pour commencer, ou choisissez Auto-hébergé si vous disposez de votre propre serveur NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Utilisez notre service hébergé. Aucune configuration requise." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hébergé" + }, + "welcome.management.selfHosted.description": { + "message": "Connectez-vous à votre propre serveur de gestion." + }, + "welcome.management.urlLabel": { + "message": "URL du serveur de gestion" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL ou votre réseau, puis continuez si vous êtes sûr qu’elle est correcte." + }, + "welcome.management.checking": { + "message": "Vérification…" + }, + "browserLogin.title": { + "message": "Continuez dans votre navigateur pour terminer la connexion" + }, + "browserLogin.notSeeing": { + "message": "Vous ne voyez pas l’onglet du navigateur ?" + }, + "browserLogin.tryAgain": { + "message": "Réessayer" + }, + "browserLogin.openFailedTitle": { + "message": "Échec de l’ouverture du navigateur" + }, + "sessionExpiration.title": { + "message": "La session expire bientôt" + }, + "sessionExpiration.titleLater": { + "message": "Votre session va expirer" + }, + "sessionExpiration.description": { + "message": "Cet appareil sera bientôt déconnecté. Renouvelez via une connexion dans le navigateur." + }, + "sessionExpiration.descriptionLater": { + "message": "Une connexion dans le navigateur maintient cet appareil connecté à votre réseau." + }, + "sessionExpiration.stay": { + "message": "Renouveler la session" + }, + "sessionExpiration.authenticate": { + "message": "S’authentifier" + }, + "sessionExpiration.logout": { + "message": "Se déconnecter" + }, + "sessionExpiration.expired": { + "message": "Session expirée" + }, + "sessionExpiration.expiredDescription": { + "message": "Appareil déconnecté. Authentifiez-vous via une connexion dans le navigateur pour vous reconnecter." + }, + "sessionExpiration.close": { + "message": "Fermer" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Échec de la prolongation de la session" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Échec de la déconnexion" + }, + "peers.search.placeholder": { + "message": "Rechercher par nom ou IP" + }, + "peers.filter.all": { + "message": "Tous" + }, + "peers.filter.online": { + "message": "En ligne" + }, + "peers.filter.offline": { + "message": "Hors ligne" + }, + "peers.empty.title": { + "message": "Aucun pair disponible" + }, + "peers.empty.description": { + "message": "Soit vous n’avez aucun pair disponible, soit vous n’y avez pas accès." + }, + "peers.details.domain": { + "message": "Domaine" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Clé publique" + }, + "peers.details.connection": { + "message": "Connexion" + }, + "peers.details.latency": { + "message": "Latence" + }, + "peers.details.lastHandshake": { + "message": "Dernier handshake" + }, + "peers.details.statusSince": { + "message": "Dernière mise à jour de connexion" + }, + "peers.details.bytes": { + "message": "Octets" + }, + "peers.details.bytesSent": { + "message": "Envoyés" + }, + "peers.details.bytesReceived": { + "message": "Reçus" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE distant" + }, + "peers.details.never": { + "message": "Jamais" + }, + "peers.details.justNow": { + "message": "À l’instant" + }, + "peers.details.refresh": { + "message": "Actualiser" + }, + "peers.status.connected": { + "message": "Connecté" + }, + "peers.status.connecting": { + "message": "Connexion" + }, + "peers.status.disconnected": { + "message": "Déconnecté" + }, + "peers.details.relayAddress": { + "message": "Relais" + }, + "peers.details.networks": { + "message": "Ressources" + }, + "peers.details.relayed": { + "message": "Relayée" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activé" + }, + "networks.search.placeholder": { + "message": "Rechercher par réseau ou domaine" + }, + "networks.filter.all": { + "message": "Toutes" + }, + "networks.filter.active": { + "message": "Actives" + }, + "networks.filter.overlapping": { + "message": "Chevauchantes" + }, + "networks.empty.title": { + "message": "Aucune ressource disponible" + }, + "networks.empty.description": { + "message": "Soit vous n’avez aucune ressource réseau disponible, soit vous n’y avez pas accès." + }, + "networks.selected": { + "message": "Sélectionnée" + }, + "networks.unselected": { + "message": "Non sélectionnée" + }, + "networks.ips.heading": { + "message": "IP résolues" + }, + "networks.bulk.selectionCount": { + "message": "{selected} sur {total} actives" + }, + "networks.bulk.enableAll": { + "message": "Tout activer" + }, + "networks.bulk.disableAll": { + "message": "Tout désactiver" + }, + "exitNodes.search.placeholder": { + "message": "Rechercher des nœuds de sortie" + }, + "exitNodes.none": { + "message": "Aucun" + }, + "exitNodes.empty.title": { + "message": "Aucun nœud de sortie disponible" + }, + "exitNodes.empty.description": { + "message": "Aucun nœud de sortie n’a été partagé avec ce pair." + }, + "exitNodes.card.title": { + "message": "Nœud de sortie" + }, + "exitNodes.card.statusActive": { + "message": "Actif" + }, + "exitNodes.card.statusInactive": { + "message": "Inactif" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Aucun" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connexion directe sans nœud de sortie" + }, + "quickActions.connect": { + "message": "Se connecter" + }, + "quickActions.disconnect": { + "message": "Se déconnecter" + }, + "daemon.unavailable.title": { + "message": "Le service NetBird n’est pas en cours d’exécution" + }, + "daemon.unavailable.description": { + "message": "L’application se reconnectera automatiquement une fois le service en cours d’exécution." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation" + }, + "error.jwt_clock_skew": { + "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." + }, + "error.jwt_expired": { + "message": "Votre jeton de connexion a expiré. Veuillez vous reconnecter." + }, + "error.jwt_signature_invalid": { + "message": "Échec de la connexion : la signature du jeton est invalide. Veuillez contacter votre administrateur." + }, + "error.session_expired": { + "message": "Votre session a expiré. Veuillez vous reconnecter." + }, + "error.invalid_setup_key": { + "message": "La clé d’installation est manquante ou invalide." + }, + "error.permission_denied": { + "message": "La connexion a été rejetée par le serveur." + }, + "error.daemon_unreachable": { + "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." + }, + "error.unknown": { + "message": "L’opération a échoué." + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 53d57802a..617815df6 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -3,7 +3,7 @@ "message": "NetBird" }, "tray.status.disconnected": { - "message": "Lekapcsolva" + "message": "Lecsatlakozva" }, "tray.status.daemonUnavailable": { "message": "Nem fut" @@ -12,10 +12,10 @@ "message": "Hiba" }, "tray.status.connected": { - "message": "Kapcsolódva" + "message": "Csatlakozva" }, "tray.status.connecting": { - "message": "Kapcsolódás" + "message": "Csatlakozás" }, "tray.status.needsLogin": { "message": "Bejelentkezés szükséges" @@ -60,7 +60,7 @@ "message": "Bontás" }, "tray.menu.exitNode": { - "message": "Kilépő csomópont" + "message": "Exit Node" }, "tray.menu.networks": { "message": "Erőforrások" @@ -72,7 +72,7 @@ "message": "Profilok kezelése" }, "tray.menu.settings": { - "message": "Beállítások..." + "message": "Beállítások…" }, "tray.menu.debugBundle": { "message": "Hibakeresési csomag készítése" @@ -96,7 +96,7 @@ "message": "{version} verzió telepítése" }, "tray.menu.guiVersion": { - "message": "Felület: {version}" + "message": "GUI: {version}" }, "tray.menu.daemonVersion": { "message": "Daemon: {version}" @@ -129,7 +129,7 @@ "message": "Átváltás sikertelen erre: {profile}" }, "notify.error.exitNode": { - "message": "A kilépő csomópont frissítése sikertelen: {name}" + "message": "Az Exit Node frissítése sikertelen: {name}" }, "notify.sessionExpired.title": { "message": "NetBird munkamenet lejárt" @@ -141,10 +141,10 @@ "message": "Munkamenet hamarosan lejár" }, "notify.sessionWarning.body": { - "message": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz." + "message": "A NetBird munkamenet {remaining} múlva lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." }, "notify.sessionWarning.bodyGeneric": { - "message": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz." + "message": "A NetBird munkamenet hamarosan lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." }, "notify.sessionWarning.extend": { "message": "Meghosszabbítás" @@ -225,19 +225,19 @@ "message": "Lecsatlakozva" }, "notConnected.description": { - "message": "Csatlakozz először a NetBirdhöz, hogy részletes információkat láthass a társakról, hálózati erőforrásokról és kilépő csomópontokról." + "message": "Csatlakozzon először a NetBirdhöz, hogy részletes információkat láthasson a Peerekről, a hálózati erőforrásokról és az Exit Node-okról." }, "connect.status.disconnected": { - "message": "Lekapcsolva" + "message": "Lecsatlakozva" }, "connect.status.connecting": { "message": "Csatlakozás…" }, "connect.status.connected": { - "message": "Csatlakoztatva" + "message": "Csatlakozva" }, "connect.status.disconnecting": { - "message": "Bontás…" + "message": "Lecsatlakozás…" }, "connect.status.daemonUnavailable": { "message": "Daemon nem elérhető" @@ -255,7 +255,7 @@ "message": "Bontás sikertelen" }, "nav.peers.title": { - "message": "Társak" + "message": "Peerek" }, "nav.peers.description": { "message": "{connected} / {total} csatlakoztatva" @@ -267,7 +267,7 @@ "message": "{active} / {total} aktív" }, "nav.exitNode.title": { - "message": "Kilépő csomópontok" + "message": "Exit Node-ok" }, "nav.exitNode.none": { "message": "Nem aktív" @@ -281,18 +281,6 @@ "header.togglePanel": { "message": "Oldalsó panel váltása" }, - "header.menu.settings": { - "message": "Beállítások…" - }, - "header.menu.defaultView": { - "message": "Alapnézet" - }, - "header.menu.advancedView": { - "message": "Speciális nézet" - }, - "header.menu.updateAvailable": { - "message": "Frissítés elérhető" - }, "profile.selector.loading": { "message": "Betöltés…" }, @@ -323,24 +311,6 @@ "profile.selector.switchTo": { "message": "Váltás erre a profilra" }, - "profile.dropdown.activeProfile": { - "message": "Aktív profil" - }, - "profile.dropdown.switchProfile": { - "message": "Profilváltás" - }, - "profile.dropdown.noEmail": { - "message": "Egyéb" - }, - "profile.dropdown.addProfile": { - "message": "Profil hozzáadása" - }, - "profile.dropdown.manageProfiles": { - "message": "Profilok kezelése" - }, - "profile.dropdown.settings": { - "message": "Beállítások" - }, "profile.dialog.title": { "message": "Új profil" }, @@ -353,18 +323,30 @@ "profile.dialog.placeholder": { "message": "pl. Munka" }, - "profile.dialog.required": { - "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" - }, "profile.dialog.submit": { "message": "Profil hozzáadása" }, + "profile.dialog.required": { + "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" + }, "profile.dialog.managementHelp": { "message": "NetBird Cloud vagy saját kiszolgáló." }, "profile.dialog.urlUnreachable": { "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes." }, + "header.menu.settings": { + "message": "Beállítások…" + }, + "header.menu.defaultView": { + "message": "Alapnézet" + }, + "header.menu.advancedView": { + "message": "Speciális nézet" + }, + "header.menu.updateAvailable": { + "message": "Frissítés elérhető" + }, "profile.switch.title": { "message": "Váltás a(z) \"{name}\" profilra?" }, @@ -410,6 +392,42 @@ "profile.error.loadTitle": { "message": "Profilok betöltése sikertelen" }, + "profile.dropdown.activeProfile": { + "message": "Aktív profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profilváltás" + }, + "profile.dropdown.noEmail": { + "message": "Egyéb" + }, + "profile.dropdown.addProfile": { + "message": "Profil hozzáadása" + }, + "profile.dropdown.manageProfiles": { + "message": "Profilok kezelése" + }, + "profile.dropdown.settings": { + "message": "Beállítások" + }, + "settings.profiles.section.profiles": { + "message": "Profilok" + }, + "settings.profiles.intro": { + "message": "Kezeljen több NetBird-profilt párhuzamosan, például munkahelyi és személyes fiókokat, vagy különböző felügyeleti szervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." + }, + "settings.profiles.addProfile": { + "message": "Profil hozzáadása" + }, + "settings.profiles.active": { + "message": "Aktív" + }, + "settings.profiles.emptyTitle": { + "message": "Nincsenek profilok" + }, + "settings.profiles.emptyDescription": { + "message": "Hozzon létre egy profilt a NetBird felügyeleti szerverhez való csatlakozáshoz." + }, "settings.error.loadTitle": { "message": "Beállítások betöltése sikertelen" }, @@ -428,12 +446,12 @@ "settings.tabs.security": { "message": "Biztonság" }, - "settings.tabs.ssh": { - "message": "SSH" - }, "settings.tabs.profiles": { "message": "Profilok" }, + "settings.tabs.ssh": { + "message": "SSH" + }, "settings.tabs.advanced": { "message": "Speciális" }, @@ -446,24 +464,6 @@ "settings.tabs.updateAvailable": { "message": "Frissítés elérhető" }, - "settings.profiles.section.profiles": { - "message": "Profilok" - }, - "settings.profiles.intro": { - "message": "Tartson külön NetBird-identitásokat egymás mellett, például munkahelyi és személyes fiókokat, vagy különböző kezelőszervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." - }, - "settings.profiles.addProfile": { - "message": "Profil hozzáadása" - }, - "settings.profiles.active": { - "message": "Aktív" - }, - "settings.profiles.emptyTitle": { - "message": "Nincsenek profilok" - }, - "settings.profiles.emptyDescription": { - "message": "Hozzon létre egy profilt a NetBird kezelőszerverhez való csatlakozáshoz." - }, "settings.general.section.general": { "message": "Általános" }, @@ -504,10 +504,10 @@ "message": "Nincs találat." }, "settings.general.management.label": { - "message": "Kezelőszerver" + "message": "Felügyeleti szerver" }, "settings.general.management.help": { - "message": "Csatlakozás a NetBird Cloudhoz vagy saját self-hosted kezelőszerverhez. A módosítások újracsatlakozást váltanak ki." + "message": "Csatlakozás a NetBird Cloudhoz vagy saját üzemeltetésű felügyeleti szerverhez. A módosítások újracsatlakozást váltanak ki." }, "settings.general.management.cloud": { "message": "Felhő" @@ -540,7 +540,7 @@ "message": "Útválasztás és DNS" }, "settings.network.lazy.label": { - "message": "Késleltetett kapcsolatok" + "message": "Igény szerinti kapcsolatok" }, "settings.network.lazy.help": { "message": "Állandó kapcsolatok fenntartása helyett a NetBird igény szerint, aktivitás vagy jelzés alapján aktiválja azokat." @@ -561,13 +561,13 @@ "message": "Kliens útvonalak engedélyezése" }, "settings.network.clientRoutes.help": { - "message": "Más társak útvonalainak elfogadása az ő hálózataik eléréséhez." + "message": "Más Peerek útvonalainak elfogadása a hálózataik eléréséhez." }, "settings.network.serverRoutes.label": { "message": "Szerver útvonalak engedélyezése" }, "settings.network.serverRoutes.help": { - "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más társak számára." + "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más Peerek számára." }, "settings.network.ipv6.label": { "message": "IPv6 engedélyezése" @@ -585,13 +585,13 @@ "message": "Bejövő forgalom blokkolása" }, "settings.security.blockInbound.help": { - "message": "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." + "message": "Visszautasítja a Peerektő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": { "message": "LAN-hozzáférés blokkolása" }, "settings.security.blockLan.help": { - "message": "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." + "message": "Megakadályozza, hogy a Peerek 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": { "message": "Kvantumellenálló titkosítás engedélyezése" @@ -603,7 +603,7 @@ "message": "Engedékeny mód engedélyezése" }, "settings.security.rosenpassPermissive.help": { - "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli társakkal." + "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli Peerekkel." }, "settings.ssh.section.server": { "message": "Szerver" @@ -618,13 +618,13 @@ "message": "SSH szerver engedélyezése" }, "settings.ssh.server.help": { - "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más társak csatlakozhassanak." + "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más Peerek csatlakozhassanak." }, "settings.ssh.root.label": { "message": "Root bejelentkezés engedélyezése" }, "settings.ssh.root.help": { - "message": "Társak bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." + "message": "A Peerek bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." }, "settings.ssh.sftp.label": { "message": "SFTP engedélyezése" @@ -636,13 +636,13 @@ "message": "Helyi porttovábbítás" }, "settings.ssh.localForward.help": { - "message": "A csatlakozó társak helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." + "message": "A csatlakozó Peerek helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." }, "settings.ssh.remoteForward.label": { "message": "Távoli porttovábbítás" }, "settings.ssh.remoteForward.help": { - "message": "A csatlakozó társak ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára." + "message": "A csatlakozó Peerek ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára." }, "settings.ssh.jwt.label": { "message": "JWT-hitelesítés engedélyezése" @@ -669,7 +669,7 @@ "message": "Név" }, "settings.advanced.interfaceName.error": { - "message": "Használj 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." + "message": "Használjon 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." }, "settings.advanced.interfaceName.errorMac": { "message": "„utun” után számmal kezdődjön (pl. utun100)." @@ -678,22 +678,22 @@ "message": "Port" }, "settings.advanced.port.error": { - "message": "Adj meg egy portot {min} és {max} között." + "message": "Adjon meg egy portot {min} és {max} között." }, "settings.advanced.port.help": { - "message": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ." + "message": "Ha 0-ra állítja, egy véletlenszerű szabad portot használ." }, "settings.advanced.mtu.label": { "message": "MTU" }, "settings.advanced.mtu.error": { - "message": "Adj meg egy MTU értéket {min} és {max} között." + "message": "Adjon meg egy MTU értéket {min} és {max} között." }, "settings.advanced.psk.label": { "message": "Pre-shared kulcs" }, "settings.advanced.psk.help": { - "message": "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." + "message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják." }, "settings.troubleshooting.section.title": { "message": "Hibakeresési csomag" @@ -735,7 +735,7 @@ "message": "perc" }, "settings.troubleshooting.create": { - "message": "Csomag létrehozása" + "message": "Hibakeresési csomag létrehozása" }, "settings.troubleshooting.progress.description": { "message": "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." @@ -801,10 +801,10 @@ "message": "[Fejlesztés]" }, "settings.about.gui": { - "message": "Felület v{version}" + "message": "GUI v{version}" }, "settings.about.guiName": { - "message": "Felület" + "message": "GUI" }, "settings.about.copyright": { "message": "© {year} NetBird. Minden jog fenntartva." @@ -867,7 +867,7 @@ "message": "Változásnapló" }, "update.card.onLatestVersion": { - "message": "A legfrissebb verziót használod" + "message": "A legfrissebb verziót használja" }, "update.header.tooltip": { "message": "Frissítés elérhető" @@ -990,7 +990,7 @@ "message": "NetBird beállítása" }, "welcome.management.description": { - "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van." + "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a „Saját üzemeltetésű” lehetőséget, ha saját NetBird-szervere van." }, "welcome.management.cloud.title": { "message": "NetBird Cloud" @@ -1002,10 +1002,10 @@ "message": "Saját üzemeltetésű" }, "welcome.management.selfHosted.description": { - "message": "Csatlakozás a saját menedzsmentszerveréhez." + "message": "Csatlakozás a saját felügyeleti szerveréhez." }, "welcome.management.urlLabel": { - "message": "Menedzsmentszerver URL" + "message": "Felügyeleti szerver URL" }, "welcome.management.urlPlaceholder": { "message": "https://netbird.selfhosted.com:443" @@ -1080,10 +1080,10 @@ "message": "Offline" }, "peers.empty.title": { - "message": "Nincs elérhető társ" + "message": "Nincs elérhető Peer" }, "peers.empty.description": { - "message": "Önnek vagy nincsenek elérhető társai vagy nincs hozzáférése egyikhez sem." + "message": "Önnek vagy nincsenek elérhető Peerei, vagy nincs hozzáférése egyikhez sem." }, "peers.details.domain": { "message": "Domain" @@ -1095,7 +1095,7 @@ "message": "NetBird IPv6" }, "peers.details.publicKey": { - "message": "Publikus kulcs" + "message": "Nyilvános kulcs" }, "peers.details.connection": { "message": "Kapcsolat" @@ -1104,7 +1104,7 @@ "message": "Késleltetés" }, "peers.details.lastHandshake": { - "message": "Utolsó kézfogás" + "message": "Utolsó Handshake" }, "peers.details.statusSince": { "message": "Utolsó kapcsolati frissítés" @@ -1194,19 +1194,19 @@ "message": "Összes letiltása" }, "exitNodes.search.placeholder": { - "message": "Keresés a kilépő csomópontok között" + "message": "Keresés az Exit Node-ok között" }, "exitNodes.none": { "message": "Egyik sem" }, "exitNodes.empty.title": { - "message": "Nincs elérhető kilépő csomópont" + "message": "Nincs elérhető Exit Node" }, "exitNodes.empty.description": { - "message": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat." + "message": "Ehhez a Peerhez nem osztottak meg Exit Node-okat." }, "exitNodes.card.title": { - "message": "Kilépő csomópont" + "message": "Exit Node" }, "exitNodes.card.statusActive": { "message": "Aktív" @@ -1218,7 +1218,7 @@ "message": "Egyik sem" }, "exitNodes.dropdown.noneDescription": { - "message": "Közvetlen kapcsolat kilépő csomópont nélkül" + "message": "Közvetlen kapcsolat Exit Node nélkül" }, "quickActions.connect": { "message": "Csatlakozás" @@ -1248,7 +1248,7 @@ "message": "A munkamenet lejárt. Kérjük, jelentkezzen be újra." }, "error.invalid_setup_key": { - "message": "A telepítési kulcs hiányzik vagy érvénytelen." + "message": "A telepítőkulcs hiányzik vagy érvénytelen." }, "error.permission_denied": { "message": "A szerver elutasította a bejelentkezést." diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json new file mode 100644 index 000000000..30bc70a28 --- /dev/null +++ b/client/ui/i18n/locales/it/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Disconnesso" + }, + "tray.status.daemonUnavailable": { + "message": "Non in esecuzione" + }, + "tray.status.error": { + "message": "Errore" + }, + "tray.status.connected": { + "message": "Connesso" + }, + "tray.status.connecting": { + "message": "Connessione" + }, + "tray.status.needsLogin": { + "message": "Accesso richiesto" + }, + "tray.status.loginFailed": { + "message": "Accesso non riuscito" + }, + "tray.status.sessionExpired": { + "message": "Sessione scaduta" + }, + "tray.session.expiresIn": { + "message": "La sessione scade tra {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "meno di un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minuti" + }, + "tray.session.unit.hour": { + "message": "1 ora" + }, + "tray.session.unit.hours": { + "message": "{count} ore" + }, + "tray.session.unit.day": { + "message": "1 giorno" + }, + "tray.session.unit.days": { + "message": "{count} giorni" + }, + "tray.menu.open": { + "message": "Apri NetBird" + }, + "tray.menu.connect": { + "message": "Connetti" + }, + "tray.menu.disconnect": { + "message": "Disconnetti" + }, + "tray.menu.exitNode": { + "message": "Nodo di uscita" + }, + "tray.menu.networks": { + "message": "Risorse" + }, + "tray.menu.profiles": { + "message": "Profili" + }, + "tray.menu.manageProfiles": { + "message": "Gestisci profili" + }, + "tray.menu.settings": { + "message": "Impostazioni..." + }, + "tray.menu.debugBundle": { + "message": "Crea pacchetto di debug" + }, + "tray.menu.about": { + "message": "Guida e supporto" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentazione" + }, + "tray.menu.troubleshoot": { + "message": "Risoluzione dei problemi" + }, + "tray.menu.downloadLatest": { + "message": "Scarica l'ultima versione" + }, + "tray.menu.installVersion": { + "message": "Installa la versione {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Esci da NetBird" + }, + "notify.update.title": { + "message": "Aggiornamento NetBird disponibile" + }, + "notify.update.body": { + "message": "NetBird {version} è disponibile." + }, + "notify.update.enforcedSuffix": { + "message": " L'amministratore richiede questo aggiornamento." + }, + "notify.error.title": { + "message": "Errore" + }, + "notify.error.connect": { + "message": "Connessione non riuscita" + }, + "notify.error.disconnect": { + "message": "Disconnessione non riuscita" + }, + "notify.error.switchProfile": { + "message": "Impossibile passare a {profile}" + }, + "notify.error.exitNode": { + "message": "Impossibile aggiornare il nodo di uscita {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessione NetBird scaduta" + }, + "notify.sessionExpired.body": { + "message": "La sessione NetBird è scaduta. Effettui di nuovo l'accesso." + }, + "notify.sessionWarning.title": { + "message": "La sessione sta per scadere" + }, + "notify.sessionWarning.body": { + "message": "La sessione NetBird scade tra {remaining}. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "La sessione NetBird sta per scadere. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.extend": { + "message": "Rinnova ora" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignora" + }, + "notify.sessionWarning.failed": { + "message": "Impossibile rinnovare la sessione NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessione NetBird rinnovata" + }, + "notify.sessionWarning.successBody": { + "message": "La sessione è stata rinnovata." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Scadenza sessione rifiutata" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso." + }, + "common.cancel": { + "message": "Annulla" + }, + "common.save": { + "message": "Salva" + }, + "common.saveChanges": { + "message": "Salva modifiche" + }, + "common.saving": { + "message": "Salvataggio…" + }, + "common.close": { + "message": "Chiudi" + }, + "common.copy": { + "message": "Copia" + }, + "common.togglePasswordVisibility": { + "message": "Mostra/nascondi password" + }, + "common.increase": { + "message": "Aumenta" + }, + "common.decrease": { + "message": "Diminuisci" + }, + "common.delete": { + "message": "Elimina" + }, + "common.create": { + "message": "Crea" + }, + "common.add": { + "message": "Aggiungi" + }, + "common.remove": { + "message": "Rimuovi" + }, + "common.refresh": { + "message": "Aggiorna" + }, + "common.loading": { + "message": "Caricamento…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nessun risultato trovato" + }, + "common.noResults.description": { + "message": "Non è stato trovato alcun risultato. Provi un altro termine di ricerca o modifichi i filtri." + }, + "notConnected.title": { + "message": "Disconnesso" + }, + "notConnected.description": { + "message": "Si connetta prima a NetBird per visualizzare informazioni dettagliate su peer, risorse di rete e nodi di uscita." + }, + "connect.status.disconnected": { + "message": "Disconnesso" + }, + "connect.status.connecting": { + "message": "Connessione..." + }, + "connect.status.connected": { + "message": "Connesso" + }, + "connect.status.disconnecting": { + "message": "Disconnessione..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non disponibile" + }, + "connect.status.loginRequired": { + "message": "Accesso richiesto" + }, + "connect.error.loginTitle": { + "message": "Accesso non riuscito" + }, + "connect.error.connectTitle": { + "message": "Connessione non riuscita" + }, + "connect.error.disconnectTitle": { + "message": "Disconnessione non riuscita" + }, + "nav.peers.title": { + "message": "Peer" + }, + "nav.peers.description": { + "message": "{connected} di {total} connessi" + }, + "nav.resources.title": { + "message": "Risorse" + }, + "nav.resources.description": { + "message": "{active} di {total} attive" + }, + "nav.exitNode.title": { + "message": "Nodi di uscita" + }, + "nav.exitNode.none": { + "message": "Non attivo" + }, + "nav.exitNode.using": { + "message": "Tramite {name}" + }, + "header.openSettings": { + "message": "Apri impostazioni" + }, + "header.togglePanel": { + "message": "Mostra/nascondi pannello laterale" + }, + "profile.selector.loading": { + "message": "Caricamento..." + }, + "profile.selector.noProfile": { + "message": "Nessun profilo" + }, + "profile.selector.searchPlaceholder": { + "message": "Cerca profilo per nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nessun profilo trovato" + }, + "profile.selector.emptyDescription": { + "message": "Provi un altro termine di ricerca o crei un nuovo profilo." + }, + "profile.selector.newProfile": { + "message": "Nuovo profilo" + }, + "profile.selector.moreOptions": { + "message": "Altre opzioni" + }, + "profile.selector.deregister": { + "message": "Annulla registrazione" + }, + "profile.selector.delete": { + "message": "Elimina" + }, + "profile.selector.switchTo": { + "message": "Passa a questo profilo" + }, + "profile.dialog.title": { + "message": "Inserisci il nome del profilo" + }, + "profile.dialog.nameLabel": { + "message": "Nome del profilo" + }, + "profile.dialog.description": { + "message": "Imposti un nome facilmente riconoscibile per il profilo." + }, + "profile.dialog.placeholder": { + "message": "es. lavoro" + }, + "profile.dialog.submit": { + "message": "Aggiungi profilo" + }, + "profile.dialog.required": { + "message": "Inserisca un nome per il profilo, es. lavoro, casa" + }, + "profile.dialog.managementHelp": { + "message": "Usi NetBird Cloud o il suo server." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure aggiunga comunque il profilo se è certo che sia corretto." + }, + "header.menu.settings": { + "message": "Impostazioni..." + }, + "header.menu.defaultView": { + "message": "Vista predefinita" + }, + "header.menu.advancedView": { + "message": "Vista avanzata" + }, + "header.menu.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "profile.switch.title": { + "message": "Passare al profilo «{name}»?" + }, + "profile.switch.message": { + "message": "Vuole davvero cambiare profilo?\nIl profilo attuale verrà disconnesso." + }, + "profile.switch.confirm": { + "message": "Conferma" + }, + "profile.deregister.title": { + "message": "Annullare la registrazione del profilo «{name}»?" + }, + "profile.deregister.message": { + "message": "Vuole davvero annullare la registrazione di questo profilo?\nDovrà accedere di nuovo per usarlo." + }, + "profile.deregister.confirm": { + "message": "Annulla registrazione" + }, + "profile.delete.title": { + "message": "Eliminare il profilo «{name}»?" + }, + "profile.delete.message": { + "message": "Vuole davvero eliminare questo profilo?\nQuesta azione non può essere annullata." + }, + "profile.delete.disabledActive": { + "message": "I profili attivi non possono essere eliminati. Passi a un altro profilo prima di eliminare questo." + }, + "profile.delete.disabledDefault": { + "message": "Il profilo predefinito non può essere eliminato." + }, + "profile.error.switchTitle": { + "message": "Cambio profilo non riuscito" + }, + "profile.error.deregisterTitle": { + "message": "Annullamento registrazione non riuscito" + }, + "profile.error.deleteTitle": { + "message": "Eliminazione profilo non riuscita" + }, + "profile.error.createTitle": { + "message": "Creazione profilo non riuscita" + }, + "profile.error.loadTitle": { + "message": "Caricamento profili non riuscito" + }, + "profile.dropdown.activeProfile": { + "message": "Profilo attivo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambia profilo" + }, + "profile.dropdown.noEmail": { + "message": "Altro" + }, + "profile.dropdown.addProfile": { + "message": "Aggiungi profilo" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestisci profili" + }, + "profile.dropdown.settings": { + "message": "Impostazioni" + }, + "settings.profiles.section.profiles": { + "message": "Profili" + }, + "settings.profiles.intro": { + "message": "Mantenga affiancate identità NetBird separate, ad esempio account di lavoro e personali, oppure server di gestione diversi. Aggiunga, annulli la registrazione o elimini i profili qui sotto." + }, + "settings.profiles.addProfile": { + "message": "Aggiungi profilo" + }, + "settings.profiles.active": { + "message": "Attivo" + }, + "settings.profiles.emptyTitle": { + "message": "Nessun profilo" + }, + "settings.profiles.emptyDescription": { + "message": "Crei un profilo per connettersi a un server di gestione NetBird." + }, + "settings.error.loadTitle": { + "message": "Caricamento impostazioni non riuscito" + }, + "settings.error.saveTitle": { + "message": "Salvataggio impostazioni non riuscito" + }, + "settings.error.debugBundleTitle": { + "message": "Pacchetto di debug non riuscito" + }, + "settings.tabs.general": { + "message": "Generale" + }, + "settings.tabs.network": { + "message": "Rete" + }, + "settings.tabs.security": { + "message": "Sicurezza" + }, + "settings.tabs.profiles": { + "message": "Profili" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzate" + }, + "settings.tabs.troubleshooting": { + "message": "Risoluzione problemi" + }, + "settings.tabs.about": { + "message": "Informazioni" + }, + "settings.tabs.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "settings.general.section.general": { + "message": "Generale" + }, + "settings.general.section.connection": { + "message": "Connessione" + }, + "settings.general.connectOnStartup.label": { + "message": "Connetti all'avvio" + }, + "settings.general.connectOnStartup.help": { + "message": "Stabilisce automaticamente una connessione all'avvio del servizio." + }, + "settings.general.notifications.label": { + "message": "Notifiche desktop" + }, + "settings.general.notifications.help": { + "message": "Mostra notifiche desktop per nuovi aggiornamenti ed eventi di connessione." + }, + "settings.general.autostart.label": { + "message": "Avvia l'interfaccia NetBird all'accesso" + }, + "settings.general.autostart.help": { + "message": "Avvia automaticamente l'interfaccia NetBird quando effettua l'accesso. Riguarda solo l'interfaccia grafica, non il servizio in background." + }, + "settings.general.autostart.errorTitle": { + "message": "Modifica avvio automatico non riuscita" + }, + "settings.general.language.label": { + "message": "Lingua dell'interfaccia" + }, + "settings.general.language.help": { + "message": "Scelga la lingua dell'interfaccia NetBird." + }, + "settings.general.language.search": { + "message": "Cerca lingua…" + }, + "settings.general.language.empty": { + "message": "Nessuna lingua corrisponde." + }, + "settings.general.management.label": { + "message": "Server di gestione" + }, + "settings.general.management.help": { + "message": "Si connetta a NetBird Cloud o al suo server di gestione self-hosted. Le modifiche riconnettono il client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure salvi comunque se è certo che sia corretto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Passare a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Questo disconnette il suo server self-hosted.\nPotrebbe dover accedere di nuovo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Passa a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connettività" + }, + "settings.network.section.routingDns": { + "message": "Routing e DNS" + }, + "settings.network.lazy.label": { + "message": "Connessioni lazy" + }, + "settings.network.lazy.help": { + "message": "Invece di mantenere connessioni sempre attive, NetBird le attiva su richiesta in base all'attività o al signaling." + }, + "settings.network.monitor.label": { + "message": "Riconnetti al cambio di rete" + }, + "settings.network.monitor.help": { + "message": "Monitora la rete e si riconnette automaticamente ai cambiamenti, come il passaggio di Wi-Fi, le modifiche Ethernet o la ripresa dalla sospensione." + }, + "settings.network.dns.label": { + "message": "Abilita DNS" + }, + "settings.network.dns.help": { + "message": "Applica le impostazioni DNS gestite da NetBird al resolver dell'host." + }, + "settings.network.clientRoutes.label": { + "message": "Abilita route client" + }, + "settings.network.clientRoutes.help": { + "message": "Accetta le route da altri peer per raggiungere le loro reti." + }, + "settings.network.serverRoutes.label": { + "message": "Abilita route server" + }, + "settings.network.serverRoutes.help": { + "message": "Annuncia le route locali di questo host agli altri peer." + }, + "settings.network.ipv6.label": { + "message": "Abilita IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa l'indirizzamento IPv6 per la rete overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Crittografia" + }, + "settings.security.blockInbound.label": { + "message": "Blocca traffico in entrata" + }, + "settings.security.blockInbound.help": { + "message": "Rifiuta le connessioni non richieste dai peer verso questo dispositivo e le reti che instrada. Il traffico in uscita non è interessato." + }, + "settings.security.blockLan.label": { + "message": "Blocca accesso alla LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedisce ai peer di raggiungere la sua rete locale o i suoi dispositivi quando questo dispositivo instrada il loro traffico." + }, + "settings.security.rosenpass.label": { + "message": "Abilita resistenza quantistica" + }, + "settings.security.rosenpass.help": { + "message": "Aggiunge uno scambio di chiavi post-quantistico tramite Rosenpass sopra WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Abilita modalità permissiva" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Consente le connessioni con peer privi del supporto alla resistenza quantistica." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funzionalità" + }, + "settings.ssh.section.authentication": { + "message": "Autenticazione" + }, + "settings.ssh.server.label": { + "message": "Abilita server SSH" + }, + "settings.ssh.server.help": { + "message": "Esegue il server SSH di NetBird su questo host in modo che altri peer possano connettersi." + }, + "settings.ssh.root.label": { + "message": "Consenti accesso come root" + }, + "settings.ssh.root.help": { + "message": "Permette ai peer di accedere come utente root. Disabiliti per richiedere un account senza privilegi." + }, + "settings.ssh.sftp.label": { + "message": "Consenti SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Trasferisca file in modo sicuro usando client SFTP o SCP nativi." + }, + "settings.ssh.localForward.label": { + "message": "Inoltro porte locale" + }, + "settings.ssh.localForward.help": { + "message": "Permette ai peer in connessione di inoltrare porte locali verso servizi raggiungibili da questo host." + }, + "settings.ssh.remoteForward.label": { + "message": "Inoltro porte remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permette ai peer in connessione di esporre porte di questo host verso la loro macchina." + }, + "settings.ssh.jwt.label": { + "message": "Abilita autenticazione JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica ogni sessione SSH con il suo IdP per l'identità utente e l'audit. Disabiliti per basarsi solo sulle policy ACL di rete, utile quando non è disponibile un IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Per quanto tempo questo client memorizza un JWT prima di richiederlo di nuovo sulle connessioni SSH in uscita. Imposti 0 per disabilitare la cache e autenticarsi a ogni connessione." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "sec." + }, + "settings.advanced.section.interface": { + "message": "Interfaccia" + }, + "settings.advanced.section.security": { + "message": "Sicurezza" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Usi da 1 a 15 lettere, cifre, punti, trattini o trattini bassi." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve iniziare con \"utun\" seguito da un numero (es. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Inserisca una porta compresa tra {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se impostata su 0, verrà usata una porta libera casuale." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Inserisca un valore MTU compreso tra {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chiave pre-condivisa" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa." + }, + "settings.troubleshooting.section.title": { + "message": "Pacchetto di debug" + }, + "settings.troubleshooting.intro": { + "message": "Un pacchetto di debug aiuta il supporto NetBird a indagare sui problemi di connessione. <br /> È un file .zip con log, dettagli di sistema e informazioni di debug del suo dispositivo." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizza informazioni sensibili" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Includi informazioni di sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, interfacce di rete e tabelle di routing." + }, + "settings.troubleshooting.upload.label": { + "message": "Carica il pacchetto sui server NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Carica il pacchetto in modo sicuro e restituisce una chiave di caricamento. Condivida la chiave con il supporto NetBird tramite GitHub o Slack invece di allegare direttamente il file." + }, + "settings.troubleshooting.trace.label": { + "message": "Acquisisci log di traccia" + }, + "settings.troubleshooting.trace.help": { + "message": "Aumenta il livello di log a TRACE e riavvia NetBird per acquisire i log di connessione. Il livello precedente viene ripristinato dopo la creazione del pacchetto." + }, + "settings.troubleshooting.duration.label": { + "message": "Durata acquisizione" + }, + "settings.troubleshooting.duration.help": { + "message": "Per quanto tempo acquisire i log di traccia prima di generare il pacchetto." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min." + }, + "settings.troubleshooting.create": { + "message": "Crea pacchetto" + }, + "settings.troubleshooting.progress.description": { + "message": "Raccolta di log, dettagli di sistema e stato della connessione. Di solito richiede un momento: tenga aperta questa finestra fino al completamento." + }, + "settings.troubleshooting.cancelling": { + "message": "Annullamento…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacchetto di debug caricato con successo!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacchetto salvato" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Condivida la chiave di caricamento qui sotto con il supporto NetBird. Una copia locale è stata salvata anche sul suo dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Il pacchetto di debug è stato salvato localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copia chiave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Apri cartella" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Apri posizione file" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Caricamento non riuscito: {reason} Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Caricamento non riuscito. Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Passaggio al logging di traccia…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Riconnessione di NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Acquisizione log — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Ripristino del livello di log precedente…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generazione del pacchetto di debug…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Caricamento su NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annullamento…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tutti i diritti riservati." + }, + "settings.about.links.imprint": { + "message": "Note legali" + }, + "settings.about.links.privacy": { + "message": "Privacy" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termini di servizio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentazione" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} è pronto per l'installazione." + }, + "update.banner.later": { + "message": "Più tardi" + }, + "update.banner.installNow": { + "message": "Installa ora" + }, + "update.card.versionAvailableDownload": { + "message": "La versione {version} è disponibile per il download." + }, + "update.card.versionAvailableInstall": { + "message": "La versione {version} è disponibile per l'installazione." + }, + "update.card.whatsNew": { + "message": "Novità?" + }, + "update.card.installNow": { + "message": "Installa ora" + }, + "update.card.getInstaller": { + "message": "Scarica" + }, + "update.card.autoCheckInterval": { + "message": "NetBird verifica gli aggiornamenti in background." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sta usando l'ultima versione" + }, + "update.header.tooltip": { + "message": "Aggiornamento disponibile" + }, + "update.overlay.updatingVersion": { + "message": "Aggiornamento di NetBird alla v{version}" + }, + "update.overlay.updating": { + "message": "Aggiornamento di NetBird" + }, + "update.overlay.description": { + "message": "È disponibile una versione più recente ed è in corso l'installazione. NetBird si riavvierà automaticamente al termine dell'aggiornamento." + }, + "update.overlay.error.timeoutTitle": { + "message": "L'aggiornamento richiede troppo tempo" + }, + "update.overlay.error.timeoutDescription": { + "message": "L'installazione di {target} ha richiesto troppo tempo e non è stata completata." + }, + "update.overlay.error.canceledTitle": { + "message": "Aggiornamento interrotto" + }, + "update.overlay.error.canceledDescription": { + "message": "L'aggiornamento a {target} è stato annullato prima del completamento." + }, + "update.overlay.error.failTitle": { + "message": "Impossibile installare l'aggiornamento" + }, + "update.overlay.error.failDescription": { + "message": "Impossibile installare {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "errore sconosciuto" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nuova versione" + }, + "update.error.loadStateTitle": { + "message": "Caricamento stato aggiornamento non riuscito" + }, + "update.error.triggerTitle": { + "message": "Avvio aggiornamento non riuscito" + }, + "update.page.versionLine": { + "message": "Aggiornamento del client a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Aggiornamento del client." + }, + "update.page.outdated": { + "message": "La versione del client è precedente alla versione di aggiornamento automatico impostata in Management." + }, + "update.page.status.running": { + "message": "Aggiornamento in corso" + }, + "update.page.status.timeout": { + "message": "Aggiornamento scaduto. Riprovi." + }, + "update.page.status.canceled": { + "message": "Aggiornamento annullato." + }, + "update.page.status.failed": { + "message": "Aggiornamento non riuscito: {message}" + }, + "update.page.status.unknownError": { + "message": "errore di aggiornamento sconosciuto" + }, + "update.page.failedTitle": { + "message": "Aggiornamento non riuscito" + }, + "update.page.timeoutMessage": { + "message": "Aggiornamento scaduto." + }, + "update.page.dontClose": { + "message": "Non chiuda questa finestra." + }, + "update.page.updating": { + "message": "Aggiornamento…" + }, + "update.page.complete": { + "message": "Aggiornamento completato" + }, + "update.page.failed": { + "message": "Aggiornamento non riuscito" + }, + "window.title.settings": { + "message": "Impostazioni" + }, + "window.title.signIn": { + "message": "Accesso" + }, + "window.title.sessionExpiration": { + "message": "Sessione in scadenza" + }, + "window.title.updating": { + "message": "Aggiornamento" + }, + "window.title.welcome": { + "message": "Benvenuto in NetBird" + }, + "window.title.error": { + "message": "Errore" + }, + "welcome.title": { + "message": "Cerchi NetBird nella tray" + }, + "welcome.description": { + "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.continue": { + "message": "Continua" + }, + "welcome.back": { + "message": "Indietro" + }, + "welcome.management.title": { + "message": "Configura NetBird" + }, + "welcome.management.description": { + "message": "Clicchi su Continua per iniziare, oppure scelga Self-hosted se dispone di un proprio server NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Usi il nostro servizio gestito. Nessuna configurazione necessaria." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Si connetta al suo server di gestione." + }, + "welcome.management.urlLabel": { + "message": "URL del server di gestione" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL o la sua rete, poi prosegua se è certo che sia corretto." + }, + "welcome.management.checking": { + "message": "Verifica…" + }, + "browserLogin.title": { + "message": "Continui nel browser per completare l'accesso" + }, + "browserLogin.notSeeing": { + "message": "Non vede la scheda del browser?" + }, + "browserLogin.tryAgain": { + "message": "Riprova" + }, + "browserLogin.openFailedTitle": { + "message": "Apertura browser non riuscita" + }, + "sessionExpiration.title": { + "message": "La sessione sta per scadere" + }, + "sessionExpiration.titleLater": { + "message": "La sessione scadrà" + }, + "sessionExpiration.description": { + "message": "Questo dispositivo verrà disconnesso a breve. Rinnovi con un accesso dal browser." + }, + "sessionExpiration.descriptionLater": { + "message": "Un accesso dal browser mantiene questo dispositivo connesso alla sua rete." + }, + "sessionExpiration.stay": { + "message": "Rinnova sessione" + }, + "sessionExpiration.authenticate": { + "message": "Autenticati" + }, + "sessionExpiration.logout": { + "message": "Esci" + }, + "sessionExpiration.expired": { + "message": "Sessione scaduta" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo disconnesso. Si autentichi con un accesso dal browser per riconnettersi." + }, + "sessionExpiration.close": { + "message": "Chiudi" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Rinnovo sessione non riuscito" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Disconnessione non riuscita" + }, + "peers.search.placeholder": { + "message": "Cerca per nome o IP" + }, + "peers.filter.all": { + "message": "Tutti" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nessun peer disponibile" + }, + "peers.empty.description": { + "message": "Non ha peer disponibili oppure non ha accesso a nessuno di essi." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Chiave pubblica" + }, + "peers.details.connection": { + "message": "Connessione" + }, + "peers.details.latency": { + "message": "Latenza" + }, + "peers.details.lastHandshake": { + "message": "Ultimo handshake" + }, + "peers.details.statusSince": { + "message": "Ultimo aggiornamento connessione" + }, + "peers.details.bytes": { + "message": "Byte" + }, + "peers.details.bytesSent": { + "message": "Inviati" + }, + "peers.details.bytesReceived": { + "message": "Ricevuti" + }, + "peers.details.localIce": { + "message": "ICE locale" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Mai" + }, + "peers.details.justNow": { + "message": "Proprio ora" + }, + "peers.details.refresh": { + "message": "Aggiorna" + }, + "peers.status.connected": { + "message": "Connesso" + }, + "peers.status.connecting": { + "message": "Connessione" + }, + "peers.status.disconnected": { + "message": "Disconnesso" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Risorse" + }, + "peers.details.relayed": { + "message": "Tramite relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass abilitato" + }, + "networks.search.placeholder": { + "message": "Cerca per rete o dominio" + }, + "networks.filter.all": { + "message": "Tutte" + }, + "networks.filter.active": { + "message": "Attive" + }, + "networks.filter.overlapping": { + "message": "Sovrapposte" + }, + "networks.empty.title": { + "message": "Nessuna risorsa disponibile" + }, + "networks.empty.description": { + "message": "Non ha risorse di rete disponibili oppure non ha accesso a nessuna di esse." + }, + "networks.selected": { + "message": "Selezionata" + }, + "networks.unselected": { + "message": "Non selezionata" + }, + "networks.ips.heading": { + "message": "IP risolti" + }, + "networks.bulk.selectionCount": { + "message": "{selected} di {total} attive" + }, + "networks.bulk.enableAll": { + "message": "Abilita tutte" + }, + "networks.bulk.disableAll": { + "message": "Disabilita tutte" + }, + "exitNodes.search.placeholder": { + "message": "Cerca nodi di uscita" + }, + "exitNodes.none": { + "message": "Nessuno" + }, + "exitNodes.empty.title": { + "message": "Nessun nodo di uscita disponibile" + }, + "exitNodes.empty.description": { + "message": "Nessun nodo di uscita è stato condiviso con questo peer." + }, + "exitNodes.card.title": { + "message": "Nodo di uscita" + }, + "exitNodes.card.statusActive": { + "message": "Attivo" + }, + "exitNodes.card.statusInactive": { + "message": "Inattivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nessuno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connessione diretta senza nodo di uscita" + }, + "quickActions.connect": { + "message": "Connetti" + }, + "quickActions.disconnect": { + "message": "Disconnetti" + }, + "daemon.unavailable.title": { + "message": "Il servizio NetBird non è in esecuzione" + }, + "daemon.unavailable.description": { + "message": "L'app si riconnetterà automaticamente non appena il servizio sarà in esecuzione." + }, + "daemon.unavailable.docsLink": { + "message": "Documentazione" + }, + "error.jwt_clock_skew": { + "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." + }, + "error.jwt_expired": { + "message": "Il token di accesso è scaduto. Effettui di nuovo l'accesso." + }, + "error.jwt_signature_invalid": { + "message": "Accesso non riuscito: la firma del token non è valida. Contatti il suo amministratore." + }, + "error.session_expired": { + "message": "La sessione è scaduta. Effettui di nuovo l'accesso." + }, + "error.invalid_setup_key": { + "message": "La chiave di configurazione è mancante o non valida." + }, + "error.permission_denied": { + "message": "L'accesso è stato rifiutato dal server." + }, + "error.daemon_unreachable": { + "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." + }, + "error.unknown": { + "message": "Operazione non riuscita." + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json new file mode 100644 index 000000000..292a2c820 --- /dev/null +++ b/client/ui/i18n/locales/pt/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Não está em execução" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Login necessário" + }, + "tray.status.loginFailed": { + "message": "Falha no login" + }, + "tray.status.sessionExpired": { + "message": "Sessão expirada" + }, + "tray.session.expiresIn": { + "message": "A sessão expira em {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de um minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 dia" + }, + "tray.session.unit.days": { + "message": "{count} dias" + }, + "tray.menu.open": { + "message": "Abrir o NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nó de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfis" + }, + "tray.menu.manageProfiles": { + "message": "Gerenciar perfis" + }, + "tray.menu.settings": { + "message": "Configurações..." + }, + "tray.menu.debugBundle": { + "message": "Criar pacote de depuração" + }, + "tray.menu.about": { + "message": "Ajuda e suporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentação" + }, + "tray.menu.troubleshoot": { + "message": "Solução de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Baixar a versão mais recente" + }, + "tray.menu.installVersion": { + "message": "Instalar a versão {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Sair do NetBird" + }, + "notify.update.title": { + "message": "Atualização do NetBird disponível" + }, + "notify.update.body": { + "message": "O NetBird {version} está disponível." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador exige esta atualização." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Falha ao conectar" + }, + "notify.error.disconnect": { + "message": "Falha ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Falha ao alternar para {profile}" + }, + "notify.error.exitNode": { + "message": "Falha ao atualizar o nó de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessão do NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "A sua sessão do NetBird expirou. Faça login novamente." + }, + "notify.sessionWarning.title": { + "message": "A sessão expira em breve" + }, + "notify.sessionWarning.body": { + "message": "A sua sessão do NetBird expira em {remaining}. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A sua sessão do NetBird está prestes a expirar. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.extend": { + "message": "Renovar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Dispensar" + }, + "notify.sessionWarning.failed": { + "message": "Falha ao renovar a sessão do NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessão do NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "A sua sessão foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Prazo da sessão rejeitado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou um prazo de sessão inválido. Faça login novamente." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Salvar" + }, + "common.saveChanges": { + "message": "Salvar alterações" + }, + "common.saving": { + "message": "Salvando…" + }, + "common.close": { + "message": "Fechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Alternar visibilidade da senha" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuir" + }, + "common.delete": { + "message": "Excluir" + }, + "common.create": { + "message": "Criar" + }, + "common.add": { + "message": "Adicionar" + }, + "common.remove": { + "message": "Remover" + }, + "common.refresh": { + "message": "Atualizar" + }, + "common.loading": { + "message": "Carregando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nenhum resultado encontrado" + }, + "common.noResults.description": { + "message": "Não encontramos nenhum resultado. Tente um termo de busca diferente ou altere os filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conecte-se ao NetBird primeiro para ver informações detalhadas sobre seus peers, recursos de rede e nós de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponível" + }, + "connect.status.loginRequired": { + "message": "Login necessário" + }, + "connect.error.loginTitle": { + "message": "Falha no login" + }, + "connect.error.connectTitle": { + "message": "Falha ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Falha ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} ativos" + }, + "nav.exitNode.title": { + "message": "Nós de saída" + }, + "nav.exitNode.none": { + "message": "Inativo" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Abrir configurações" + }, + "header.togglePanel": { + "message": "Alternar painel lateral" + }, + "profile.selector.loading": { + "message": "Carregando..." + }, + "profile.selector.noProfile": { + "message": "Nenhum perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nenhum perfil encontrado" + }, + "profile.selector.emptyDescription": { + "message": "Tente um termo de busca diferente ou crie um novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Mais opções" + }, + "profile.selector.deregister": { + "message": "Cancelar registro" + }, + "profile.selector.delete": { + "message": "Excluir" + }, + "profile.selector.switchTo": { + "message": "Alternar para este perfil" + }, + "profile.dialog.title": { + "message": "Insira o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Defina um nome fácil de identificar para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "ex.: trabalho" + }, + "profile.dialog.submit": { + "message": "Adicionar perfil" + }, + "profile.dialog.required": { + "message": "Insira um nome de perfil, ex.: trabalho, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use o NetBird Cloud ou seu próprio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou adicione o perfil mesmo assim se tiver certeza de que está correta." + }, + "header.menu.settings": { + "message": "Configurações..." + }, + "header.menu.defaultView": { + "message": "Visualização padrão" + }, + "header.menu.advancedView": { + "message": "Visualização avançada" + }, + "header.menu.updateAvailable": { + "message": "Atualização disponível" + }, + "profile.switch.title": { + "message": "Alternar perfil para \"{name}\"?" + }, + "profile.switch.message": { + "message": "Tem certeza de que deseja alternar de perfil?\nO seu perfil atual será desconectado." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Cancelar registro do perfil \"{name}\"?" + }, + "profile.deregister.message": { + "message": "Tem certeza de que deseja cancelar o registro deste perfil?\nVocê precisará fazer login novamente para usá-lo." + }, + "profile.deregister.confirm": { + "message": "Cancelar registro" + }, + "profile.delete.title": { + "message": "Excluir o perfil \"{name}\"?" + }, + "profile.delete.message": { + "message": "Tem certeza de que deseja excluir este perfil?\nEsta ação não pode ser desfeita." + }, + "profile.delete.disabledActive": { + "message": "Perfis ativos não podem ser excluídos. Alterne para outro antes de excluir este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil padrão não pode ser excluído." + }, + "profile.error.switchTitle": { + "message": "Falha ao alternar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Falha ao cancelar registro do perfil" + }, + "profile.error.deleteTitle": { + "message": "Falha ao excluir o perfil" + }, + "profile.error.createTitle": { + "message": "Falha ao criar o perfil" + }, + "profile.error.loadTitle": { + "message": "Falha ao carregar os perfis" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil ativo" + }, + "profile.dropdown.switchProfile": { + "message": "Alternar perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Adicionar perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gerenciar perfis" + }, + "profile.dropdown.settings": { + "message": "Configurações" + }, + "settings.profiles.section.profiles": { + "message": "Perfis" + }, + "settings.profiles.intro": { + "message": "Mantenha identidades separadas do NetBird lado a lado, por exemplo contas de trabalho e pessoais, ou diferentes servidores de gerenciamento. Adicione, cancele o registro ou exclua perfis abaixo." + }, + "settings.profiles.addProfile": { + "message": "Adicionar perfil" + }, + "settings.profiles.active": { + "message": "Ativo" + }, + "settings.profiles.emptyTitle": { + "message": "Nenhum perfil" + }, + "settings.profiles.emptyDescription": { + "message": "Crie um perfil para conectar a um servidor de gerenciamento do NetBird." + }, + "settings.error.loadTitle": { + "message": "Falha ao carregar as configurações" + }, + "settings.error.saveTitle": { + "message": "Falha ao salvar as configurações" + }, + "settings.error.debugBundleTitle": { + "message": "Falha no pacote de depuração" + }, + "settings.tabs.general": { + "message": "Geral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Segurança" + }, + "settings.tabs.profiles": { + "message": "Perfis" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avançado" + }, + "settings.tabs.troubleshooting": { + "message": "Solução de problemas" + }, + "settings.tabs.about": { + "message": "Sobre" + }, + "settings.tabs.updateAvailable": { + "message": "Atualização disponível" + }, + "settings.general.section.general": { + "message": "Geral" + }, + "settings.general.section.connection": { + "message": "Conexão" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Estabelecer uma conexão automaticamente quando o serviço iniciar." + }, + "settings.general.notifications.label": { + "message": "Notificações na área de trabalho" + }, + "settings.general.notifications.help": { + "message": "Mostrar notificações na área de trabalho para novas atualizações e eventos de conexão." + }, + "settings.general.autostart.label": { + "message": "Iniciar a interface do NetBird ao fazer login" + }, + "settings.general.autostart.help": { + "message": "Iniciar a interface do NetBird automaticamente quando você fizer login. Isto afeta apenas a interface gráfica, não o serviço em segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Falha ao alterar o início automático" + }, + "settings.general.language.label": { + "message": "Idioma de exibição" + }, + "settings.general.language.help": { + "message": "Escolha o idioma da interface do NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Nenhum idioma corresponde." + }, + "settings.general.management.label": { + "message": "Servidor de gerenciamento" + }, + "settings.general.management.help": { + "message": "Conecte ao NetBird Cloud ou ao seu próprio servidor de gerenciamento auto-hospedado. As alterações reconectarão o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hospedado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou salve mesmo assim se tiver certeza de que está correta." + }, + "settings.general.management.switchCloudTitle": { + "message": "Alternar para o NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor auto-hospedado.\nVocê pode precisar fazer login novamente." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Alternar para o Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Roteamento e DNS" + }, + "settings.network.lazy.label": { + "message": "Conexões sob demanda" + }, + "settings.network.lazy.help": { + "message": "Em vez de manter conexões sempre ativas, o NetBird as ativa sob demanda com base na atividade ou na sinalização." + }, + "settings.network.monitor.label": { + "message": "Reconectar ao mudar de rede" + }, + "settings.network.monitor.help": { + "message": "Monitora a rede e reconecta automaticamente diante de mudanças como troca de Wi-Fi, alterações na Ethernet ou retorno do modo de suspensão." + }, + "settings.network.dns.label": { + "message": "Ativar DNS" + }, + "settings.network.dns.help": { + "message": "Aplicar as configurações de DNS gerenciadas pelo NetBird ao resolvedor do host." + }, + "settings.network.clientRoutes.label": { + "message": "Ativar rotas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Aceitar rotas de outros peers para alcançar as redes deles." + }, + "settings.network.serverRoutes.label": { + "message": "Ativar rotas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anunciar as rotas locais deste host para outros peers." + }, + "settings.network.ipv6.label": { + "message": "Ativar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usar endereçamento IPv6 para a rede de sobreposição do NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Criptografia" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfego de entrada" + }, + "settings.security.blockInbound.help": { + "message": "Rejeitar conexões não solicitadas de peers para este dispositivo e quaisquer redes que ele roteie. O tráfego de saída não é afetado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acesso à LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedir que peers alcancem a sua rede local ou os dispositivos dela quando este dispositivo roteia o tráfego deles." + }, + "settings.security.rosenpass.label": { + "message": "Ativar resistência quântica" + }, + "settings.security.rosenpass.help": { + "message": "Adicionar uma troca de chaves pós-quântica via Rosenpass sobre o WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Ativar modo permissivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permitir conexões com peers sem suporte a resistência quântica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Recursos" + }, + "settings.ssh.section.authentication": { + "message": "Autenticação" + }, + "settings.ssh.server.label": { + "message": "Ativar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executar o servidor SSH do NetBird neste host para que outros peers possam conectar a ele." + }, + "settings.ssh.root.label": { + "message": "Permitir login como root" + }, + "settings.ssh.root.help": { + "message": "Permitir que peers façam login como usuário root. Desative para exigir uma conta sem privilégios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transferir arquivos com segurança usando clientes SFTP ou SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Encaminhamento de porta local" + }, + "settings.ssh.localForward.help": { + "message": "Permitir que peers conectados encaminhem portas locais para serviços acessíveis a partir deste host." + }, + "settings.ssh.remoteForward.label": { + "message": "Encaminhamento de porta remota" + }, + "settings.ssh.remoteForward.help": { + "message": "Permitir que peers conectados exponham portas deste host de volta para a própria máquina deles." + }, + "settings.ssh.jwt.label": { + "message": "Ativar autenticação JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verificar cada sessão SSH no seu IdP para identidade do usuário e auditoria. Desative para depender apenas das políticas de ACL da rede, útil quando nenhum IdP está disponível." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL do cache de JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Por quanto tempo este cliente mantém um JWT em cache antes de solicitar novamente em conexões SSH de saída. Defina como 0 para desativar o cache e autenticar em cada conexão." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Segurança" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, pontos, hifens ou sublinhados." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve começar com \"utun\" seguido de um número (ex.: utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Insira uma porta entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se definida como 0, uma porta livre aleatória será usada." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Insira um valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave pré-compartilhada" + }, + "settings.advanced.psk.help": { + "message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada." + }, + "settings.troubleshooting.section.title": { + "message": "Pacote de depuração" + }, + "settings.troubleshooting.intro": { + "message": "Um pacote de depuração ajuda o suporte do NetBird a investigar problemas de conexão. <br /> É um arquivo .zip com logs, detalhes do sistema e informações de depuração do seu dispositivo." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar informações sensíveis" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir informações do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluir o OS, o kernel, as interfaces de rede e as tabelas de roteamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Enviar pacote aos servidores do NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Envia o pacote com segurança e retorna uma chave de upload. Compartilhe a chave com o suporte do NetBird via GitHub ou Slack em vez de anexar o arquivo diretamente." + }, + "settings.troubleshooting.trace.label": { + "message": "Capturar logs de trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nível de log para TRACE e reinicia o NetBird para capturar os logs de conexão. O nível anterior é restaurado após a criação do pacote." + }, + "settings.troubleshooting.duration.label": { + "message": "Duração da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Por quanto tempo capturar os logs de trace antes de gerar o pacote." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Criar pacote" + }, + "settings.troubleshooting.progress.description": { + "message": "Coletando logs, detalhes do sistema e estado da conexão. Isto costuma levar um instante — mantenha esta janela aberta até concluir." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacote de depuração enviado com sucesso!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacote salvo" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Compartilhe a chave de upload abaixo com o suporte do NetBird. Uma cópia local também foi salva no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu pacote de depuração foi salvo localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir pasta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir local do arquivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Falha no upload: {reason} O pacote continua salvo localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Falha no upload. O pacote continua salvo localmente." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Alternando para logs de trace…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando o NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando logs — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Restaurando o nível de log anterior…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Gerando o pacote de depuração…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Enviando para o NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os direitos reservados." + }, + "settings.about.links.imprint": { + "message": "Identificação legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos de serviço" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Documentação" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "O NetBird {version} está pronto para instalar." + }, + "update.banner.later": { + "message": "Mais tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versão {version} está disponível para download." + }, + "update.card.versionAvailableInstall": { + "message": "A versão {version} está disponível para instalação." + }, + "update.card.whatsNew": { + "message": "Novidades?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Baixar" + }, + "update.card.autoCheckInterval": { + "message": "O NetBird verifica atualizações em segundo plano." + }, + "update.card.changelog": { + "message": "Registro de alterações" + }, + "update.card.onLatestVersion": { + "message": "Você está na versão mais recente" + }, + "update.header.tooltip": { + "message": "Atualização disponível" + }, + "update.overlay.updatingVersion": { + "message": "Atualizando o NetBird para a v{version}" + }, + "update.overlay.updating": { + "message": "Atualizando o NetBird" + }, + "update.overlay.description": { + "message": "Uma versão mais recente está disponível e sendo instalada. O NetBird reiniciará automaticamente quando a atualização terminar." + }, + "update.overlay.error.timeoutTitle": { + "message": "A atualização está demorando demais" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalação de {target} demorou demais e não foi concluída." + }, + "update.overlay.error.canceledTitle": { + "message": "A atualização foi interrompida" + }, + "update.overlay.error.canceledDescription": { + "message": "A atualização para {target} foi cancelada antes de terminar." + }, + "update.overlay.error.failTitle": { + "message": "Não foi possível instalar a atualização" + }, + "update.overlay.error.failDescription": { + "message": "Não foi possível instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro desconhecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versão" + }, + "update.error.loadStateTitle": { + "message": "Falha ao carregar o estado da atualização" + }, + "update.error.triggerTitle": { + "message": "Falha ao iniciar a atualização" + }, + "update.page.versionLine": { + "message": "Atualizando o cliente para: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Atualizando o cliente." + }, + "update.page.outdated": { + "message": "A versão do seu cliente é anterior à versão de atualização automática definida no Management." + }, + "update.page.status.running": { + "message": "Atualizando" + }, + "update.page.status.timeout": { + "message": "A atualização expirou. Tente novamente." + }, + "update.page.status.canceled": { + "message": "Atualização cancelada." + }, + "update.page.status.failed": { + "message": "Falha na atualização: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de atualização desconhecido" + }, + "update.page.failedTitle": { + "message": "Falha na atualização" + }, + "update.page.timeoutMessage": { + "message": "A atualização expirou." + }, + "update.page.dontClose": { + "message": "Não feche esta janela." + }, + "update.page.updating": { + "message": "Atualizando…" + }, + "update.page.complete": { + "message": "Atualização concluída" + }, + "update.page.failed": { + "message": "Falha na atualização" + }, + "window.title.settings": { + "message": "Configurações" + }, + "window.title.signIn": { + "message": "Login" + }, + "window.title.sessionExpiration": { + "message": "Sessão expirando" + }, + "window.title.updating": { + "message": "Atualizando" + }, + "window.title.welcome": { + "message": "Bem-vindo ao NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Procure o NetBird na sua bandeja" + }, + "welcome.description": { + "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Voltar" + }, + "welcome.management.title": { + "message": "Configurar o NetBird" + }, + "welcome.management.description": { + "message": "Clique em Continuar para começar ou escolha Auto-hospedado se você tiver o seu próprio servidor NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o nosso serviço hospedado. Sem configuração necessária." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hospedado" + }, + "welcome.management.selfHosted.description": { + "message": "Conecte ao seu próprio servidor de gerenciamento." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de gerenciamento" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou a sua rede e continue se tiver certeza de que está correta." + }, + "welcome.management.checking": { + "message": "Verificando…" + }, + "browserLogin.title": { + "message": "Continue no seu navegador para concluir o login" + }, + "browserLogin.notSeeing": { + "message": "Não está vendo a aba do navegador?" + }, + "browserLogin.tryAgain": { + "message": "Tentar novamente" + }, + "browserLogin.openFailedTitle": { + "message": "Falha ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sessão expira em breve" + }, + "sessionExpiration.titleLater": { + "message": "A sua sessão vai expirar" + }, + "sessionExpiration.description": { + "message": "Este dispositivo será desconectado em breve. Renove com um login pelo navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Um login pelo navegador mantém este dispositivo conectado à sua rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sessão" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Sair" + }, + "sessionExpiration.expired": { + "message": "Sessão expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentique com um login pelo navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Fechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Falha ao renovar a sessão" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Falha ao sair" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nenhum peer disponível" + }, + "peers.empty.description": { + "message": "Você não tem nenhum peer disponível ou não tem acesso a nenhum deles." + }, + "peers.details.domain": { + "message": "Domínio" + }, + "peers.details.netbirdIp": { + "message": "IP do NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 do NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexão" + }, + "peers.details.latency": { + "message": "Latência" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última atualização da conexão" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recebidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Atualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Via relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass ativado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou domínio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Ativos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Nenhum recurso disponível" + }, + "networks.empty.description": { + "message": "Você não tem nenhum recurso de rede disponível ou não tem acesso a nenhum deles." + }, + "networks.selected": { + "message": "Selecionado" + }, + "networks.unselected": { + "message": "Não selecionado" + }, + "networks.ips.heading": { + "message": "IPs resolvidos" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} ativos" + }, + "networks.bulk.enableAll": { + "message": "Ativar todos" + }, + "networks.bulk.disableAll": { + "message": "Desativar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nós de saída" + }, + "exitNodes.none": { + "message": "Nenhum" + }, + "exitNodes.empty.title": { + "message": "Nenhum nó de saída disponível" + }, + "exitNodes.empty.description": { + "message": "Nenhum nó de saída foi compartilhado com este peer." + }, + "exitNodes.card.title": { + "message": "Nó de saída" + }, + "exitNodes.card.statusActive": { + "message": "Ativo" + }, + "exitNodes.card.statusInactive": { + "message": "Inativo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nenhum" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexão direta sem um nó de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O serviço do NetBird não está em execução" + }, + "daemon.unavailable.description": { + "message": "O aplicativo reconectará automaticamente assim que o serviço estiver em execução." + }, + "daemon.unavailable.docsLink": { + "message": "Documentação" + }, + "error.jwt_clock_skew": { + "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." + }, + "error.jwt_expired": { + "message": "O seu token de login expirou. Faça login novamente." + }, + "error.jwt_signature_invalid": { + "message": "Falha no login: a assinatura do token é inválida. Entre em contato com o seu administrador." + }, + "error.session_expired": { + "message": "A sua sessão expirou. Faça login novamente." + }, + "error.invalid_setup_key": { + "message": "A chave de configuração está ausente ou é inválida." + }, + "error.permission_denied": { + "message": "O login foi rejeitado pelo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." + }, + "error.unknown": { + "message": "A operação falhou." + } +} diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json new file mode 100644 index 000000000..4258bb5ca --- /dev/null +++ b/client/ui/i18n/locales/ru/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Отключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Ошибка" + }, + "tray.status.connected": { + "message": "Подключено" + }, + "tray.status.connecting": { + "message": "Подключение" + }, + "tray.status.needsLogin": { + "message": "Требуется вход" + }, + "tray.status.loginFailed": { + "message": "Ошибка входа" + }, + "tray.status.sessionExpired": { + "message": "Сеанс истёк" + }, + "tray.session.expiresIn": { + "message": "Сеанс истекает через {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менее чем минуту" + }, + "tray.session.unit.minute": { + "message": "1 минуту" + }, + "tray.session.unit.minutes": { + "message": "{count} минут" + }, + "tray.session.unit.hour": { + "message": "1 час" + }, + "tray.session.unit.hours": { + "message": "{count} часов" + }, + "tray.session.unit.day": { + "message": "1 день" + }, + "tray.session.unit.days": { + "message": "{count} дней" + }, + "tray.menu.open": { + "message": "Открыть NetBird" + }, + "tray.menu.connect": { + "message": "Подключиться" + }, + "tray.menu.disconnect": { + "message": "Отключиться" + }, + "tray.menu.exitNode": { + "message": "Выходной узел" + }, + "tray.menu.networks": { + "message": "Ресурсы" + }, + "tray.menu.profiles": { + "message": "Профили" + }, + "tray.menu.manageProfiles": { + "message": "Управление профилями" + }, + "tray.menu.settings": { + "message": "Настройки…" + }, + "tray.menu.debugBundle": { + "message": "Создать отладочный пакет" + }, + "tray.menu.about": { + "message": "Помощь и поддержка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документация" + }, + "tray.menu.troubleshoot": { + "message": "Диагностика" + }, + "tray.menu.downloadLatest": { + "message": "Загрузить последнюю версию" + }, + "tray.menu.installVersion": { + "message": "Установить версию {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Демон: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Выйти из NetBird" + }, + "notify.update.title": { + "message": "Доступно обновление NetBird" + }, + "notify.update.body": { + "message": "Доступна версия NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш администратор требует установить это обновление." + }, + "notify.error.title": { + "message": "Ошибка" + }, + "notify.error.connect": { + "message": "Не удалось подключиться" + }, + "notify.error.disconnect": { + "message": "Не удалось отключиться" + }, + "notify.error.switchProfile": { + "message": "Не удалось переключиться на {profile}" + }, + "notify.error.exitNode": { + "message": "Не удалось обновить выходной узел {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird истёк" + }, + "notify.sessionExpired.body": { + "message": "Сеанс NetBird истёк. Пожалуйста, войдите снова." + }, + "notify.sessionWarning.title": { + "message": "Сеанс скоро истечёт" + }, + "notify.sessionWarning.body": { + "message": "Сеанс NetBird истекает через {remaining}. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Сеанс NetBird скоро истечёт. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.extend": { + "message": "Продлить сейчас" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрыть" + }, + "notify.sessionWarning.failed": { + "message": "Не удалось продлить сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продлён" + }, + "notify.sessionWarning.successBody": { + "message": "Сеанс обновлён." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Неверный срок действия сеанса" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова." + }, + "common.cancel": { + "message": "Отмена" + }, + "common.save": { + "message": "Сохранить" + }, + "common.saveChanges": { + "message": "Сохранить изменения" + }, + "common.saving": { + "message": "Сохранение…" + }, + "common.close": { + "message": "Закрыть" + }, + "common.copy": { + "message": "Копировать" + }, + "common.togglePasswordVisibility": { + "message": "Показать или скрыть пароль" + }, + "common.increase": { + "message": "Увеличить" + }, + "common.decrease": { + "message": "Уменьшить" + }, + "common.delete": { + "message": "Удалить" + }, + "common.create": { + "message": "Создать" + }, + "common.add": { + "message": "Добавить" + }, + "common.remove": { + "message": "Убрать" + }, + "common.refresh": { + "message": "Обновить" + }, + "common.loading": { + "message": "Загрузка…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Ничего не найдено" + }, + "common.noResults.description": { + "message": "Ничего не найдено. Попробуйте изменить поисковый запрос или фильтры." + }, + "notConnected.title": { + "message": "Отключено" + }, + "notConnected.description": { + "message": "Сначала подключитесь к NetBird, чтобы увидеть подробную информацию о пирах, сетевых ресурсах и выходных узлах." + }, + "connect.status.disconnected": { + "message": "Отключено" + }, + "connect.status.connecting": { + "message": "Подключение…" + }, + "connect.status.connected": { + "message": "Подключено" + }, + "connect.status.disconnecting": { + "message": "Отключение…" + }, + "connect.status.daemonUnavailable": { + "message": "Демон недоступен" + }, + "connect.status.loginRequired": { + "message": "Требуется вход" + }, + "connect.error.loginTitle": { + "message": "Не удалось войти" + }, + "connect.error.connectTitle": { + "message": "Не удалось подключиться" + }, + "connect.error.disconnectTitle": { + "message": "Не удалось отключиться" + }, + "nav.peers.title": { + "message": "Пиры" + }, + "nav.peers.description": { + "message": "{connected} из {total} подключено" + }, + "nav.resources.title": { + "message": "Ресурсы" + }, + "nav.resources.description": { + "message": "{active} из {total} активно" + }, + "nav.exitNode.title": { + "message": "Выходные узлы" + }, + "nav.exitNode.none": { + "message": "Не активен" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Открыть настройки" + }, + "header.togglePanel": { + "message": "Показать или скрыть боковую панель" + }, + "profile.selector.loading": { + "message": "Загрузка…" + }, + "profile.selector.noProfile": { + "message": "Нет профиля" + }, + "profile.selector.searchPlaceholder": { + "message": "Поиск профиля по имени…" + }, + "profile.selector.emptyTitle": { + "message": "Профили не найдены" + }, + "profile.selector.emptyDescription": { + "message": "Измените поисковый запрос или создайте новый профиль." + }, + "profile.selector.newProfile": { + "message": "Новый профиль" + }, + "profile.selector.moreOptions": { + "message": "Дополнительные параметры" + }, + "profile.selector.deregister": { + "message": "Отменить регистрацию" + }, + "profile.selector.delete": { + "message": "Удалить" + }, + "profile.selector.switchTo": { + "message": "Переключиться на этот профиль" + }, + "profile.dialog.title": { + "message": "Введите имя профиля" + }, + "profile.dialog.nameLabel": { + "message": "Имя профиля" + }, + "profile.dialog.description": { + "message": "Задайте легко узнаваемое имя для профиля." + }, + "profile.dialog.placeholder": { + "message": "например, работа" + }, + "profile.dialog.submit": { + "message": "Добавить профиль" + }, + "profile.dialog.required": { + "message": "Введите имя профиля, например работа или дом" + }, + "profile.dialog.managementHelp": { + "message": "Используйте NetBird Cloud или собственный сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно добавьте профиль, если уверены, что он правильный." + }, + "header.menu.settings": { + "message": "Настройки…" + }, + "header.menu.defaultView": { + "message": "Обычный вид" + }, + "header.menu.advancedView": { + "message": "Расширенный вид" + }, + "header.menu.updateAvailable": { + "message": "Доступно обновление" + }, + "profile.switch.title": { + "message": "Переключиться на профиль «{name}»?" + }, + "profile.switch.message": { + "message": "Вы действительно хотите переключить профиль?\nТекущий профиль будет отключён." + }, + "profile.switch.confirm": { + "message": "Подтвердить" + }, + "profile.deregister.title": { + "message": "Отменить регистрацию профиля «{name}»?" + }, + "profile.deregister.message": { + "message": "Вы действительно хотите отменить регистрацию этого профиля?\nДля его использования потребуется войти снова." + }, + "profile.deregister.confirm": { + "message": "Отменить регистрацию" + }, + "profile.delete.title": { + "message": "Удалить профиль «{name}»?" + }, + "profile.delete.message": { + "message": "Вы действительно хотите удалить этот профиль?\nЭто действие нельзя отменить." + }, + "profile.delete.disabledActive": { + "message": "Активные профили нельзя удалить. Переключитесь на другой профиль, прежде чем удалять этот." + }, + "profile.delete.disabledDefault": { + "message": "Профиль по умолчанию нельзя удалить." + }, + "profile.error.switchTitle": { + "message": "Не удалось переключить профиль" + }, + "profile.error.deregisterTitle": { + "message": "Не удалось отменить регистрацию профиля" + }, + "profile.error.deleteTitle": { + "message": "Не удалось удалить профиль" + }, + "profile.error.createTitle": { + "message": "Не удалось создать профиль" + }, + "profile.error.loadTitle": { + "message": "Не удалось загрузить профили" + }, + "profile.dropdown.activeProfile": { + "message": "Активный профиль" + }, + "profile.dropdown.switchProfile": { + "message": "Переключить профиль" + }, + "profile.dropdown.noEmail": { + "message": "Другое" + }, + "profile.dropdown.addProfile": { + "message": "Добавить профиль" + }, + "profile.dropdown.manageProfiles": { + "message": "Управление профилями" + }, + "profile.dropdown.settings": { + "message": "Настройки" + }, + "settings.profiles.section.profiles": { + "message": "Профили" + }, + "settings.profiles.intro": { + "message": "Управляйте несколькими профилями NetBird параллельно — например, рабочими и личными учётными записями или разными серверами управления. Ниже можно добавлять профили, отменять их регистрацию и удалять их." + }, + "settings.profiles.addProfile": { + "message": "Добавить профиль" + }, + "settings.profiles.active": { + "message": "Активен" + }, + "settings.profiles.emptyTitle": { + "message": "Нет профилей" + }, + "settings.profiles.emptyDescription": { + "message": "Создайте профиль, чтобы подключиться к серверу управления NetBird." + }, + "settings.error.loadTitle": { + "message": "Не удалось загрузить настройки" + }, + "settings.error.saveTitle": { + "message": "Не удалось сохранить настройки" + }, + "settings.error.debugBundleTitle": { + "message": "Не удалось создать отладочный пакет" + }, + "settings.tabs.general": { + "message": "Общие" + }, + "settings.tabs.network": { + "message": "Сеть" + }, + "settings.tabs.security": { + "message": "Безопасность" + }, + "settings.tabs.profiles": { + "message": "Профили" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Дополнительно" + }, + "settings.tabs.troubleshooting": { + "message": "Диагностика" + }, + "settings.tabs.about": { + "message": "О программе" + }, + "settings.tabs.updateAvailable": { + "message": "Доступно обновление" + }, + "settings.general.section.general": { + "message": "Общие" + }, + "settings.general.section.connection": { + "message": "Подключение" + }, + "settings.general.connectOnStartup.label": { + "message": "Подключаться при запуске" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматически устанавливать подключение при запуске службы." + }, + "settings.general.notifications.label": { + "message": "Уведомления на рабочем столе" + }, + "settings.general.notifications.help": { + "message": "Показывать уведомления о новых обновлениях и событиях подключения." + }, + "settings.general.autostart.label": { + "message": "Запускать интерфейс NetBird при входе" + }, + "settings.general.autostart.help": { + "message": "Автоматически запускать интерфейс NetBird при входе в систему. Это влияет только на графический интерфейс, но не на фоновую службу." + }, + "settings.general.autostart.errorTitle": { + "message": "Не удалось изменить автозапуск" + }, + "settings.general.language.label": { + "message": "Язык интерфейса" + }, + "settings.general.language.help": { + "message": "Выберите язык интерфейса NetBird." + }, + "settings.general.language.search": { + "message": "Поиск языка…" + }, + "settings.general.language.empty": { + "message": "Языки не найдены." + }, + "settings.general.management.label": { + "message": "Сервер управления" + }, + "settings.general.management.help": { + "message": "Подключайтесь к NetBird Cloud или к собственному серверу управления. Изменения вызовут переподключение клиента." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Собственный сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно сохраните, если уверены, что он правильный." + }, + "settings.general.management.switchCloudTitle": { + "message": "Переключиться на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Это отключит ваш собственный сервер.\nВозможно, потребуется войти снова." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Переключиться на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Подключение" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизация и DNS" + }, + "settings.network.lazy.label": { + "message": "Подключения по требованию" + }, + "settings.network.lazy.help": { + "message": "Вместо постоянно активных подключений NetBird активирует их по требованию — на основе активности или сигналинга." + }, + "settings.network.monitor.label": { + "message": "Переподключаться при смене сети" + }, + "settings.network.monitor.help": { + "message": "Отслеживать сеть и автоматически переподключаться при изменениях, таких как смена Wi-Fi, изменения Ethernet или выход из спящего режима." + }, + "settings.network.dns.label": { + "message": "Включить DNS" + }, + "settings.network.dns.help": { + "message": "Применять управляемые NetBird настройки DNS к системному резолверу." + }, + "settings.network.clientRoutes.label": { + "message": "Включить клиентские маршруты" + }, + "settings.network.clientRoutes.help": { + "message": "Принимать маршруты от других пиров для доступа к их сетям." + }, + "settings.network.serverRoutes.label": { + "message": "Включить серверные маршруты" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсировать локальные маршруты этого хоста другим пирам." + }, + "settings.network.ipv6.label": { + "message": "Включить IPv6" + }, + "settings.network.ipv6.help": { + "message": "Использовать IPv6-адресацию для оверлейной сети NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауэр" + }, + "settings.security.section.encryption": { + "message": "Шифрование" + }, + "settings.security.blockInbound.label": { + "message": "Блокировать входящий трафик" + }, + "settings.security.blockInbound.help": { + "message": "Отклонять незапрошенные подключения от пиров к этому устройству и сетям, которые оно маршрутизирует. Исходящий трафик не затрагивается." + }, + "settings.security.blockLan.label": { + "message": "Блокировать доступ к LAN" + }, + "settings.security.blockLan.help": { + "message": "Запрещать пирам доступ к вашей локальной сети и её устройствам, когда это устройство маршрутизирует их трафик." + }, + "settings.security.rosenpass.label": { + "message": "Включить квантовую устойчивость" + }, + "settings.security.rosenpass.help": { + "message": "Добавить постквантовый обмен ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Включить разрешающий режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Разрешать подключения к пирам без поддержки квантовой устойчивости." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Возможности" + }, + "settings.ssh.section.authentication": { + "message": "Аутентификация" + }, + "settings.ssh.server.label": { + "message": "Включить SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запускать SSH-сервер NetBird на этом хосте, чтобы другие пиры могли к нему подключаться." + }, + "settings.ssh.root.label": { + "message": "Разрешить вход под root" + }, + "settings.ssh.root.help": { + "message": "Разрешить пирам входить под пользователем root. Отключите, чтобы требовать непривилегированную учётную запись." + }, + "settings.ssh.sftp.label": { + "message": "Разрешить SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безопасно передавать файлы через стандартные клиенты SFTP или SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальная переадресация портов" + }, + "settings.ssh.localForward.help": { + "message": "Разрешить подключающимся пирам туннелировать локальные порты к службам, доступным с этого хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Удалённая переадресация портов" + }, + "settings.ssh.remoteForward.help": { + "message": "Разрешить подключающимся пирам пробрасывать порты этого хоста на свою машину." + }, + "settings.ssh.jwt.label": { + "message": "Включить аутентификацию JWT" + }, + "settings.ssh.jwt.help": { + "message": "Проверять каждую сессию SSH через ваш IdP для идентификации пользователя и аудита. Отключите, чтобы полагаться только на сетевые политики ACL — полезно, когда IdP недоступен." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL кэша JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Как долго этот клиент кэширует JWT, прежде чем снова запрашивать его при исходящих SSH-подключениях. Установите 0, чтобы отключить кэширование и аутентифицироваться при каждом подключении." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Интерфейс" + }, + "settings.advanced.section.security": { + "message": "Безопасность" + }, + "settings.advanced.interfaceName.label": { + "message": "Имя" + }, + "settings.advanced.interfaceName.error": { + "message": "Используйте от 1 до 15 букв, цифр, точек, дефисов или подчёркиваний." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Должно начинаться с «utun», за которым следует число (например, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введите порт от {min} до {max}." + }, + "settings.advanced.port.help": { + "message": "Если задано 0, будет использован случайный свободный порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введите значение MTU от {min} до {max}." + }, + "settings.advanced.psk.label": { + "message": "Общий ключ" + }, + "settings.advanced.psk.help": { + "message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Отладочный пакет" + }, + "settings.troubleshooting.intro": { + "message": "Отладочный пакет помогает поддержке NetBird исследовать проблемы с подключением. <br /> Это .zip-файл с журналами, сведениями о системе и отладочной информацией с вашего устройства." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонимизировать конфиденциальную информацию" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Включить сведения о системе" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Включить ОС, ядро, сетевые интерфейсы и таблицы маршрутизации." + }, + "settings.troubleshooting.upload.label": { + "message": "Загрузить пакет на серверы NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Безопасно загружает пакет и возвращает ключ загрузки. Поделитесь ключом с поддержкой NetBird через GitHub или Slack вместо того, чтобы прикреплять файл напрямую." + }, + "settings.troubleshooting.trace.label": { + "message": "Собирать журналы TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Повышает уровень журналирования до TRACE и перезапускает NetBird (отключение и подключение) для сбора журналов подключения. Прежний уровень восстанавливается после создания пакета." + }, + "settings.troubleshooting.duration.label": { + "message": "Длительность записи" + }, + "settings.troubleshooting.duration.help": { + "message": "Как долго собирать журналы TRACE перед созданием пакета." + }, + "settings.troubleshooting.duration.suffix": { + "message": "мин." + }, + "settings.troubleshooting.create": { + "message": "Создать отладочный пакет" + }, + "settings.troubleshooting.progress.description": { + "message": "Сбор журналов, сведений о системе и состояния подключения. Обычно это занимает несколько секунд — не закрывайте это окно до завершения." + }, + "settings.troubleshooting.cancelling": { + "message": "Отмена…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Отладочный пакет успешно загружен!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Пакет сохранён" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поделитесь ключом загрузки ниже с поддержкой NetBird. Локальная копия также сохранена на вашем устройстве." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Отладочный пакет сохранён локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копировать ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Открыть папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Открыть расположение файла" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Не удалось загрузить: {reason} Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Не удалось загрузить. Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "Переключение на журналирование TRACE…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Переподключение NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Сбор журналов — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "Восстановление прежнего уровня журналирования…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Создание отладочного пакета…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Загрузка в NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Отмена…" + }, + "settings.about.client": { + "message": "Клиент NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Клиент NetBird" + }, + "settings.about.development": { + "message": "[Разработка]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Все права защищены." + }, + "settings.about.links.imprint": { + "message": "Правовая информация" + }, + "settings.about.links.privacy": { + "message": "Конфиденциальность" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Условия использования" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документация" + }, + "settings.about.community.feedback": { + "message": "Обратная связь" + }, + "update.banner.message": { + "message": "NetBird {version} готов к установке." + }, + "update.banner.later": { + "message": "Позже" + }, + "update.banner.installNow": { + "message": "Установить сейчас" + }, + "update.card.versionAvailableDownload": { + "message": "Версия {version} доступна для загрузки." + }, + "update.card.versionAvailableInstall": { + "message": "Версия {version} доступна для установки." + }, + "update.card.whatsNew": { + "message": "Что нового?" + }, + "update.card.installNow": { + "message": "Установить сейчас" + }, + "update.card.getInstaller": { + "message": "Загрузить" + }, + "update.card.autoCheckInterval": { + "message": "NetBird проверяет обновления в фоновом режиме." + }, + "update.card.changelog": { + "message": "Список изменений" + }, + "update.card.onLatestVersion": { + "message": "У вас установлена последняя версия" + }, + "update.header.tooltip": { + "message": "Доступно обновление" + }, + "update.overlay.updatingVersion": { + "message": "Обновление NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Обновление NetBird" + }, + "update.overlay.description": { + "message": "Доступна более новая версия, идёт её установка. NetBird автоматически перезапустится после завершения обновления." + }, + "update.overlay.error.timeoutTitle": { + "message": "Обновление занимает слишком много времени" + }, + "update.overlay.error.timeoutDescription": { + "message": "Установка {target} заняла слишком много времени и не завершилась." + }, + "update.overlay.error.canceledTitle": { + "message": "Обновление остановлено" + }, + "update.overlay.error.canceledDescription": { + "message": "Обновление до {target} было отменено до завершения." + }, + "update.overlay.error.failTitle": { + "message": "Не удалось установить обновление" + }, + "update.overlay.error.failDescription": { + "message": "Не удалось установить обновление до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "неизвестная ошибка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "новой версии" + }, + "update.error.loadStateTitle": { + "message": "Не удалось загрузить состояние обновления" + }, + "update.error.triggerTitle": { + "message": "Не удалось запустить обновление" + }, + "update.page.versionLine": { + "message": "Обновление клиента до версии {version}." + }, + "update.page.versionLineGeneric": { + "message": "Обновление клиента." + }, + "update.page.outdated": { + "message": "Версия вашего клиента старше версии автообновления, заданной на сервере управления." + }, + "update.page.status.running": { + "message": "Обновление" + }, + "update.page.status.timeout": { + "message": "Время ожидания обновления истекло. Повторите попытку." + }, + "update.page.status.canceled": { + "message": "Обновление отменено." + }, + "update.page.status.failed": { + "message": "Не удалось обновить: {message}" + }, + "update.page.status.unknownError": { + "message": "неизвестная ошибка обновления" + }, + "update.page.failedTitle": { + "message": "Не удалось обновить" + }, + "update.page.timeoutMessage": { + "message": "Время ожидания обновления истекло." + }, + "update.page.dontClose": { + "message": "Пожалуйста, не закрывайте это окно." + }, + "update.page.updating": { + "message": "Обновление…" + }, + "update.page.complete": { + "message": "Обновление завершено" + }, + "update.page.failed": { + "message": "Обновление не удалось" + }, + "window.title.settings": { + "message": "Настройки" + }, + "window.title.signIn": { + "message": "Вход" + }, + "window.title.sessionExpiration": { + "message": "Истечение сеанса" + }, + "window.title.updating": { + "message": "Обновление" + }, + "window.title.welcome": { + "message": "Добро пожаловать в NetBird" + }, + "window.title.error": { + "message": "Ошибка" + }, + "welcome.title": { + "message": "Найдите NetBird в системном трее" + }, + "welcome.description": { + "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.continue": { + "message": "Продолжить" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Настройка NetBird" + }, + "welcome.management.description": { + "message": "Нажмите «Продолжить», чтобы начать, или выберите «Собственный сервер», если у вас есть свой сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Используйте наш облачный сервис. Настройка не требуется." + }, + "welcome.management.selfHosted.title": { + "message": "Собственный сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Подключитесь к собственному серверу управления." + }, + "welcome.management.urlLabel": { + "message": "URL сервера управления" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или сеть, затем продолжите, если уверены, что он правильный." + }, + "welcome.management.checking": { + "message": "Проверка…" + }, + "browserLogin.title": { + "message": "Продолжите в браузере, чтобы завершить вход" + }, + "browserLogin.notSeeing": { + "message": "Не видите вкладку браузера?" + }, + "browserLogin.tryAgain": { + "message": "Повторить" + }, + "browserLogin.openFailedTitle": { + "message": "Не удалось открыть браузер" + }, + "sessionExpiration.title": { + "message": "Сеанс скоро истечёт" + }, + "sessionExpiration.titleLater": { + "message": "Ваш сеанс истечёт" + }, + "sessionExpiration.description": { + "message": "Это устройство скоро будет отключено. Продлите сеанс через вход в браузере." + }, + "sessionExpiration.descriptionLater": { + "message": "Вход в браузере сохранит подключение этого устройства к вашей сети." + }, + "sessionExpiration.stay": { + "message": "Продлить сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Войти" + }, + "sessionExpiration.logout": { + "message": "Выйти" + }, + "sessionExpiration.expired": { + "message": "Сеанс истёк" + }, + "sessionExpiration.expiredDescription": { + "message": "Устройство отключено. Войдите через браузер, чтобы переподключиться." + }, + "sessionExpiration.close": { + "message": "Закрыть" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Не удалось продлить сеанс" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Не удалось выйти" + }, + "peers.search.placeholder": { + "message": "Поиск по имени или IP" + }, + "peers.filter.all": { + "message": "Все" + }, + "peers.filter.online": { + "message": "В сети" + }, + "peers.filter.offline": { + "message": "Не в сети" + }, + "peers.empty.title": { + "message": "Нет доступных пиров" + }, + "peers.empty.description": { + "message": "У вас нет доступных пиров или нет доступа ни к одному из них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Открытый ключ" + }, + "peers.details.connection": { + "message": "Подключение" + }, + "peers.details.latency": { + "message": "Задержка" + }, + "peers.details.lastHandshake": { + "message": "Последнее рукопожатие" + }, + "peers.details.statusSince": { + "message": "Последнее обновление подключения" + }, + "peers.details.bytes": { + "message": "Байты" + }, + "peers.details.bytesSent": { + "message": "Отправлено" + }, + "peers.details.bytesReceived": { + "message": "Получено" + }, + "peers.details.localIce": { + "message": "Локальный ICE" + }, + "peers.details.remoteIce": { + "message": "Удалённый ICE" + }, + "peers.details.never": { + "message": "Никогда" + }, + "peers.details.justNow": { + "message": "Только что" + }, + "peers.details.refresh": { + "message": "Обновить" + }, + "peers.status.connected": { + "message": "Подключено" + }, + "peers.status.connecting": { + "message": "Подключение" + }, + "peers.status.disconnected": { + "message": "Отключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурсы" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass включён" + }, + "networks.search.placeholder": { + "message": "Поиск по сети или домену" + }, + "networks.filter.all": { + "message": "Все" + }, + "networks.filter.active": { + "message": "Активные" + }, + "networks.filter.overlapping": { + "message": "Пересекающиеся" + }, + "networks.empty.title": { + "message": "Нет доступных ресурсов" + }, + "networks.empty.description": { + "message": "У вас нет доступных сетевых ресурсов или нет доступа ни к одному из них." + }, + "networks.selected": { + "message": "Выбрано" + }, + "networks.unselected": { + "message": "Не выбрано" + }, + "networks.ips.heading": { + "message": "Распознанные IP-адреса" + }, + "networks.bulk.selectionCount": { + "message": "{selected} из {total} активно" + }, + "networks.bulk.enableAll": { + "message": "Включить все" + }, + "networks.bulk.disableAll": { + "message": "Отключить все" + }, + "exitNodes.search.placeholder": { + "message": "Поиск выходных узлов" + }, + "exitNodes.none": { + "message": "Нет" + }, + "exitNodes.empty.title": { + "message": "Нет доступных выходных узлов" + }, + "exitNodes.empty.description": { + "message": "Этому пиру не предоставлены выходные узлы." + }, + "exitNodes.card.title": { + "message": "Выходной узел" + }, + "exitNodes.card.statusActive": { + "message": "Активен" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивен" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Нет" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Прямое подключение без выходного узла" + }, + "quickActions.connect": { + "message": "Подключиться" + }, + "quickActions.disconnect": { + "message": "Отключиться" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Приложение автоматически переподключится, как только служба будет запущена." + }, + "daemon.unavailable.docsLink": { + "message": "Документация" + }, + "error.jwt_clock_skew": { + "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." + }, + "error.jwt_expired": { + "message": "Срок действия токена входа истёк. Войдите снова." + }, + "error.jwt_signature_invalid": { + "message": "Не удалось войти: недействительная подпись токена. Обратитесь к администратору." + }, + "error.session_expired": { + "message": "Ваш сеанс истёк. Войдите снова." + }, + "error.invalid_setup_key": { + "message": "Ключ установки отсутствует или недействителен." + }, + "error.permission_denied": { + "message": "Сервер отклонил вход." + }, + "error.daemon_unreachable": { + "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." + }, + "error.unknown": { + "message": "Не удалось выполнить операцию." + } +} diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json new file mode 100644 index 000000000..c6333e7cd --- /dev/null +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -0,0 +1,1262 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "已断开连接" + }, + "tray.status.daemonUnavailable": { + "message": "未运行" + }, + "tray.status.error": { + "message": "错误" + }, + "tray.status.connected": { + "message": "已连接" + }, + "tray.status.connecting": { + "message": "正在连接" + }, + "tray.status.needsLogin": { + "message": "需要登录" + }, + "tray.status.loginFailed": { + "message": "登录失败" + }, + "tray.status.sessionExpired": { + "message": "会话已过期" + }, + "tray.session.expiresIn": { + "message": "会话将在 {remaining} 后过期" + }, + "tray.session.unit.lessThanMinute": { + "message": "不到一分钟" + }, + "tray.session.unit.minute": { + "message": "1 分钟" + }, + "tray.session.unit.minutes": { + "message": "{count} 分钟" + }, + "tray.session.unit.hour": { + "message": "1 小时" + }, + "tray.session.unit.hours": { + "message": "{count} 小时" + }, + "tray.session.unit.day": { + "message": "1 天" + }, + "tray.session.unit.days": { + "message": "{count} 天" + }, + "tray.menu.open": { + "message": "打开 NetBird" + }, + "tray.menu.connect": { + "message": "连接" + }, + "tray.menu.disconnect": { + "message": "断开连接" + }, + "tray.menu.exitNode": { + "message": "出口节点" + }, + "tray.menu.networks": { + "message": "资源" + }, + "tray.menu.profiles": { + "message": "配置文件" + }, + "tray.menu.manageProfiles": { + "message": "管理配置文件" + }, + "tray.menu.settings": { + "message": "设置…" + }, + "tray.menu.debugBundle": { + "message": "创建调试包" + }, + "tray.menu.about": { + "message": "帮助与支持" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "文档" + }, + "tray.menu.troubleshoot": { + "message": "故障排除" + }, + "tray.menu.downloadLatest": { + "message": "下载最新版本" + }, + "tray.menu.installVersion": { + "message": "安装 {version} 版本" + }, + "tray.menu.guiVersion": { + "message": "GUI:{version}" + }, + "tray.menu.daemonVersion": { + "message": "守护进程:{version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "退出 NetBird" + }, + "notify.update.title": { + "message": "NetBird 有可用更新" + }, + "notify.update.body": { + "message": "NetBird {version} 已可用。" + }, + "notify.update.enforcedSuffix": { + "message": " 您的管理员要求进行此次更新。" + }, + "notify.error.title": { + "message": "错误" + }, + "notify.error.connect": { + "message": "连接失败" + }, + "notify.error.disconnect": { + "message": "断开连接失败" + }, + "notify.error.switchProfile": { + "message": "切换到 {profile} 失败" + }, + "notify.error.exitNode": { + "message": "更新出口节点 {name} 失败" + }, + "notify.sessionExpired.title": { + "message": "NetBird 会话已过期" + }, + "notify.sessionExpired.body": { + "message": "您的 NetBird 会话已过期。请重新登录。" + }, + "notify.sessionWarning.title": { + "message": "会话即将过期" + }, + "notify.sessionWarning.body": { + "message": "您的 NetBird 会话将在 {remaining} 后过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "您的 NetBird 会话即将过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.extend": { + "message": "立即延长" + }, + "notify.sessionWarning.dismiss": { + "message": "忽略" + }, + "notify.sessionWarning.failed": { + "message": "延长 NetBird 会话失败" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird 会话已延长" + }, + "notify.sessionWarning.successBody": { + "message": "您的会话已刷新。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "会话截止时间被拒绝" + }, + "notify.sessionDeadlineRejected.body": { + "message": "服务器发送了无效的会话截止时间。请重新登录。" + }, + "common.cancel": { + "message": "取消" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "保存更改" + }, + "common.saving": { + "message": "正在保存…" + }, + "common.close": { + "message": "关闭" + }, + "common.copy": { + "message": "复制" + }, + "common.togglePasswordVisibility": { + "message": "切换密码可见性" + }, + "common.increase": { + "message": "增加" + }, + "common.decrease": { + "message": "减少" + }, + "common.delete": { + "message": "删除" + }, + "common.create": { + "message": "创建" + }, + "common.add": { + "message": "添加" + }, + "common.remove": { + "message": "移除" + }, + "common.refresh": { + "message": "刷新" + }, + "common.loading": { + "message": "正在加载…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "未找到任何结果" + }, + "common.noResults.description": { + "message": "我们未能找到任何结果。请尝试其他搜索词或更改筛选条件。" + }, + "notConnected.title": { + "message": "已断开连接" + }, + "notConnected.description": { + "message": "请先连接到 NetBird,以查看有关对等节点、网络资源和出口节点的详细信息。" + }, + "connect.status.disconnected": { + "message": "已断开连接" + }, + "connect.status.connecting": { + "message": "正在连接…" + }, + "connect.status.connected": { + "message": "已连接" + }, + "connect.status.disconnecting": { + "message": "正在断开连接…" + }, + "connect.status.daemonUnavailable": { + "message": "守护进程不可用" + }, + "connect.status.loginRequired": { + "message": "需要登录" + }, + "connect.error.loginTitle": { + "message": "登录失败" + }, + "connect.error.connectTitle": { + "message": "连接失败" + }, + "connect.error.disconnectTitle": { + "message": "断开连接失败" + }, + "nav.peers.title": { + "message": "对等节点" + }, + "nav.peers.description": { + "message": "{total} 个中已连接 {connected} 个" + }, + "nav.resources.title": { + "message": "资源" + }, + "nav.resources.description": { + "message": "{total} 个中已激活 {active} 个" + }, + "nav.exitNode.title": { + "message": "出口节点" + }, + "nav.exitNode.none": { + "message": "未激活" + }, + "nav.exitNode.using": { + "message": "经由 {name}" + }, + "header.openSettings": { + "message": "打开设置" + }, + "header.togglePanel": { + "message": "切换侧边栏" + }, + "profile.selector.loading": { + "message": "正在加载…" + }, + "profile.selector.noProfile": { + "message": "无配置文件" + }, + "profile.selector.searchPlaceholder": { + "message": "按名称搜索配置文件…" + }, + "profile.selector.emptyTitle": { + "message": "未找到配置文件" + }, + "profile.selector.emptyDescription": { + "message": "请尝试其他搜索词或创建新的配置文件。" + }, + "profile.selector.newProfile": { + "message": "新建配置文件" + }, + "profile.selector.moreOptions": { + "message": "更多选项" + }, + "profile.selector.deregister": { + "message": "注销" + }, + "profile.selector.delete": { + "message": "删除" + }, + "profile.selector.switchTo": { + "message": "切换到此配置文件" + }, + "profile.dialog.title": { + "message": "输入配置文件名称" + }, + "profile.dialog.nameLabel": { + "message": "配置文件名称" + }, + "profile.dialog.description": { + "message": "为您的配置文件设置一个易于识别的名称。" + }, + "profile.dialog.placeholder": { + "message": "例如:工作" + }, + "profile.dialog.submit": { + "message": "添加配置文件" + }, + "profile.dialog.required": { + "message": "请输入配置文件名称,例如:工作、家庭" + }, + "profile.dialog.managementHelp": { + "message": "使用 NetBird Cloud 或您自己的服务器。" + }, + "profile.dialog.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常添加该配置文件。" + }, + "header.menu.settings": { + "message": "设置…" + }, + "header.menu.defaultView": { + "message": "默认视图" + }, + "header.menu.advancedView": { + "message": "高级视图" + }, + "header.menu.updateAvailable": { + "message": "有可用更新" + }, + "profile.switch.title": { + "message": "切换到配置文件“{name}”?" + }, + "profile.switch.message": { + "message": "您确定要切换配置文件吗?\n您当前的配置文件将被断开连接。" + }, + "profile.switch.confirm": { + "message": "确认" + }, + "profile.deregister.title": { + "message": "注销配置文件“{name}”?" + }, + "profile.deregister.message": { + "message": "您确定要注销此配置文件吗?\n您将需要重新登录才能使用它。" + }, + "profile.deregister.confirm": { + "message": "注销" + }, + "profile.delete.title": { + "message": "删除配置文件“{name}”?" + }, + "profile.delete.message": { + "message": "您确定要删除此配置文件吗?\n此操作无法撤销。" + }, + "profile.delete.disabledActive": { + "message": "无法删除处于活动状态的配置文件。请先切换到其他配置文件,再删除此配置文件。" + }, + "profile.delete.disabledDefault": { + "message": "无法删除默认配置文件。" + }, + "profile.error.switchTitle": { + "message": "切换配置文件失败" + }, + "profile.error.deregisterTitle": { + "message": "注销配置文件失败" + }, + "profile.error.deleteTitle": { + "message": "删除配置文件失败" + }, + "profile.error.createTitle": { + "message": "创建配置文件失败" + }, + "profile.error.loadTitle": { + "message": "加载配置文件失败" + }, + "profile.dropdown.activeProfile": { + "message": "当前配置文件" + }, + "profile.dropdown.switchProfile": { + "message": "切换配置文件" + }, + "profile.dropdown.noEmail": { + "message": "其他" + }, + "profile.dropdown.addProfile": { + "message": "添加配置文件" + }, + "profile.dropdown.manageProfiles": { + "message": "管理配置文件" + }, + "profile.dropdown.settings": { + "message": "设置" + }, + "settings.profiles.section.profiles": { + "message": "配置文件" + }, + "settings.profiles.intro": { + "message": "并行保留多个独立的 NetBird 身份,例如工作和个人账户,或不同的管理服务器。可在下方添加、注销或删除配置文件。" + }, + "settings.profiles.addProfile": { + "message": "添加配置文件" + }, + "settings.profiles.active": { + "message": "活动" + }, + "settings.profiles.emptyTitle": { + "message": "无配置文件" + }, + "settings.profiles.emptyDescription": { + "message": "创建一个配置文件以连接到 NetBird 管理服务器。" + }, + "settings.error.loadTitle": { + "message": "加载设置失败" + }, + "settings.error.saveTitle": { + "message": "保存设置失败" + }, + "settings.error.debugBundleTitle": { + "message": "创建调试包失败" + }, + "settings.tabs.general": { + "message": "常规" + }, + "settings.tabs.network": { + "message": "网络" + }, + "settings.tabs.security": { + "message": "安全" + }, + "settings.tabs.profiles": { + "message": "配置文件" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "高级" + }, + "settings.tabs.troubleshooting": { + "message": "故障排除" + }, + "settings.tabs.about": { + "message": "关于" + }, + "settings.tabs.updateAvailable": { + "message": "有可用更新" + }, + "settings.general.section.general": { + "message": "常规" + }, + "settings.general.section.connection": { + "message": "连接" + }, + "settings.general.connectOnStartup.label": { + "message": "启动时连接" + }, + "settings.general.connectOnStartup.help": { + "message": "在服务启动时自动建立连接。" + }, + "settings.general.notifications.label": { + "message": "桌面通知" + }, + "settings.general.notifications.help": { + "message": "显示有关新更新和连接事件的桌面通知。" + }, + "settings.general.autostart.label": { + "message": "登录时启动 NetBird 界面" + }, + "settings.general.autostart.help": { + "message": "在您登录时自动启动 NetBird 界面。此设置仅影响图形界面,不影响后台服务。" + }, + "settings.general.autostart.errorTitle": { + "message": "更改自启动设置失败" + }, + "settings.general.language.label": { + "message": "显示语言" + }, + "settings.general.language.help": { + "message": "选择 NetBird 界面的语言。" + }, + "settings.general.language.search": { + "message": "搜索语言…" + }, + "settings.general.language.empty": { + "message": "没有匹配的语言。" + }, + "settings.general.management.label": { + "message": "管理服务器" + }, + "settings.general.management.help": { + "message": "连接到 NetBird Cloud 或您自己的自托管管理服务器。更改将使客户端重新连接。" + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "自托管" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常保存。" + }, + "settings.general.management.switchCloudTitle": { + "message": "切换到 NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "这将断开您的自托管服务器。\n您可能需要重新登录。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "切换到 Cloud" + }, + "settings.network.section.connectivity": { + "message": "连接性" + }, + "settings.network.section.routingDns": { + "message": "路由与 DNS" + }, + "settings.network.lazy.label": { + "message": "懒连接" + }, + "settings.network.lazy.help": { + "message": "NetBird 不会维持始终在线的连接,而是根据活动或信令按需激活连接。" + }, + "settings.network.monitor.label": { + "message": "网络变化时重新连接" + }, + "settings.network.monitor.help": { + "message": "监测网络,并在发生变化时自动重新连接,例如切换 Wi-Fi、以太网变化或从睡眠中恢复。" + }, + "settings.network.dns.label": { + "message": "启用 DNS" + }, + "settings.network.dns.help": { + "message": "将 NetBird 管理的 DNS 设置应用到主机解析器。" + }, + "settings.network.clientRoutes.label": { + "message": "启用客户端路由" + }, + "settings.network.clientRoutes.help": { + "message": "接受来自其他对等节点的路由,以访问它们的网络。" + }, + "settings.network.serverRoutes.label": { + "message": "启用服务器路由" + }, + "settings.network.serverRoutes.help": { + "message": "向其他对等节点通告此主机的本地路由。" + }, + "settings.network.ipv6.label": { + "message": "启用 IPv6" + }, + "settings.network.ipv6.help": { + "message": "为 NetBird 叠加网络使用 IPv6 寻址。" + }, + "settings.security.section.firewall": { + "message": "防火墙" + }, + "settings.security.section.encryption": { + "message": "加密" + }, + "settings.security.blockInbound.label": { + "message": "阻止入站流量" + }, + "settings.security.blockInbound.help": { + "message": "拒绝对等节点向本设备及其路由的任何网络发起的未经请求的连接。出站流量不受影响。" + }, + "settings.security.blockLan.label": { + "message": "阻止 LAN 访问" + }, + "settings.security.blockLan.help": { + "message": "当本设备为对等节点路由流量时,阻止它们访问您的本地网络或其设备。" + }, + "settings.security.rosenpass.label": { + "message": "启用抗量子加密" + }, + "settings.security.rosenpass.help": { + "message": "在 WireGuard® 之上通过 Rosenpass 添加后量子密钥交换。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "启用宽松模式" + }, + "settings.security.rosenpassPermissive.help": { + "message": "允许连接到不支持抗量子加密的对等节点。" + }, + "settings.ssh.section.server": { + "message": "服务器" + }, + "settings.ssh.section.capabilities": { + "message": "功能" + }, + "settings.ssh.section.authentication": { + "message": "身份验证" + }, + "settings.ssh.server.label": { + "message": "启用 SSH 服务器" + }, + "settings.ssh.server.help": { + "message": "在此主机上运行 NetBird SSH 服务器,以便其他对等节点可以连接到它。" + }, + "settings.ssh.root.label": { + "message": "允许 root 登录" + }, + "settings.ssh.root.help": { + "message": "允许对等节点以 root 用户身份登录。禁用后将要求使用非特权账户。" + }, + "settings.ssh.sftp.label": { + "message": "允许 SFTP" + }, + "settings.ssh.sftp.help": { + "message": "使用原生 SFTP 或 SCP 客户端安全地传输文件。" + }, + "settings.ssh.localForward.label": { + "message": "本地端口转发" + }, + "settings.ssh.localForward.help": { + "message": "允许连接的对等节点将本地端口隧道转发到此主机可访问的服务。" + }, + "settings.ssh.remoteForward.label": { + "message": "远程端口转发" + }, + "settings.ssh.remoteForward.help": { + "message": "允许连接的对等节点将此主机上的端口反向暴露到它们自己的机器。" + }, + "settings.ssh.jwt.label": { + "message": "启用 JWT 身份验证" + }, + "settings.ssh.jwt.help": { + "message": "针对您的 IdP 验证每个 SSH 会话,以确认用户身份并进行审计。禁用后将仅依赖网络 ACL 策略,在没有可用 IdP 时很有用。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT 缓存 TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "在出站 SSH 连接再次提示前,此客户端缓存 JWT 的时长。设为 0 可禁用缓存,每次连接都进行身份验证。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "接口" + }, + "settings.advanced.section.security": { + "message": "安全" + }, + "settings.advanced.interfaceName.label": { + "message": "名称" + }, + "settings.advanced.interfaceName.error": { + "message": "请使用 1-15 个字母、数字、点、连字符或下划线。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "必须以“utun”开头,后跟一个数字(例如 utun100)。" + }, + "settings.advanced.port.label": { + "message": "端口" + }, + "settings.advanced.port.error": { + "message": "请输入介于 {min} 和 {max} 之间的端口。" + }, + "settings.advanced.port.help": { + "message": "如果设为 0,将使用一个随机的空闲端口。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "请输入介于 {min} 和 {max} 之间的 MTU 值。" + }, + "settings.advanced.psk.label": { + "message": "预共享密钥" + }, + "settings.advanced.psk.help": { + "message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。" + }, + "settings.troubleshooting.section.title": { + "message": "调试包" + }, + "settings.troubleshooting.intro": { + "message": "调试包可帮助 NetBird 支持团队排查连接问题。<br /> 它是一个 .zip 文件,包含来自您设备的日志、系统详情和调试信息。" + }, + "settings.troubleshooting.anonymize.label": { + "message": "匿名化敏感信息" + }, + "settings.troubleshooting.anonymize.help": { + "message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "包含系统信息" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "包含操作系统、内核、网络接口和路由表。" + }, + "settings.troubleshooting.upload.label": { + "message": "将调试包上传到 NetBird 服务器" + }, + "settings.troubleshooting.upload.help": { + "message": "安全地上传调试包并返回一个上传密钥。请通过 GitHub 或 Slack 将该密钥分享给 NetBird 支持团队,而不要直接附上文件。" + }, + "settings.troubleshooting.trace.label": { + "message": "捕获跟踪日志" + }, + "settings.troubleshooting.trace.help": { + "message": "将日志级别提升到 TRACE,并使 NetBird 上下线一次以捕获连接日志。调试包生成后将恢复先前的级别。" + }, + "settings.troubleshooting.duration.label": { + "message": "捕获时长" + }, + "settings.troubleshooting.duration.help": { + "message": "在生成调试包之前捕获跟踪日志的时长。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分钟" + }, + "settings.troubleshooting.create": { + "message": "创建调试包" + }, + "settings.troubleshooting.progress.description": { + "message": "正在收集日志、系统详情和连接状态。这通常只需片刻——请保持此窗口打开,直到完成。" + }, + "settings.troubleshooting.cancelling": { + "message": "正在取消…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "调试包已成功上传!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "调试包已保存" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "请将下方的上传密钥分享给 NetBird 支持团队。本地也已保存了一份副本。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "您的调试包已保存在本地。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "复制密钥" + }, + "settings.troubleshooting.done.openFolder": { + "message": "打开文件夹" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "打开文件位置" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "上传失败:{reason} 调试包仍已保存在本地。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "上传失败。调试包仍已保存在本地。" + }, + "settings.troubleshooting.stage.preparingTrace": { + "message": "正在切换到跟踪日志记录…" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "正在重新连接 NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "正在捕获日志 — {elapsed} / {total}" + }, + "settings.troubleshooting.stage.restoring": { + "message": "正在恢复先前的日志级别…" + }, + "settings.troubleshooting.stage.bundling": { + "message": "正在生成调试包…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "正在上传到 NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "正在取消…" + }, + "settings.about.client": { + "message": "NetBird 客户端 v{version}" + }, + "settings.about.clientName": { + "message": "NetBird 客户端" + }, + "settings.about.development": { + "message": "[开发版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird。保留所有权利。" + }, + "settings.about.links.imprint": { + "message": "法律声明" + }, + "settings.about.links.privacy": { + "message": "隐私" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "服务条款" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "论坛" + }, + "settings.about.community.documentation": { + "message": "文档" + }, + "settings.about.community.feedback": { + "message": "反馈" + }, + "update.banner.message": { + "message": "NetBird {version} 已准备好安装。" + }, + "update.banner.later": { + "message": "稍后" + }, + "update.banner.installNow": { + "message": "立即安装" + }, + "update.card.versionAvailableDownload": { + "message": "{version} 版本可供下载。" + }, + "update.card.versionAvailableInstall": { + "message": "{version} 版本可供安装。" + }, + "update.card.whatsNew": { + "message": "更新内容?" + }, + "update.card.installNow": { + "message": "立即安装" + }, + "update.card.getInstaller": { + "message": "下载" + }, + "update.card.autoCheckInterval": { + "message": "NetBird 会在后台检查更新。" + }, + "update.card.changelog": { + "message": "更新日志" + }, + "update.card.onLatestVersion": { + "message": "您已是最新版本" + }, + "update.header.tooltip": { + "message": "有可用更新" + }, + "update.overlay.updatingVersion": { + "message": "正在将 NetBird 更新到 v{version}" + }, + "update.overlay.updating": { + "message": "正在更新 NetBird" + }, + "update.overlay.description": { + "message": "有更新的版本可用,正在安装。更新完成后,NetBird 将自动重启。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新耗时过长" + }, + "update.overlay.error.timeoutDescription": { + "message": "安装 {target} 耗时过长,未能完成。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新已停止" + }, + "update.overlay.error.canceledDescription": { + "message": "对 {target} 的更新在完成前已被取消。" + }, + "update.overlay.error.failTitle": { + "message": "无法安装更新" + }, + "update.overlay.error.failDescription": { + "message": "无法安装 {target}。" + }, + "update.overlay.error.unknownMessage": { + "message": "未知错误" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新版本" + }, + "update.error.loadStateTitle": { + "message": "加载更新状态失败" + }, + "update.error.triggerTitle": { + "message": "启动更新失败" + }, + "update.page.versionLine": { + "message": "正在将客户端更新到:{version}。" + }, + "update.page.versionLineGeneric": { + "message": "正在更新客户端。" + }, + "update.page.outdated": { + "message": "您的客户端版本早于管理服务器中设置的自动更新版本。" + }, + "update.page.status.running": { + "message": "正在更新" + }, + "update.page.status.timeout": { + "message": "更新超时。请重试。" + }, + "update.page.status.canceled": { + "message": "更新已取消。" + }, + "update.page.status.failed": { + "message": "更新失败:{message}" + }, + "update.page.status.unknownError": { + "message": "未知的更新错误" + }, + "update.page.failedTitle": { + "message": "更新失败" + }, + "update.page.timeoutMessage": { + "message": "更新超时。" + }, + "update.page.dontClose": { + "message": "请勿关闭此窗口。" + }, + "update.page.updating": { + "message": "正在更新…" + }, + "update.page.complete": { + "message": "更新完成" + }, + "update.page.failed": { + "message": "更新失败" + }, + "window.title.settings": { + "message": "设置" + }, + "window.title.signIn": { + "message": "登录" + }, + "window.title.sessionExpiration": { + "message": "会话即将过期" + }, + "window.title.updating": { + "message": "正在更新" + }, + "window.title.welcome": { + "message": "欢迎使用 NetBird" + }, + "window.title.error": { + "message": "错误" + }, + "welcome.title": { + "message": "在托盘中查找 NetBird" + }, + "welcome.description": { + "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.continue": { + "message": "继续" + }, + "welcome.back": { + "message": "返回" + }, + "welcome.management.title": { + "message": "设置 NetBird" + }, + "welcome.management.description": { + "message": "点击“继续”即可开始;如果您有自己的 NetBird 服务器,请选择“自托管”。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "使用我们的托管服务。无需任何设置。" + }, + "welcome.management.selfHosted.title": { + "message": "自托管" + }, + "welcome.management.selfHosted.description": { + "message": "连接到您自己的管理服务器。" + }, + "welcome.management.urlLabel": { + "message": "管理服务器 URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL 或您的网络,如果确认无误,再继续。" + }, + "welcome.management.checking": { + "message": "正在检查…" + }, + "browserLogin.title": { + "message": "请在浏览器中继续以完成登录" + }, + "browserLogin.notSeeing": { + "message": "没看到浏览器标签页?" + }, + "browserLogin.tryAgain": { + "message": "重试" + }, + "browserLogin.openFailedTitle": { + "message": "打开浏览器失败" + }, + "sessionExpiration.title": { + "message": "会话即将过期" + }, + "sessionExpiration.titleLater": { + "message": "您的会话即将过期" + }, + "sessionExpiration.description": { + "message": "此设备即将断开连接。通过浏览器登录进行续期。" + }, + "sessionExpiration.descriptionLater": { + "message": "通过浏览器登录可让此设备保持连接到您的网络。" + }, + "sessionExpiration.stay": { + "message": "续期会话" + }, + "sessionExpiration.authenticate": { + "message": "进行身份验证" + }, + "sessionExpiration.logout": { + "message": "退出登录" + }, + "sessionExpiration.expired": { + "message": "会话已过期" + }, + "sessionExpiration.expiredDescription": { + "message": "设备已断开连接。通过浏览器登录进行身份验证以重新连接。" + }, + "sessionExpiration.close": { + "message": "关闭" + }, + "sessionExpiration.extendFailedTitle": { + "message": "延长会话失败" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "退出登录失败" + }, + "peers.search.placeholder": { + "message": "按名称或 IP 搜索" + }, + "peers.filter.all": { + "message": "全部" + }, + "peers.filter.online": { + "message": "在线" + }, + "peers.filter.offline": { + "message": "离线" + }, + "peers.empty.title": { + "message": "无可用的对等节点" + }, + "peers.empty.description": { + "message": "您可能没有任何可用的对等节点,或者无权访问其中任何一个。" + }, + "peers.details.domain": { + "message": "域名" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公钥" + }, + "peers.details.connection": { + "message": "连接" + }, + "peers.details.latency": { + "message": "延迟" + }, + "peers.details.lastHandshake": { + "message": "上次握手" + }, + "peers.details.statusSince": { + "message": "上次连接更新" + }, + "peers.details.bytes": { + "message": "字节" + }, + "peers.details.bytesSent": { + "message": "已发送" + }, + "peers.details.bytesReceived": { + "message": "已接收" + }, + "peers.details.localIce": { + "message": "本地 ICE" + }, + "peers.details.remoteIce": { + "message": "远程 ICE" + }, + "peers.details.never": { + "message": "从不" + }, + "peers.details.justNow": { + "message": "刚刚" + }, + "peers.details.refresh": { + "message": "刷新" + }, + "peers.status.connected": { + "message": "已连接" + }, + "peers.status.connecting": { + "message": "正在连接" + }, + "peers.status.disconnected": { + "message": "已断开连接" + }, + "peers.details.relayAddress": { + "message": "中继" + }, + "peers.details.networks": { + "message": "资源" + }, + "peers.details.relayed": { + "message": "经中继" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "已启用 Rosenpass" + }, + "networks.search.placeholder": { + "message": "按网络或域名搜索" + }, + "networks.filter.all": { + "message": "全部" + }, + "networks.filter.active": { + "message": "活动" + }, + "networks.filter.overlapping": { + "message": "重叠" + }, + "networks.empty.title": { + "message": "无可用资源" + }, + "networks.empty.description": { + "message": "您可能没有任何可用的网络资源,或者无权访问其中任何一个。" + }, + "networks.selected": { + "message": "已选择" + }, + "networks.unselected": { + "message": "未选择" + }, + "networks.ips.heading": { + "message": "已解析的 IP" + }, + "networks.bulk.selectionCount": { + "message": "{total} 个中已激活 {selected} 个" + }, + "networks.bulk.enableAll": { + "message": "全部启用" + }, + "networks.bulk.disableAll": { + "message": "全部禁用" + }, + "exitNodes.search.placeholder": { + "message": "搜索出口节点" + }, + "exitNodes.none": { + "message": "无" + }, + "exitNodes.empty.title": { + "message": "无可用的出口节点" + }, + "exitNodes.empty.description": { + "message": "尚未向此对等节点共享任何出口节点。" + }, + "exitNodes.card.title": { + "message": "出口节点" + }, + "exitNodes.card.statusActive": { + "message": "活动" + }, + "exitNodes.card.statusInactive": { + "message": "非活动" + }, + "exitNodes.dropdown.noneTitle": { + "message": "无" + }, + "exitNodes.dropdown.noneDescription": { + "message": "不使用出口节点的直接连接" + }, + "quickActions.connect": { + "message": "连接" + }, + "quickActions.disconnect": { + "message": "断开连接" + }, + "daemon.unavailable.title": { + "message": "NetBird 服务未运行" + }, + "daemon.unavailable.description": { + "message": "服务运行后,应用将自动重新连接。" + }, + "daemon.unavailable.docsLink": { + "message": "文档" + }, + "error.jwt_clock_skew": { + "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" + }, + "error.jwt_expired": { + "message": "您的登录令牌已过期。请重新登录。" + }, + "error.jwt_signature_invalid": { + "message": "登录失败:令牌签名无效。请联系您的管理员。" + }, + "error.session_expired": { + "message": "您的会话已过期。请重新登录。" + }, + "error.invalid_setup_key": { + "message": "设置密钥缺失或无效。" + }, + "error.permission_denied": { + "message": "登录被服务器拒绝。" + }, + "error.daemon_unreachable": { + "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" + }, + "error.unknown": { + "message": "操作失败。" + } +} From dac2ca4088d8794d4be5cc21883c46d1af143c67 Mon Sep 17 00:00:00 2001 From: Eduard Gert <kontakt@eduardgert.de> Date: Tue, 9 Jun 2026 18:08:47 +0200 Subject: [PATCH 6/7] fix dropdown animation --- client/ui/frontend/src/components/LanguagePicker.tsx | 2 +- client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 0a4660e43..8e63ae8c0 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -105,7 +105,7 @@ export function LanguagePicker() { className={cn( "w-[var(--radix-popover-trigger-width)]", "rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50", - "origin-[var(--radix-popover-content-transform-origin)]", + "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", "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", diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index b042aea87..75d88440d 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -73,6 +73,8 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", + "data-[side=left]:origin-right data-[side=right]:origin-left", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", )} From d70b3a3fa40933e1ed6fc1b4a5ffe8d6f9c390f9 Mon Sep 17 00:00:00 2001 From: Eduard Gert <kontakt@eduardgert.de> Date: Tue, 9 Jun 2026 18:23:07 +0200 Subject: [PATCH 7/7] update debug bundle text --- .../settings/SettingsTroubleshooting.tsx | 27 ++++++++++++------- client/ui/i18n/locales/de/common.json | 2 +- client/ui/i18n/locales/en/common.json | 6 ++--- client/ui/i18n/locales/es/common.json | 2 +- client/ui/i18n/locales/fr/common.json | 2 +- client/ui/i18n/locales/hu/common.json | 2 +- client/ui/i18n/locales/it/common.json | 2 +- client/ui/i18n/locales/pt/common.json | 2 +- client/ui/i18n/locales/ru/common.json | 2 +- client/ui/i18n/locales/zh-CN/common.json | 2 +- 10 files changed, 28 insertions(+), 21 deletions(-) diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx index 50288e3cc..d3aa060fb 100644 --- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -13,6 +13,7 @@ import { Input } from "@/components/inputs/Input"; import { Label } from "@/components/typography/Label"; import { SquareIcon } from "@/components/SquareIcon"; import { cn } from "@/lib/cn"; +import { formatRemaining } from "@/lib/formatters"; import type { DebugStage } from "@/contexts/DebugBundleContext"; import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; @@ -133,13 +134,24 @@ function ProgressSection({ <CenteredPanel> <SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} /> - <div className={"flex flex-col items-center gap-2 max-w-xs"}> + <div className={"flex flex-col items-center gap-2 max-w-sm"}> <DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading> <DialogDescription> {t("settings.troubleshooting.progress.description")} </DialogDescription> </div> + {stage.kind === "capturing" && ( + <div + className={ + "font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider" + } + aria-live={"polite"} + > + {formatRemaining(stage.remainingSec)} + </div> + )} + <DialogActions className={"max-w-[220px]"}> <Button autoFocus @@ -178,7 +190,7 @@ function DoneResult({ <CenteredPanel> <SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} /> - <div className={"flex flex-col items-center gap-2 max-w-xs"}> + <div className={"flex flex-col items-center gap-2 max-w-sm"}> <DialogHeading className={"text-balance"}> {showKey ? t("settings.troubleshooting.done.uploadedTitle") @@ -191,7 +203,7 @@ function DoneResult({ </DialogDescription> </div> - <div className={"w-full max-w-xs flex flex-col gap-3"}> + <div className={"w-full max-w-sm flex flex-col gap-3"}> {showKey && <Input value={result.uploadedKey} readOnly copy />} {result.path && !showKey && ( @@ -268,13 +280,8 @@ const stageLabel = ( return t("settings.troubleshooting.stage.preparingTrace"); case "reconnecting": return t("settings.troubleshooting.stage.reconnecting"); - case "capturing": { - const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; - return t("settings.troubleshooting.stage.capturing", { - elapsed: fmt(stage.totalSec - stage.remainingSec), - total: fmt(stage.totalSec), - }); - } + case "capturing": + return t("settings.troubleshooting.stage.capturing"); case "restoring-level": return t("settings.troubleshooting.stage.restoring"); case "bundling": diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 435c46cf7..3aa8592fc 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -777,7 +777,7 @@ "message": "NetBird wird neu verbunden…" }, "settings.troubleshooting.stage.capturing": { - "message": "Logs werden erfasst — {elapsed} / {total}" + "message": "Logs werden erfasst" }, "settings.troubleshooting.stage.restoring": { "message": "Vorheriges Log-Level wird wiederhergestellt…" diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e85016873..0e555c1bb 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -984,8 +984,8 @@ "description": "Button to create the debug bundle. Keep short." }, "settings.troubleshooting.progress.description": { - "message": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.", - "description": "Status text shown while the bundle is being collected; asks the user to keep the window open." + "message": "Collecting logs, system details, and connection state. This usually takes a moment. You can keep using NetBird or close Settings while it finishes.", + "description": "Status text shown while the debug bundle is being collected; reassures the user they can keep using NetBird or close Settings meanwhile." }, "settings.troubleshooting.cancelling": { "message": "Canceling…", @@ -1036,7 +1036,7 @@ "description": "Progress stage: reconnecting NetBird. Ends with an ellipsis." }, "settings.troubleshooting.stage.capturing": { - "message": "Capturing logs — {elapsed} / {total}", + "message": "Capturing logs", "description": "Progress stage: capturing logs. {elapsed} and {total} are time values (e.g. 0:30 / 2:00); keep both." }, "settings.troubleshooting.stage.restoring": { diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 5186747a4..05f70937f 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -777,7 +777,7 @@ "message": "Reconectando NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "Capturando registros: {elapsed} / {total}" + "message": "Capturando registros" }, "settings.troubleshooting.stage.restoring": { "message": "Restaurando el nivel de registro anterior…" diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index ae181b6d2..3315f1031 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -777,7 +777,7 @@ "message": "Reconnexion de NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "Capture des journaux — {elapsed} / {total}" + "message": "Capture des journaux" }, "settings.troubleshooting.stage.restoring": { "message": "Rétablissement du niveau de journalisation précédent…" diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 617815df6..7725fd458 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -777,7 +777,7 @@ "message": "NetBird újracsatlakoztatása…" }, "settings.troubleshooting.stage.capturing": { - "message": "Naplók rögzítése — {elapsed} / {total}" + "message": "Naplók rögzítése" }, "settings.troubleshooting.stage.restoring": { "message": "Korábbi napló szint visszaállítása…" diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 30bc70a28..315647a6c 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -777,7 +777,7 @@ "message": "Riconnessione di NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "Acquisizione log — {elapsed} / {total}" + "message": "Acquisizione log" }, "settings.troubleshooting.stage.restoring": { "message": "Ripristino del livello di log precedente…" diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 292a2c820..b4df4fd6a 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -777,7 +777,7 @@ "message": "Reconectando o NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "Capturando logs — {elapsed} / {total}" + "message": "Capturando logs" }, "settings.troubleshooting.stage.restoring": { "message": "Restaurando o nível de log anterior…" diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 4258bb5ca..0c65ab4fb 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -777,7 +777,7 @@ "message": "Переподключение NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "Сбор журналов — {elapsed} / {total}" + "message": "Сбор журналов" }, "settings.troubleshooting.stage.restoring": { "message": "Восстановление прежнего уровня журналирования…" diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index c6333e7cd..035ec4884 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -777,7 +777,7 @@ "message": "正在重新连接 NetBird…" }, "settings.troubleshooting.stage.capturing": { - "message": "正在捕获日志 — {elapsed} / {total}" + "message": "正在捕获日志" }, "settings.troubleshooting.stage.restoring": { "message": "正在恢复先前的日志级别…"