[management,client] 0.75.0 release with new desktop UI (#6473)

- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Maycon Santos
2026-07-06 13:47:16 +02:00
committed by GitHub
co-authored by Zoltan Papp Eduard Gert braginini Pascal Fischer riccardom
parent c9d387bd0d
commit 91acb8147c
389 changed files with 46269 additions and 6684 deletions
@@ -0,0 +1,169 @@
import type { ComponentType, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
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) {
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function SettingsAbout() {
const { t } = useTranslation();
const { status } = useStatus();
const { guiVersion } = useSettings();
const daemonVersion = status?.daemonVersion ?? "—";
const handleVersionClick = useAccentTrigger();
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: GithubIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.slack"),
url: "https://docs.netbird.io/slack-url",
Icon: SlackIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.forum"),
url: "https://forum.netbird.io",
Icon: MessagesSquare,
},
{
label: t("settings.about.community.documentation"),
url: "https://docs.netbird.io",
Icon: BookOpen,
},
{
label: t("settings.about.community.feedback"),
url: "https://forms.gle/TeLw2zrXEdw6RcQ36",
Icon: MessageSquareText,
},
];
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={
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
}
>
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<button
type={"button"}
onClick={handleVersionClick}
className={
"cursor-text select-text bg-transparent text-sm font-semibold text-nb-gray-100 outline-none"
}
>
{daemonVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: daemonVersion })
)}
</button>
<p className={"cursor-text select-text text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<UpdateVersionCard />
<p className={"mt-2 text-center text-sm text-nb-gray-300"}>
{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"}
>
{COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => (
<button
key={url}
type={"button"}
tabIndex={0}
onClick={() => openUrl(url)}
className={
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
<span>{label}</span>
</button>
))}
</div>
<div
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
>
{LEGAL_LINKS.map((link) => (
<button
key={link.url}
type={"button"}
tabIndex={0}
onClick={() => openUrl(link.url)}
className={
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
{link.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
export function useAccentTrigger() {
const clicksRef = useRef(0);
const lastClickRef = useRef(0);
return useCallback(() => {
const now = performance.now();
if (now - lastClickRef.current > 400) {
clicksRef.current = 0;
}
lastClickRef.current = now;
clicksRef.current += 1;
if (clicksRef.current >= 10) {
clicksRef.current = 0;
triggerAccent();
}
}, []);
}
function triggerAccent() {
if (document.getElementById("nb-accent-root")) return;
const container = document.createElement("div");
container.id = "nb-accent-root";
document.body.appendChild(container);
const root = createRoot(container);
const cleanup = () => {
root.unmount();
container.remove();
};
root.render(<Accent onDone={cleanup} />);
}
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
}
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
const timeout = globalThis.setTimeout(() => {
setVisible(false);
globalThis.setTimeout(onDone, 500);
}, 9000);
return () => {
cancelAnimationFrame(raf);
globalThis.clearTimeout(timeout);
window.removeEventListener("resize", resize);
};
}, [onDone]);
return (
<div
className={`pointer-events-none fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 ${visible ? "opacity-100" : "opacity-0"}`}
>
<canvas ref={canvasRef} className={"block"} />
</div>
);
}
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { System } from "@wailsio/runtime";
import Button from "@/components/buttons/Button";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
// 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 lets the daemon pick a random free port.
const PORT_MIN = 0;
const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
const MTU_MIN = 576;
const MTU_MAX = 8192;
const PSK_MASK = "**********";
export function SettingsAdvanced() {
const { t } = useTranslation();
const { config, saveFields } = useSettings();
const { mdm } = useRestrictions();
const initialPsk = config.preSharedKeySet ? PSK_MASK : "";
const [values, setValues] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
});
const [pskInputValue, setPskInputValue] = useState(initialPsk);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValues({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
});
setPskInputValue(config.preSharedKeySet ? PSK_MASK : "");
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]);
const errors = useMemo(() => {
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
out.interfaceName = t(INTERFACE_NAME_ERROR_KEY);
}
if (
!Number.isInteger(values.wireguardPort) ||
values.wireguardPort < PORT_MIN ||
values.wireguardPort > PORT_MAX
) {
out.wireguardPort = t("settings.advanced.port.error", {
min: PORT_MIN,
max: PORT_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;
}, [values.interfaceName, values.wireguardPort, values.mtu, t]);
const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors;
const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined);
const pskChanged = pskInputValue !== initialPsk;
const hasChanges =
values.interfaceName !== config.interfaceName ||
(!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) ||
values.mtu !== config.mtu ||
(!mdm.preSharedKey && pskChanged);
const handleSave = async () => {
if (!hasChanges || saving || hasErrors) return;
setSaving(true);
try {
const partial: typeof values = { ...values };
if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort;
const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK;
const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined;
await saveFields(partial, pskOpts);
if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK);
} finally {
setSaving(false);
}
};
return (
<>
<SectionGroup title={t("settings.advanced.section.interface")}>
<Input
label={t("settings.advanced.interfaceName.label")}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
<div className={mdm.wireguardPort ? "" : "grid grid-cols-2 gap-4"}>
{!mdm.wireguardPort && (
<div>
<Input
label={t("settings.advanced.port.label")}
type={"number"}
min={PORT_MIN}
max={PORT_MAX}
value={values.wireguardPort}
error={errors.wireguardPort}
onChange={(e) =>
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
/>
<HelpText className={"mt-1.5"}>
{t("settings.advanced.port.help")}
</HelpText>
</div>
)}
<Input
label={t("settings.advanced.mtu.label")}
type={"number"}
min={MTU_MIN}
max={MTU_MAX}
value={values.mtu}
error={errors.mtu}
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
/>
</div>
</SectionGroup>
{!mdm.preSharedKey && (
<SectionGroup title={t("settings.advanced.section.security")}>
<div>
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
showPasswordToggle={pskInputValue !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={pskInputValue}
onChange={(e) => setPskInputValue(e.target.value)}
spellCheck={false}
autoComplete={"new-password"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
</div>
</SectionGroup>
)}
<SettingsBottomBar>
<Button
variant={"primary"}
size={"md"}
disabled={!hasChanges || saving || hasErrors}
onClick={handleSave}
>
{t("common.saveChanges")}
</Button>
</SettingsBottomBar>
</>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
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 { mdm, features } = useRestrictions();
const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
const prevMode = useRef(mode);
useEffect(() => {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
inputRef.current?.focus();
}
prevMode.current = mode;
}, [mode]);
return (
<>
<SectionGroup title={t("settings.general.section.general")}>
<LanguagePicker />
<FancyToggleSwitch
value={!config.disableNotifications}
onChange={(v) => setField("disableNotifications", !v)}
label={t("settings.general.notifications.label")}
helpText={t("settings.general.notifications.help")}
/>
{!mdm.disableAutoConnect && !features.disableUpdateSettings && (
<FancyToggleSwitch
value={!config.disableAutoConnect}
onChange={(v) => setField("disableAutoConnect", !v)}
label={t("settings.general.connectOnStartup.label")}
helpText={t("settings.general.connectOnStartup.help")}
/>
)}
{(autostart === null || autostart.supported) && (
<FancyToggleSwitch
value={autostart?.enabled ?? false}
onChange={setAutostartEnabled}
loading={autostart === null}
label={t("settings.general.autostart.label")}
helpText={t("settings.general.autostart.help")}
/>
)}
</SectionGroup>
{!mdm.managementURL && !features.disableUpdateSettings && (
<SectionGroup title={t("settings.general.section.connection")}>
<div>
<div className={"flex items-start gap-3"}>
<div className={"min-w-0 flex-1"}>
<Label htmlFor={managementUrlId}>
{t("settings.general.management.label")}
</Label>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"mt-2 flex items-start gap-3"}>
<Input
id={managementUrlId}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("settings.general.management.urlPlaceholder")}
error={
showError
? t("settings.general.management.urlError")
: undefined
}
warning={
unreachable
? t("settings.general.management.urlUnreachable")
: undefined
}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
loading={checking}
onClick={() => save()}
>
{t("common.save")}
</Button>
</div>
)}
</div>
</SectionGroup>
)}
</>
);
}
@@ -0,0 +1,87 @@
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";
import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import {
BoltIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
UserCircleIcon,
} from "lucide-react";
export const SettingsNavigation = () => {
const { t } = useTranslation();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings;
const aboutAdornment = updateAvailable ? (
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
<UpdateBadge />
</Tooltip>
) : undefined;
return (
<div className={"flex w-52 shrink-0 select-none flex-col items-center"}>
<VerticalTabs.List aria-label={t("settings.nav.label")}>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
{!features.disableUpdateSettings && (
<>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
</>
)}
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
)}
{showSsh && (
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
)}
{!features.disableUpdateSettings && (
<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>
);
};
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export function SettingsNetwork() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { mdm } = useRestrictions();
return (
<>
<SectionGroup title={t("settings.network.section.connectivity")}>
<FancyToggleSwitch
value={config.networkMonitor}
onChange={(v) => setField("networkMonitor", v)}
label={t("settings.network.monitor.label")}
helpText={t("settings.network.monitor.help")}
/>
</SectionGroup>
<SectionGroup title={t("settings.network.section.routingDns")}>
<FancyToggleSwitch
value={!config.disableDns}
onChange={(v) => setField("disableDns", !v)}
label={t("settings.network.dns.label")}
helpText={t("settings.network.dns.help")}
/>
{!mdm.disableClientRoutes && (
<FancyToggleSwitch
value={!config.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", !v)}
label={t("settings.network.clientRoutes.label")}
helpText={t("settings.network.clientRoutes.help")}
/>
)}
{!mdm.disableServerRoutes && (
<FancyToggleSwitch
value={!config.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", !v)}
label={t("settings.network.serverRoutes.label")}
helpText={t("settings.network.serverRoutes.help")}
/>
)}
<FancyToggleSwitch
value={!config.disableIpv6}
onChange={(v) => setField("disableIpv6", !v)}
label={t("settings.network.ipv6.label")}
helpText={t("settings.network.ipv6.help")}
/>
</SectionGroup>
</>
);
}
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
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 { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
const enum Tab {
General = "general",
Network = "network",
Security = "security",
Profiles = "profiles",
SSH = "ssh",
Advanced = "advanced",
Troubleshooting = "troubleshooting",
About = "about",
}
const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
[Tab.Security]: <SettingsSecurity />,
[Tab.Profiles]: <ProfilesTab />,
[Tab.SSH]: <SettingsSSH />,
[Tab.Advanced]: <SettingsAdvanced />,
[Tab.Troubleshooting]: <SettingsTroubleshooting />,
[Tab.About]: <SettingsAbout />,
};
export const SettingsPage = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const { mdm, features } = useRestrictions();
const visibleTabs = useMemo<Tab[]>(() => {
const editable = !features.disableUpdateSettings;
const visibility: Record<Tab, boolean> = {
[Tab.General]: true,
[Tab.Network]: editable,
[Tab.Security]: editable,
[Tab.Profiles]: !features.disableProfiles,
[Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.Advanced]: editable,
[Tab.Troubleshooting]: true,
[Tab.About]: true,
};
return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]);
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
const defaultTab = visibleTabs[0];
const [active, setActive] = useState<string>(() => navState?.tab ?? defaultTab);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
}, [navState?.tab, location.key]);
useEffect(() => {
return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => {
setActive(e.data || defaultTab);
});
}, [defaultTab]);
// Reset active tab if it got disabled by any feature flag or mdm restrictions
useEffect(() => {
if (!visibleTabs.includes(active as Tab)) setActive(defaultTab);
}, [visibleTabs, active, defaultTab]);
return (
<>
{isMacOS() ? (
<div className={"wails-draggable h-12 shrink-0 cursor-default select-none"} />
) : (
<div className={"h-px shrink-0 bg-nb-gray-920/0"} />
)}
<main className={"flex min-h-0 flex-1"}>
<VerticalTabs value={active} onValueChange={setActive}>
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<SettingsProvider>
<ScrollArea.Root
key={active}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"px-7 py-6"}>
{visibleTabs.map((tab) => (
<VerticalTabs.Content key={tab} value={tab}>
{TAB_CONTENT[tab]}
</VerticalTabs.Content>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</SettingsProvider>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
</main>
</>
);
};
@@ -0,0 +1,119 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const isSSHServerEnabled = config.serverSshAllowed;
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
useEffect(() => {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}, [config.sshJwtCacheTtl]);
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setJwtTtlInput(v);
if (v === "") return;
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
setField("sshJwtCacheTtl", n);
}
};
const handleJwtTtlBlur = () => {
if (jwtTtlInput === "") {
setJwtTtlInput("0");
setField("sshJwtCacheTtl", 0);
return;
}
const n = Number(jwtTtlInput);
if (!Number.isFinite(n) || n < 0) {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}
};
return (
<>
<SectionGroup title={t("settings.ssh.section.server")}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
/>
</SectionGroup>
<SectionGroup
title={t("settings.ssh.section.capabilities")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
/>
<FancyToggleSwitch
value={config.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label={t("settings.ssh.sftp.label")}
helpText={t("settings.ssh.sftp.help")}
/>
<FancyToggleSwitch
value={config.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label={t("settings.ssh.localForward.label")}
helpText={t("settings.ssh.localForward.help")}
/>
<FancyToggleSwitch
value={config.enableSshRemotePortForwarding}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label={t("settings.ssh.remoteForward.label")}
helpText={t("settings.ssh.remoteForward.help")}
/>
</SectionGroup>
<SectionGroup
title={t("settings.ssh.section.authentication")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
/>
<div
className={cn(
"flex items-center justify-between gap-6",
config.disableSshAuth && "pointer-events-none opacity-50",
)}
>
<div className={"max-w-md flex-1"}>
<Label htmlFor={jwtTtlId}>{t("settings.ssh.jwtTtl.label")}</Label>
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
id={jwtTtlId}
type={"number"}
min={0}
value={jwtTtlInput}
onChange={handleJwtTtlChange}
onBlur={handleJwtTtlBlur}
customSuffix={t("settings.ssh.jwtTtl.suffix")}
/>
</div>
</div>
</SectionGroup>
</>
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
export const SectionGroup = ({
title,
children,
disabled = false,
}: {
title: string;
children: ReactNode;
disabled?: boolean;
}) => (
<section
aria-label={title}
tabIndex={disabled ? -1 : 0}
{...(disabled ? { inert: "" } : {})}
className={cn(
"mb-8 rounded-md px-1 outline-none last:mb-1",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled && "pointer-events-none opacity-30",
)}
>
<h2 className={"mb-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-400"}>
{title}
</h2>
<div className={"flex flex-col gap-5"}>{children}</div>
</section>
);
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
}
>
{children}
</div>
</div>
</>
);
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export function SettingsSecurity() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { mdm } = useRestrictions();
const hideRosenpassEnabled = mdm.rosenpassEnabled;
const hideRosenpassPermissive =
mdm.rosenpassPermissive || (mdm.rosenpassEnabled && !config.rosenpassEnabled);
const showEncryptionSection = !(hideRosenpassEnabled && hideRosenpassPermissive);
return (
<>
<SectionGroup title={t("settings.security.section.firewall")}>
{!mdm.blockInbound && (
<FancyToggleSwitch
value={config.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label={t("settings.security.blockInbound.label")}
helpText={t("settings.security.blockInbound.help")}
/>
)}
<FancyToggleSwitch
value={config.blockLanAccess}
onChange={(v) => setField("blockLanAccess", v)}
label={t("settings.security.blockLan.label")}
helpText={t("settings.security.blockLan.help")}
/>
</SectionGroup>
{showEncryptionSection && (
<SectionGroup title={t("settings.security.section.encryption")}>
{!hideRosenpassEnabled && (
<FancyToggleSwitch
value={config.rosenpassEnabled}
onChange={(v) => {
setField("rosenpassEnabled", v);
if (!v) setField("rosenpassPermissive", false);
}}
label={t("settings.security.rosenpass.label")}
helpText={t("settings.security.rosenpass.help")}
/>
)}
{!hideRosenpassPermissive && (
<FancyToggleSwitch
value={config.rosenpassPermissive}
onChange={(v) => setField("rosenpassPermissive", v)}
label={t("settings.security.rosenpassPermissive.label")}
helpText={t("settings.security.rosenpassPermissive.help")}
disabled={!config.rosenpassEnabled}
/>
)}
</SectionGroup>
)}
</>
);
}
@@ -0,0 +1,27 @@
import Skeleton from "react-loading-skeleton";
export const SettingsSkeleton = () => {
return (
<div className={"flex flex-col gap-6"}>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
<div className={"mt-3"}>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
</div>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
<Skeleton width={300} height={10} />
</div>
</div>
</div>
);
};
@@ -0,0 +1,337 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon";
import { formatRemaining } from "@/lib/formatters";
import type { DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
const {
anonymize,
setAnonymize,
systemInfo,
setSystemInfo,
upload,
setUpload,
trace,
setTrace,
capture,
setCapture,
traceMinutes,
setTraceMinutes,
capturePackets,
setCapturePackets,
run,
stage,
cancel,
reset,
} = useDebugBundleContext();
if (stage.kind === "done") {
return (
<DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} />
);
}
if (stage.kind !== "idle") {
return <ProgressSection stage={stage} onCancel={cancel} />;
}
return (
<SectionGroup title={t("settings.troubleshooting.section.title")}>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={t("settings.troubleshooting.anonymize.label")}
helpText={t("settings.troubleshooting.anonymize.help")}
/>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}
label={t("settings.troubleshooting.systemInfo.label")}
helpText={t("settings.troubleshooting.systemInfo.help")}
/>
<FancyToggleSwitch
value={upload}
onChange={setUpload}
label={t("settings.troubleshooting.upload.label")}
helpText={t("settings.troubleshooting.upload.help")}
/>
<FancyToggleSwitch
value={trace}
onChange={setTrace}
label={t("settings.troubleshooting.trace.label")}
helpText={t("settings.troubleshooting.trace.help")}
/>
<FancyToggleSwitch
value={capture}
onChange={setCapture}
label={t("settings.troubleshooting.capture.label")}
helpText={t("settings.troubleshooting.capture.help")}
/>
<div className={"flex flex-col gap-4"}>
<FancyToggleSwitch
value={capturePackets}
onChange={setCapturePackets}
label={t("settings.troubleshooting.packets.label")}
helpText={t("settings.troubleshooting.packets.help")}
disabled={!capture}
/>
<div
className={"flex items-center justify-between gap-6"}
{...(capture ? {} : { inert: "" })}
>
<div className={"max-w-md flex-1"}>
<Label htmlFor={durationId} disabled={!capture}>
{t("settings.troubleshooting.duration.label")}
</Label>
<HelpText margin={false} disabled={!capture}>
{t("settings.troubleshooting.duration.help")}
</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
id={durationId}
type={"number"}
min={1}
max={30}
value={traceMinutes}
onChange={(e) =>
setTraceMinutes(
Math.max(1, Math.min(30, Number(e.target.value) || 1)),
)
}
customSuffix={t("settings.troubleshooting.duration.suffix")}
disabled={!capture}
/>
</div>
</div>
</div>
<SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={run}>
{t("settings.troubleshooting.create")}
</Button>
</SettingsBottomBar>
</SectionGroup>
);
}
function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
return (
<div
className={
"absolute inset-0 flex flex-col items-center justify-center gap-5 p-8 text-center"
}
>
{children}
</div>
);
}
function ProgressSection({
stage,
onCancel,
}: Readonly<{ stage: DebugStage; onCancel: () => void }>) {
const { t } = useTranslation();
const cancelling = stage.kind === "cancelling";
return (
<CenteredPanel>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex max-w-sm flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading>
<DialogDescription>
{t("settings.troubleshooting.progress.description")}
</DialogDescription>
</div>
{stage.kind === "capturing" && (
<div
className={
"font-mono text-2xl font-semibold tabular-nums tracking-wider text-nb-gray-50"
}
aria-live={"polite"}
>
{formatRemaining(stage.remainingSec)}
</div>
)}
<DialogActions className={"max-w-[220px]"}>
<Button
autoFocus
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={onCancel}
disabled={cancelling}
>
{t("common.cancel")}
</Button>
</DialogActions>
</CenteredPanel>
);
}
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;
DebugSvc.RevealFile(result.path).catch((err: unknown) =>
console.error("reveal debug bundle file", err),
);
};
return (
<CenteredPanel>
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
<div className={"flex max-w-sm flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{showKey
? t("settings.troubleshooting.done.uploadedTitle")
: t("settings.troubleshooting.done.savedTitle")}
</DialogHeading>
<DialogDescription>
{showKey ? (
<Trans
i18nKey={"settings.troubleshooting.done.uploadedDescription"}
components={{
docs: (
<a
href={SUPPORT_DOCS_URL}
aria-label={t("settings.about.community.documentation")}
onClick={(e) => {
e.preventDefault();
Browser.OpenURL(SUPPORT_DOCS_URL).catch(() =>
globalThis.open(SUPPORT_DOCS_URL, "_blank"),
);
}}
className={"text-netbird hover:underline"}
>
{/* content is provided by <Trans> */}
<span />
</a>
),
}}
/>
) : (
t("settings.troubleshooting.done.savedDescription")
)}
</DialogDescription>
</div>
<div className={"flex w-full max-w-sm flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
customSuffix={
<button
type={"button"}
onClick={onRevealPath}
className={"pointer-events-auto transition-all hover:text-white"}
aria-label={t("settings.troubleshooting.done.openFileLocation")}
>
<FolderOpen size={16} aria-hidden={"true"} />
</button>
}
/>
)}
{uploadFailed && (
<div
role={"alert"}
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
}
>
{result.uploadFailureReason
? t("settings.troubleshooting.uploadFailedWithReason", {
reason: result.uploadFailureReason,
})
: t("settings.troubleshooting.uploadFailed")}
</div>
)}
</div>
<DialogActions className={"max-w-[220px]"}>
{showKey ? (
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
copy={result.uploadedKey}
>
{t("settings.troubleshooting.done.copyKey")}
</Button>
) : (
result.path && (
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={onRevealPath}
>
<FolderOpen size={14} aria-hidden={"true"} />
{t("settings.troubleshooting.done.openFolder")}
</Button>
)
)}
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}>
{t("common.close")}
</Button>
</DialogActions>
</CenteredPanel>
);
}
const stageLabel = (
stage: DebugStage,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
switch (stage.kind) {
case "reconnecting":
return t("settings.troubleshooting.stage.reconnecting");
case "capturing":
return t("settings.troubleshooting.stage.capturing");
case "bundling":
return t("settings.troubleshooting.stage.bundling");
case "uploading":
return t("settings.troubleshooting.stage.uploading");
case "cancelling":
return t("settings.troubleshooting.stage.cancelling");
default:
return "";
}
};