mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-21 08:09:55 +00:00
add i18n to frontend
This commit is contained in:
243
client/ui/frontend/src/modules/settings/LanguagePicker.tsx
Normal file
243
client/ui/frontend/src/modules/settings/LanguagePicker.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { CheckIcon, ChevronDown, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
|
||||
// build time; the JS bundle only holds URL refs, not the SVG bytes.
|
||||
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?url",
|
||||
});
|
||||
|
||||
const flagByCode: Record<string, string> = {};
|
||||
for (const path in FLAG_URLS) {
|
||||
const match = path.match(/1x1\/([^/]+)\.svg$/);
|
||||
if (match) flagByCode[match[1]] = FLAG_URLS[path];
|
||||
}
|
||||
|
||||
const flagFor = (code: string): string | undefined =>
|
||||
flagByCode[code.toLowerCase().split("-")[0]];
|
||||
|
||||
function Flag({ code, label }: { code: string; label: string }) {
|
||||
const src = flagFor(code);
|
||||
if (!src) {
|
||||
return (
|
||||
<span
|
||||
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLanguages()
|
||||
.then((list) => {
|
||||
if (!cancelled) setLanguages(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[languages],
|
||||
);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
languages.find((l) => l.code === i18n.language) ??
|
||||
languages.find((l) => l.code === "en"),
|
||||
[languages, i18n.language],
|
||||
);
|
||||
|
||||
const select = async (code: string) => {
|
||||
if (busy || code === i18n.language) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await Preferences.SetLanguage(code as LanguageCode);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center gap-6 justify-between"}>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>{t("settings.general.language.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
disabled={busy || languages.length === 0}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{current && <Flag code={current.code} label={current.displayName} />}
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current?.displayName ?? "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"start"}
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)]",
|
||||
"rounded-md border border-nb-gray-700 bg-nb-gray-900 shadow-lg p-1 z-50",
|
||||
"origin-[var(--radix-popover-content-transform-origin)]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-1",
|
||||
"data-[side=top]:slide-in-from-bottom-1",
|
||||
"duration-150 ease-out",
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
||||
)}
|
||||
>
|
||||
<div className={"px-1 pb-1"}>
|
||||
<div className={"group flex items-center gap-2 px-1 h-8"}>
|
||||
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"outline-none border-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
|
||||
<ScrollArea.Viewport className={"max-h-64 px-1"}>
|
||||
<Command.List>
|
||||
<Command.Empty>
|
||||
<div
|
||||
className={
|
||||
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
|
||||
}
|
||||
>
|
||||
{t("settings.general.language.empty")}
|
||||
</div>
|
||||
</Command.Empty>
|
||||
|
||||
{sorted.map((lang) => {
|
||||
const checked = lang.code === i18n.language;
|
||||
return (
|
||||
<Command.Item
|
||||
key={lang.code}
|
||||
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
|
||||
onSelect={() => void select(lang.code)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none",
|
||||
"text-xs font-semibold text-nb-gray-100",
|
||||
"data-[selected=true]:bg-nb-gray-850 my-0.5",
|
||||
checked &&
|
||||
"bg-nb-gray-800 data-[selected=true]:bg-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
<Flag
|
||||
code={lang.code}
|
||||
label={lang.displayName}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{lang.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={12}
|
||||
className={"text-white"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
@@ -9,13 +10,20 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
return (
|
||||
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as ManagementMode)}
|
||||
>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
Cloud
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,35 +14,19 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
|
||||
const LAST_TAB_KEY = "netbird:settings:lastTab";
|
||||
|
||||
const readLastTab = () => {
|
||||
try {
|
||||
return localStorage.getItem(LAST_TAB_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The settings window always opens at General. The only way to land on a
|
||||
// different tab is via navigation state (e.g. the update-available header
|
||||
// trigger jumps to About). No persistence across opens — a user who wants
|
||||
// to revisit a deep tab gets there in two clicks.
|
||||
export const Settings = () => {
|
||||
const location = useLocation();
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const [active, setActive] = useState(
|
||||
() => navState?.tab ?? readLastTab() ?? "general",
|
||||
);
|
||||
const [active, setActive] = useState(() => navState?.tab ?? "general");
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.tab) setActive(navState.tab);
|
||||
}, [navState?.tab, location.key]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(LAST_TAB_KEY, active);
|
||||
} catch {
|
||||
// ignore quota / unavailable storage
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||
<SettingsNavigationTriggers />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import pkg from "../../../package.json";
|
||||
@@ -5,24 +6,25 @@ import { useStatus } from "@/hooks/useStatus";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: "Imprint", url: "https://netbird.io/imprint" },
|
||||
{ label: "Privacy", url: "https://netbird.io/privacy" },
|
||||
{ label: "CLA", url: "https://netbird.io/cla" },
|
||||
{ label: "Terms of Service", url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
function openUrl(url: string) {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
}
|
||||
|
||||
export function SettingsAbout() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const guiVersion = pkg.version;
|
||||
const daemonVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" },
|
||||
{ label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" },
|
||||
{ label: t("settings.about.links.cla"), url: "https://netbird.io/cla" },
|
||||
{ label: t("settings.about.links.terms"), url: "https://netbird.io/terms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -35,15 +37,17 @@ export function SettingsAbout() {
|
||||
className={"text-sm font-semibold text-nb-gray-100 cursor-default select-none"}
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
NetBird Client v{daemonVersion}
|
||||
{t("settings.about.client", { version: daemonVersion })}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>
|
||||
{t("settings.about.gui", { version: guiVersion })}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
|
||||
</div>
|
||||
|
||||
<UpdateVersionCard />
|
||||
|
||||
<p className={"text-sm text-nb-gray-300 text-center"}>
|
||||
© {new Date().getFullYear()} NetBird. All Rights Reserved.
|
||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
<div
|
||||
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "@/components/Button";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
@@ -7,6 +8,7 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveFields } = useSettings();
|
||||
|
||||
const [values, setValues] = useState({
|
||||
@@ -35,9 +37,9 @@ export function SettingsAdvanced() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Interface"}>
|
||||
<SectionGroup title={t("settings.advanced.section.interface")}>
|
||||
<Input
|
||||
label={"Name"}
|
||||
label={t("settings.advanced.interfaceName.label")}
|
||||
value={values.interfaceName}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||
@@ -45,7 +47,7 @@ export function SettingsAdvanced() {
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={"Port"}
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
value={values.wireguardPort}
|
||||
onChange={(e) =>
|
||||
@@ -56,7 +58,7 @@ export function SettingsAdvanced() {
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={"MTU"}
|
||||
label={t("settings.advanced.mtu.label")}
|
||||
type={"number"}
|
||||
value={values.mtu}
|
||||
onChange={(e) =>
|
||||
@@ -66,13 +68,11 @@ export function SettingsAdvanced() {
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Security"}>
|
||||
<SectionGroup title={t("settings.advanced.section.security")}>
|
||||
<div>
|
||||
<Label as={"div"}>Pre-shared Key</Label>
|
||||
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
|
||||
<HelpText>
|
||||
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
||||
NetBird Setup Key. You will only communicate with peers that use the same
|
||||
pre-shared key.
|
||||
{t("settings.advanced.psk.help")}
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
@@ -94,7 +94,7 @@ export function SettingsAdvanced() {
|
||||
disabled={!hasChanges || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
|
||||
@@ -52,7 +53,7 @@ const useSettingsState = () => {
|
||||
setConfig(c);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Load Settings Failed",
|
||||
Title: i18next.t("settings.error.loadTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
@@ -82,7 +83,7 @@ const useSettingsState = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: "Save Settings Failed",
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
@@ -8,8 +9,10 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/modules/settings/LanguagePicker.tsx";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
|
||||
@@ -27,29 +30,29 @@ export function SettingsGeneral() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"General"}>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableAutoConnect}
|
||||
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||
label={"Connect on Startup"}
|
||||
helpText={"Automatically establish a connection when the service starts."}
|
||||
label={t("settings.general.connectOnStartup.label")}
|
||||
helpText={t("settings.general.connectOnStartup.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
label={"Desktop Notifications"}
|
||||
helpText={"Show desktop notifications for new updates and connection events."}
|
||||
label={t("settings.general.notifications.label")}
|
||||
helpText={t("settings.general.notifications.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Connection"}>
|
||||
<SectionGroup title={t("settings.general.section.connection")}>
|
||||
<div>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>Management Server</Label>
|
||||
<Label as={"div"}>{t("settings.general.management.label")}</Label>
|
||||
<HelpText>
|
||||
Connect to NetBird Cloud or your own self-hosted management server.
|
||||
Changes will reconnect the client.
|
||||
{t("settings.general.management.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
@@ -60,10 +63,10 @@ export function SettingsGeneral() {
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={"https://netbird.selfhosted.com:443"}
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
error={
|
||||
showError
|
||||
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
|
||||
? t("settings.general.management.urlError")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -73,7 +76,7 @@ export function SettingsGeneral() {
|
||||
disabled={!canSave}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@/components/Tooltip.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigationTriggers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
|
||||
const aboutAdornment = updateAvailable ? (
|
||||
<Tooltip content={"Update Available"} side={"right"}>
|
||||
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
|
||||
<UpdateBadge />
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
@@ -27,37 +29,37 @@ export const SettingsNavigationTriggers = () => {
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={"General"}
|
||||
title={t("settings.tabs.general")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={"Network"}
|
||||
title={t("settings.tabs.network")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={"Security"}
|
||||
title={t("settings.tabs.security")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={"SSH"}
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={"Advanced"}
|
||||
title={t("settings.tabs.advanced")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={"Troubleshooting"}
|
||||
title={t("settings.tabs.troubleshooting")}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={"About"}
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsNetwork() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Connectivity"}>
|
||||
<SectionGroup title={t("settings.network.section.connectivity")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.lazyConnectionEnabled}
|
||||
onChange={(v) => setField("lazyConnectionEnabled", v)}
|
||||
label={"Lazy Connections"}
|
||||
helpText={
|
||||
"Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling."
|
||||
}
|
||||
label={t("settings.network.lazy.label")}
|
||||
helpText={t("settings.network.lazy.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.networkMonitor}
|
||||
onChange={(v) => setField("networkMonitor", v)}
|
||||
label={"Reconnect on Network Change"}
|
||||
helpText={
|
||||
"Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep."
|
||||
}
|
||||
label={t("settings.network.monitor.label")}
|
||||
helpText={t("settings.network.monitor.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Routing & DNS"}>
|
||||
<SectionGroup title={t("settings.network.section.routingDns")}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableDns}
|
||||
onChange={(v) => setField("disableDns", !v)}
|
||||
label={"Enable DNS"}
|
||||
helpText={"Apply NetBird-managed DNS settings to the host resolver."}
|
||||
label={t("settings.network.dns.label")}
|
||||
helpText={t("settings.network.dns.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableClientRoutes}
|
||||
onChange={(v) => setField("disableClientRoutes", !v)}
|
||||
label={"Enable Client Routes"}
|
||||
helpText={"Accept routes from other peers to reach their networks."}
|
||||
label={t("settings.network.clientRoutes.label")}
|
||||
helpText={t("settings.network.clientRoutes.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableServerRoutes}
|
||||
onChange={(v) => setField("disableServerRoutes", !v)}
|
||||
label={"Enable Server Routes"}
|
||||
helpText={"Advertise this host's local routes to other peers."}
|
||||
label={t("settings.network.serverRoutes.label")}
|
||||
helpText={t("settings.network.serverRoutes.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
@@ -8,11 +9,11 @@ import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
// Keep the local input in sync when the config changes from elsewhere
|
||||
useEffect(() => {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}, [config.sshJwtCacheTtl]);
|
||||
@@ -40,58 +41,48 @@ export function SettingsSSH() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Server"}>
|
||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label={"Enable SSH Server"}
|
||||
helpText={
|
||||
"Run the NetBird SSH server on this host so other peers can connect to it."
|
||||
}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
|
||||
<SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label={"Allow Root Login"}
|
||||
helpText={
|
||||
"Let peers sign in as the root user. Disable to require a non-privileged account."
|
||||
}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label={"Allow SFTP"}
|
||||
helpText={"Transfer files securely using native SFTP or SCP clients."}
|
||||
label={t("settings.ssh.sftp.label")}
|
||||
helpText={t("settings.ssh.sftp.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label={"Local Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers tunnel local ports to services reachable from this host."
|
||||
}
|
||||
label={t("settings.ssh.localForward.label")}
|
||||
helpText={t("settings.ssh.localForward.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRemotePortForwarding}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label={"Remote Port Forwarding"}
|
||||
helpText={
|
||||
"Let connecting peers expose ports on this host back to their own machine."
|
||||
}
|
||||
label={t("settings.ssh.remoteForward.label")}
|
||||
helpText={t("settings.ssh.remoteForward.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
|
||||
<SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
label={"Enable JWT Authentication"}
|
||||
helpText={
|
||||
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
|
||||
}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -100,11 +91,9 @@ export function SettingsSSH() {
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>JWT Cache TTL</Label>
|
||||
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
|
||||
<HelpText margin={false}>
|
||||
How long this client caches a JWT before prompting again on outgoing SSH
|
||||
connections. Set to 0 to disable caching and authenticate on every
|
||||
connection.
|
||||
{t("settings.ssh.jwtTtl.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
@@ -114,7 +103,7 @@ export function SettingsSSH() {
|
||||
value={jwtTtlInput}
|
||||
onChange={handleJwtTtlChange}
|
||||
onBlur={handleJwtTtlBlur}
|
||||
customSuffix={"Second(s)"}
|
||||
customSuffix={t("settings.ssh.jwtTtl.suffix")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Firewall"}>
|
||||
<SectionGroup title={t("settings.security.section.firewall")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockInbound}
|
||||
onChange={(v) => setField("blockInbound", v)}
|
||||
label={"Block Inbound Traffic"}
|
||||
helpText={
|
||||
"Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected."
|
||||
}
|
||||
label={t("settings.security.blockInbound.label")}
|
||||
helpText={t("settings.security.blockInbound.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.blockLanAccess}
|
||||
onChange={(v) => setField("blockLanAccess", v)}
|
||||
label={"Block LAN Access"}
|
||||
helpText={
|
||||
"Prevent peers from reaching your local network or its devices when this device routes their traffic."
|
||||
}
|
||||
label={t("settings.security.blockLan.label")}
|
||||
helpText={t("settings.security.blockLan.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Encryption"}>
|
||||
<SectionGroup title={t("settings.security.section.encryption")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassEnabled}
|
||||
onChange={(v) => {
|
||||
setField("rosenpassEnabled", v);
|
||||
if (!v) setField("rosenpassPermissive", false);
|
||||
}}
|
||||
label={"Enable Quantum-Resistance"}
|
||||
helpText={
|
||||
"Add a post-quantum key exchange via Rosenpass on top of WireGuard®."
|
||||
}
|
||||
label={t("settings.security.rosenpass.label")}
|
||||
helpText={t("settings.security.rosenpass.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.rosenpassPermissive}
|
||||
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||
label={"Enable Permissive Mode"}
|
||||
helpText={
|
||||
"Allow connections to peers without quantum-resistance support."
|
||||
}
|
||||
label={t("settings.security.rosenpassPermissive.label")}
|
||||
helpText={t("settings.security.rosenpassPermissive.help")}
|
||||
disabled={!config.rosenpassEnabled}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
@@ -14,6 +15,7 @@ import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleCont
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
@@ -45,39 +47,34 @@ export function SettingsTroubleshooting() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionGroup title={"Debug bundle"}>
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<HelpText className={"-mt-2 mb-2"}>
|
||||
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
|
||||
.zip file with logs, system details and debug information from your device.
|
||||
<Trans i18nKey={"settings.troubleshooting.intro"} components={{ br: <br /> }} />
|
||||
</HelpText>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={"Anonymize Sensitive Information"}
|
||||
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
|
||||
label={t("settings.troubleshooting.anonymize.label")}
|
||||
helpText={t("settings.troubleshooting.anonymize.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label={"Include System Information"}
|
||||
helpText={"Include OS, kernel, network interfaces, and routing tables."}
|
||||
label={t("settings.troubleshooting.systemInfo.label")}
|
||||
helpText={t("settings.troubleshooting.systemInfo.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={upload}
|
||||
onChange={setUpload}
|
||||
label={"Upload Bundle to NetBird Servers"}
|
||||
helpText={
|
||||
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
|
||||
}
|
||||
label={t("settings.troubleshooting.upload.label")}
|
||||
helpText={t("settings.troubleshooting.upload.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={trace}
|
||||
onChange={setTrace}
|
||||
label={"Capture Trace Logs"}
|
||||
helpText={
|
||||
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
|
||||
}
|
||||
label={t("settings.troubleshooting.trace.label")}
|
||||
helpText={t("settings.troubleshooting.trace.help")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -86,9 +83,9 @@ export function SettingsTroubleshooting() {
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>Capture Duration</Label>
|
||||
<Label as={"div"}>{t("settings.troubleshooting.duration.label")}</Label>
|
||||
<HelpText margin={false}>
|
||||
How long to capture trace logs before generating the bundle.
|
||||
{t("settings.troubleshooting.duration.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
@@ -100,7 +97,7 @@ export function SettingsTroubleshooting() {
|
||||
onChange={(e) =>
|
||||
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
|
||||
}
|
||||
customSuffix={"Minute(s)"}
|
||||
customSuffix={t("settings.troubleshooting.duration.suffix")}
|
||||
disabled={!trace}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,7 +105,7 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
<BottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={run}>
|
||||
Create Bundle
|
||||
{t("settings.troubleshooting.create")}
|
||||
</Button>
|
||||
</BottomBar>
|
||||
</SectionGroup>
|
||||
@@ -116,17 +113,18 @@ export function SettingsTroubleshooting() {
|
||||
}
|
||||
|
||||
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cancelling = stage.kind === "cancelling";
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"loading"}
|
||||
title={stageLabel(stage)}
|
||||
description={
|
||||
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
|
||||
}
|
||||
title={stageLabel(stage, t)}
|
||||
description={t("settings.troubleshooting.progress.description")}
|
||||
actions={
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Cancel"}
|
||||
{cancelling
|
||||
? t("settings.troubleshooting.cancelling")
|
||||
: t("common.cancel")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -142,6 +140,7 @@ function DoneResult({
|
||||
uploaded: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showKey = uploaded && Boolean(result.uploadedKey);
|
||||
const uploadFailed = uploaded && !result.uploadedKey;
|
||||
const onRevealPath = () => {
|
||||
@@ -151,26 +150,30 @@ function DoneResult({
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"success"}
|
||||
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
|
||||
title={
|
||||
showKey
|
||||
? t("settings.troubleshooting.done.uploadedTitle")
|
||||
: t("settings.troubleshooting.done.savedTitle")
|
||||
}
|
||||
description={
|
||||
showKey
|
||||
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
|
||||
: "Your debug bundle has been saved locally."
|
||||
? t("settings.troubleshooting.done.uploadedDescription")
|
||||
: t("settings.troubleshooting.done.savedDescription")
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||
Close
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
{showKey ? (
|
||||
<Button variant={"primary"} size={"xs"} copy={result.uploadedKey}>
|
||||
Copy Key
|
||||
{t("settings.troubleshooting.done.copyKey")}
|
||||
</Button>
|
||||
) : (
|
||||
result.path && (
|
||||
<Button variant={"primary"} size={"xs"} onClick={onRevealPath}>
|
||||
<FolderOpen size={12} />
|
||||
Open Folder
|
||||
{t("settings.troubleshooting.done.openFolder")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -189,7 +192,7 @@ function DoneResult({
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto hover:text-white transition-all"}
|
||||
aria-label={"Open file location"}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
@@ -203,9 +206,11 @@ function DoneResult({
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
}
|
||||
>
|
||||
Upload failed
|
||||
{result.uploadFailureReason ? `: ${result.uploadFailureReason}` : "."} The
|
||||
bundle is still saved locally.
|
||||
{result.uploadFailureReason
|
||||
? t("settings.troubleshooting.uploadFailedWithReason", {
|
||||
reason: result.uploadFailureReason,
|
||||
})
|
||||
: t("settings.troubleshooting.uploadFailed")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -227,26 +232,27 @@ function BottomBar({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabel = (stage: DebugStage): string => {
|
||||
const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return "Switching to trace logging…";
|
||||
return t("settings.troubleshooting.stage.preparingTrace");
|
||||
case "reconnecting":
|
||||
return "Reconnecting NetBird…";
|
||||
return t("settings.troubleshooting.stage.reconnecting");
|
||||
case "capturing": {
|
||||
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return `Capturing logs — ${fmt(
|
||||
stage.totalSec - stage.remainingSec,
|
||||
)} / ${fmt(stage.totalSec)}`;
|
||||
return t("settings.troubleshooting.stage.capturing", {
|
||||
elapsed: fmt(stage.totalSec - stage.remainingSec),
|
||||
total: fmt(stage.totalSec),
|
||||
});
|
||||
}
|
||||
case "restoring-level":
|
||||
return "Restoring previous log level…";
|
||||
return t("settings.troubleshooting.stage.restoring");
|
||||
case "bundling":
|
||||
return "Generating debug bundle…";
|
||||
return t("settings.troubleshooting.stage.bundling");
|
||||
case "uploading":
|
||||
return "Uploading to NetBird…";
|
||||
return t("settings.troubleshooting.stage.uploading");
|
||||
case "cancelling":
|
||||
return "Cancelling…";
|
||||
return t("settings.troubleshooting.stage.cancelling");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
@@ -60,16 +61,17 @@ export function useManagementUrl() {
|
||||
// Switching from a self-hosted management server to NetBird Cloud
|
||||
// re-points the client at a different deployment and forces a
|
||||
// reconnect/re-login. Confirm before applying.
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
|
||||
void Dialogs.Warning({
|
||||
Title: "Switch to NetBird Cloud?",
|
||||
Message:
|
||||
"This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
|
||||
Title: i18next.t("settings.general.management.switchCloudTitle"),
|
||||
Message: i18next.t("settings.general.management.switchCloudMessage"),
|
||||
Buttons: [
|
||||
{ Label: "Cancel", IsCancel: true, IsDefault: true },
|
||||
{ Label: "Switch to Cloud" },
|
||||
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
|
||||
{ Label: confirmLabel },
|
||||
],
|
||||
}).then((result) => {
|
||||
if (result !== "Switch to Cloud") return;
|
||||
if (result !== confirmLabel) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user