({ kind: "idle" });
-
- const isRunning =
- stage.kind !== "idle" &&
- stage.kind !== "done" &&
- stage.kind !== "error";
-
- const reset = () => setStage({ kind: "idle" });
-
- const run = async () => {
- const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
- try {
- let originalLevel = "info";
- if (trace) {
- setStage({ kind: "preparing-trace" });
- try {
- const cur = await DebugSvc.GetLogLevel();
- if (cur?.level) originalLevel = cur.level;
- } catch {
- // best effort
- }
- await DebugSvc.SetLogLevel({ level: "trace" });
-
- setStage({ kind: "reconnecting" });
- try {
- await ConnectionSvc.Down();
- } catch {
- // already down
- }
- await ConnectionSvc.Up({ profileName: profile, 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);
- }
-
- setStage({ kind: "restoring-level" });
- try {
- await DebugSvc.SetLogLevel({ level: originalLevel });
- } catch {
- // restore is best-effort
- }
- }
-
- setStage({ kind: "bundling" });
- const logFileCount = trace
- ? TRACE_LOG_FILE_COUNT
- : PLAIN_LOG_FILE_COUNT;
-
- if (uploadUrl) setStage({ kind: "uploading" });
- const result = await DebugSvc.Bundle({
- anonymize,
- systemInfo,
- uploadUrl,
- logFileCount,
- });
- setStage({
- kind: "done",
- result,
- uploadAttempted: Boolean(uploadUrl),
- });
- } catch (e) {
- setStage({ kind: "error", message: String(e) });
- }
- };
-
- return (
-
-
- A debug bundle helps NetBird support investigate connection
- problems. It's a zip file with logs and system details from
- this device.
-
-
-
-
-
-
-
-
- Capture for
-
-
-
- setTraceMinutes(
- Math.max(
- 1,
- Math.min(
- 30,
- Number(e.target.value) || 1,
- ),
- ),
- )
- }
- customSuffix={
- min
- }
- disabled={isRunning}
- />
-
-
-
-
-
-
- {isRunning ? "Creating bundle…" : "Create bundle"}
-
- {stage.kind === "error" && (
-
- Try again
-
- )}
-
-
-
-
- );
-}
-
-function BundleStatus({ stage }: { stage: Stage }) {
- if (stage.kind === "idle") return null;
-
- if (
- stage.kind === "preparing-trace" ||
- stage.kind === "reconnecting" ||
- stage.kind === "capturing" ||
- stage.kind === "restoring-level" ||
- stage.kind === "bundling" ||
- stage.kind === "uploading"
- ) {
- return (
-
-
-
- {stageLabel(stage)}
-
-
- );
- }
-
- if (stage.kind === "error") {
- return (
-
- {stage.message}
-
- );
- }
-
- return ;
-}
-
-function stageLabel(stage: Stage): string {
- switch (stage.kind) {
- case "preparing-trace":
- return "Switching to trace logging…";
- case "reconnecting":
- return "Reconnecting NetBird…";
- 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)}`;
- }
- case "restoring-level":
- return "Restoring previous log level…";
- case "bundling":
- return "Building bundle…";
- case "uploading":
- return "Uploading to NetBird…";
- default:
- return "";
- }
-}
-
-function BundleResult({
- result,
- uploaded,
-}: {
- result: DebugBundleResult;
- uploaded: boolean;
-}) {
- const uploadFailed = uploaded && !result.uploadedKey;
- return (
-
- {uploaded && result.uploadedKey && (
-
-
- Bundle uploaded
-
-
- Share this key with NetBird support so they can find
- your bundle.
-
-
-
- )}
-
- {uploadFailed && (
-
- Upload failed
- {result.uploadFailureReason
- ? `: ${result.uploadFailureReason}`
- : "."}{" "}
- The bundle is still saved locally.
-
- )}
-
- {result.path && (
-
-
- {uploaded && result.uploadedKey
- ? "A local copy was also saved at:"
- : "Bundle saved to:"}
-
-
-
- You may need admin privileges to open this file.
-
-
- )}
-
- );
-}
-
-function CopyableValue({
- value,
- mono = false,
- large = false,
-}: {
- value: string;
- mono?: boolean;
- large?: boolean;
-}) {
- const [copied, setCopied] = useState(false);
- const onCopy = async () => {
- try {
- await navigator.clipboard.writeText(value);
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- } catch {
- // ignore
- }
- };
- const onReveal = () => {
- void Browser.OpenURL(`file://${value}`).catch(() => {});
- };
- return (
-
-
- {value}
-
-
- {copied ? : }
-
- {value.startsWith("/") || value.match(/^[A-Za-z]:\\/) ? (
-
-
-
- ) : null}
-
- );
-}
-
-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"));
-}
-
-function AboutSection() {
- const { status } = useStatus();
- const guiVersion = pkg.version;
- const daemonVersion = status?.daemonVersion ?? "—";
-
- const updateVersion = (status?.events ?? [])
- .map((e) => e.metadata?.["new_version_available"])
- .find((v): v is string => Boolean(v));
-
- const triggerUpdate = () => {
- UpdateSvc.Trigger().catch(() => {});
- };
-
- return (
-
-
-
-
-
NetBird
-
-
GUI v{guiVersion}
-
Client v{daemonVersion}
-
-
-
-
- {updateVersion && (
-
-
-
- Version {updateVersion} is available.
-
-
- openUrl(
- `https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`,
- )
- }
- className={"text-xs text-netbird hover:underline"}
- >
- What's new?
-
-
-
- Restart now
-
-
- )}
-
-
- © {new Date().getFullYear()} NetBird. All Rights Reserved.
-
-
- {LEGAL_LINKS.map((link, i) => (
-
- {i > 0 && (
-
- ·
-
- )}
- openUrl(link.url)}
- className={"hover:text-nb-gray-200 transition"}
- >
- {link.label}
-
-
- ))}
-
-
- );
-}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsAbout.tsx
new file mode 100644
index 000000000..4274312c9
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsAbout.tsx
@@ -0,0 +1,113 @@
+import { Browser } from "@wailsio/runtime";
+import { Update as UpdateSvc } from "@bindings/services";
+import netbirdAppIcon from "@/assets/logos/netbird-app-icon.svg";
+import pkg from "../../../package.json";
+import { useStatus } from "@/hooks/useStatus";
+import { Button } from "@/components/Button";
+
+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 { status } = useStatus();
+ const guiVersion = pkg.version;
+ const daemonVersion = status?.daemonVersion ?? "—";
+
+ const updateVersion = (status?.events ?? [])
+ .map((e) => e.metadata?.["new_version_available"])
+ .find((v): v is string => Boolean(v));
+
+ const triggerUpdate = () => {
+ UpdateSvc.Trigger().catch(() => {});
+ };
+
+ return (
+
+
+
+
+
NetBird
+
+
GUI v{guiVersion}
+
Client v{daemonVersion}
+
+
+
+
+ {updateVersion && (
+
+
+
+ Version {updateVersion} is available.
+
+
+ openUrl(
+ `https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`,
+ )
+ }
+ className={"text-xs text-netbird hover:underline"}
+ >
+ What's new?
+
+
+
+ Restart now
+
+
+ )}
+
+
+ © {new Date().getFullYear()} NetBird. All Rights Reserved.
+
+
+ {LEGAL_LINKS.map((link, i) => (
+
+ {i > 0 && (
+
+ ·
+
+ )}
+ openUrl(link.url)}
+ className={"hover:text-nb-gray-200 transition"}
+ >
+ {link.label}
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx
new file mode 100644
index 000000000..86fb1bae1
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx
@@ -0,0 +1,57 @@
+import { HelpText } from "@/components/HelpText";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
+import { useSettings } from "@/modules/settings/SettingsContext.tsx";
+
+export function SettingsAdvanced() {
+ const { config, setField } = useSettings();
+ return (
+ <>
+
+
+ Pre-shared key
+
+ Optional WireGuard pre-shared key for an extra layer of
+ symmetric encryption. Must match the value configured
+ on every peer in the network.
+
+
+ setField("preSharedKey", e.target.value)
+ }
+ />
+
+
+
+
+ setField("interfaceName", e.target.value)}
+ />
+
+
+ setField("wireguardPort", Number(e.target.value))
+ }
+ />
+
+ setField("mtu", Number(e.target.value))
+ }
+ />
+
+
+ >
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx
new file mode 100644
index 000000000..db9bfa68a
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx
@@ -0,0 +1,127 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Settings as SettingsSvc } from "@bindings/services";
+import type { Config } from "@bindings/services/models.js";
+import { useProfile } from "@/modules/profile/ProfileContext.tsx";
+
+const SAVE_DEBOUNCE_MS = 400;
+
+type SettingsContextValue = {
+ config: Config;
+ setField: (k: K, v: Config[K]) => void;
+ saveNow: () => Promise;
+};
+
+const SettingsContext = createContext(null);
+
+export const useSettings = () => {
+ const ctx = useContext(SettingsContext);
+ if (!ctx) {
+ throw new Error("useSettings must be used inside SettingsProvider");
+ }
+ return ctx;
+};
+
+const useSettingsState = () => {
+ const { username, activeProfile, loaded: profileLoaded } = useProfile();
+ const [config, setConfig] = useState(null);
+ const [error, setError] = useState(null);
+ const saveTimer = useRef | null>(null);
+
+ useEffect(() => {
+ if (!profileLoaded || !activeProfile) return;
+ (async () => {
+ try {
+ const c = await SettingsSvc.GetConfig({
+ profileName: activeProfile,
+ username,
+ });
+ setConfig(c);
+ setError(null);
+ } catch (e) {
+ setError(String(e));
+ }
+ })();
+ }, [profileLoaded, activeProfile, username]);
+
+ useEffect(
+ () => () => {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ },
+ [],
+ );
+
+ const save = useCallback(
+ async (next: Config) => {
+ try {
+ await SettingsSvc.SetConfig({
+ ...next,
+ profileName: activeProfile,
+ username,
+ });
+ setError(null);
+ } catch (e) {
+ setError(String(e));
+ }
+ },
+ [activeProfile, username],
+ );
+
+ const setField = useCallback(
+ (k: K, v: Config[K]) => {
+ setConfig((c) => {
+ if (!c) return c;
+ const next = { ...c, [k]: v };
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => {
+ void save(next);
+ }, SAVE_DEBOUNCE_MS);
+ return next;
+ });
+ },
+ [save],
+ );
+
+ const saveNow = useCallback(async () => {
+ if (!config) return;
+ if (saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = null;
+ }
+ await save(config);
+ }, [config, save]);
+
+ return { config, error, setField, saveNow };
+};
+
+export const SettingsProvider = ({ children }: { children: ReactNode }) => {
+ const { config, error, setField, saveNow } = useSettingsState();
+
+ return (
+ <>
+ {error && (
+ {error}
+ )}
+
+ {!config ? (
+
+ Loading…
+
+ ) : (
+
+ {children}
+
+ )}
+
+ >
+ );
+};
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx
new file mode 100644
index 000000000..e0ae9e8e9
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx
@@ -0,0 +1,60 @@
+import { Button } from "@/components/Button";
+import FancyToggleSwitch from "@/components/FancyToggleSwitch";
+import { HelpText } from "@/components/HelpText";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
+import { useSettings } from "@/modules/settings/SettingsContext.tsx";
+
+export function SettingsGeneral() {
+ const { config, setField, saveNow } = useSettings();
+ return (
+ <>
+
+ setField("disableAutoConnect", !v)}
+ label={"Connect on startup"}
+ helpText={
+ "Automatically connect to NetBird when the app launches."
+ }
+ />
+ setField("disableNotifications", !v)}
+ label={"Show notifications"}
+ helpText={
+ "Show desktop notifications for connection events and updates."
+ }
+ />
+
+
+
+
+
Management Server
+
+ The NetBird management server this client connects to.
+ Saving will reconnect to apply the new server.
+
+
+
+
+ setField("managementUrl", e.target.value)
+ }
+ />
+
+
saveNow()}
+ >
+ Save
+
+
+
+
+ >
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx
index 195512fc5..269be8827 100644
--- a/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx
@@ -11,7 +11,7 @@ import {
export const SettingsNavigationTriggers = () => {
return (
-
+
+
+ setField("lazyConnectionEnabled", v)}
+ label={"Lazy connections"}
+ helpText={
+ "Only establish peer tunnels on first traffic instead of eagerly at startup."
+ }
+ />
+ setField("networkMonitor", v)}
+ label={"Network monitor"}
+ helpText={
+ "Reconnect automatically when the host network changes (Wi-Fi switch, VPN, sleep/wake)."
+ }
+ />
+
+
+
+ setField("disableDns", !v)}
+ label={"Enable DNS"}
+ helpText={
+ "Apply NetBird-managed DNS settings to the host resolver."
+ }
+ />
+ setField("disableClientRoutes", !v)}
+ label={"Enable client routes"}
+ helpText={
+ "Accept routes advertised by other peers so this client can reach their networks."
+ }
+ />
+ setField("disableServerRoutes", !v)}
+ label={"Enable server routes"}
+ helpText={
+ "Advertise this host's local routes to other peers."
+ }
+ />
+
+ >
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx
new file mode 100644
index 000000000..95cd593ff
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx
@@ -0,0 +1,108 @@
+import FancyToggleSwitch from "@/components/FancyToggleSwitch";
+import { HelpText } from "@/components/HelpText";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import { cn } from "@/lib/cn";
+import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
+import { useSettings } from "@/modules/settings/SettingsContext.tsx";
+
+export function SettingsSSH() {
+ const { config, setField } = useSettings();
+ const sshOff = !config.serverSshAllowed;
+ return (
+ <>
+
+ setField("serverSshAllowed", v)}
+ label={"Allow SSH"}
+ helpText={
+ "Run the NetBird SSH server on this host so other peers can connect to it."
+ }
+ />
+
+
+
+ setField("enableSshRoot", v)}
+ label={"Allow root login"}
+ helpText={
+ "Permit incoming SSH sessions to authenticate as root."
+ }
+ disabled={sshOff}
+ />
+ setField("enableSshSftp", v)}
+ label={"Enable SFTP"}
+ helpText={"Allow file transfers over the NetBird SSH server."}
+ disabled={sshOff}
+ />
+ setField("enableSshLocalPortForwarding", v)}
+ label={"Local port forwarding"}
+ helpText={
+ "Allow clients to forward local ports through this host."
+ }
+ disabled={sshOff}
+ />
+
+ setField("enableSshRemotePortForwarding", v)
+ }
+ label={"Remote port forwarding"}
+ helpText={
+ "Allow clients to expose remote ports back through this host."
+ }
+ disabled={sshOff}
+ />
+
+
+
+ setField("disableSshAuth", v)}
+ label={"Disable SSH auth"}
+ helpText={
+ "Skip JWT authentication for incoming SSH sessions. Insecure — diagnostics only."
+ }
+ disabled={sshOff}
+ />
+
+
+ JWT cache TTL
+
+ How long verified JWTs are cached before
+ re-validation. Shorter values increase load on the
+ management server; longer values delay revocation.
+
+
+
+
+ setField(
+ "sshJwtCacheTtl",
+ Number(e.target.value),
+ )
+ }
+ customSuffix={
+ s
+ }
+ disabled={sshOff}
+ />
+
+
+
+ >
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx
new file mode 100644
index 000000000..c961af55f
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+export const SectionGroup = ({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) => (
+
+
+ {title}
+
+ {children}
+
+);
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSecurity.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSecurity.tsx
new file mode 100644
index 000000000..14930ec76
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsSecurity.tsx
@@ -0,0 +1,49 @@
+import FancyToggleSwitch from "@/components/FancyToggleSwitch";
+import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
+import { useSettings } from "@/modules/settings/SettingsContext.tsx";
+
+export function SettingsSecurity() {
+ const { config, setField } = useSettings();
+ return (
+ <>
+
+ setField("blockInbound", v)}
+ label={"Block inbound traffic"}
+ helpText={
+ "Drop all unsolicited inbound traffic on the NetBird interface."
+ }
+ />
+ setField("blockLanAccess", v)}
+ label={"Block LAN access"}
+ helpText={
+ "Prevent peers from reaching this host's local network."
+ }
+ />
+
+
+
+ setField("rosenpassEnabled", v)}
+ label={"Quantum-resistant encryption"}
+ helpText={
+ "Add a post-quantum key exchange (Rosenpass) on top of WireGuard."
+ }
+ >
+ setField("rosenpassPermissive", v)}
+ label={"Permissive mode"}
+ helpText={
+ "Allow connections to peers without quantum-resistance support."
+ }
+ />
+
+
+ >
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsTroubleshooting.tsx
new file mode 100644
index 000000000..25e9b18a9
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsTroubleshooting.tsx
@@ -0,0 +1,320 @@
+import { useState } from "react";
+import { Browser } from "@wailsio/runtime";
+import { Check, Copy, FolderOpen, Loader2 } from "lucide-react";
+import type { DebugBundleResult } from "@bindings/services/models.js";
+import { Button } from "@/components/Button";
+import FancyToggleSwitch from "@/components/FancyToggleSwitch";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import { cn } from "@/lib/cn";
+import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
+import {
+ useDebugBundle,
+ type DebugStage,
+} from "@/modules/settings/useDebugBundle.ts";
+
+export function SettingsTroubleshooting() {
+ const bundle = useDebugBundle();
+ const {
+ anonymize,
+ setAnonymize,
+ systemInfo,
+ setSystemInfo,
+ upload,
+ setUpload,
+ trace,
+ setTrace,
+ traceMinutes,
+ setTraceMinutes,
+ stage,
+ isRunning,
+ run,
+ reset,
+ } = bundle;
+
+ return (
+
+
+ A debug bundle helps NetBird support investigate connection
+ problems. It's a zip file with logs and system details from
+ this device.
+
+
+
+
+
+
+
+
+ Capture for
+
+
+
+ setTraceMinutes(
+ Math.max(
+ 1,
+ Math.min(
+ 30,
+ Number(e.target.value) || 1,
+ ),
+ ),
+ )
+ }
+ customSuffix={
+ min
+ }
+ disabled={isRunning}
+ />
+
+
+
+
+
+
+ {isRunning ? "Creating bundle…" : "Create bundle"}
+
+ {stage.kind === "error" && (
+
+ Try again
+
+ )}
+
+
+
+
+ );
+}
+
+function BundleStatus({ stage }: { stage: DebugStage }) {
+ if (stage.kind === "idle") return null;
+
+ if (
+ stage.kind === "preparing-trace" ||
+ stage.kind === "reconnecting" ||
+ stage.kind === "capturing" ||
+ stage.kind === "restoring-level" ||
+ stage.kind === "bundling" ||
+ stage.kind === "uploading"
+ ) {
+ return (
+
+
+
+ {stageLabel(stage)}
+
+
+ );
+ }
+
+ if (stage.kind === "error") {
+ return (
+
+ {stage.message}
+
+ );
+ }
+
+ return ;
+}
+
+function stageLabel(stage: DebugStage): string {
+ switch (stage.kind) {
+ case "preparing-trace":
+ return "Switching to trace logging…";
+ case "reconnecting":
+ return "Reconnecting NetBird…";
+ 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)}`;
+ }
+ case "restoring-level":
+ return "Restoring previous log level…";
+ case "bundling":
+ return "Building bundle…";
+ case "uploading":
+ return "Uploading to NetBird…";
+ default:
+ return "";
+ }
+}
+
+function BundleResult({
+ result,
+ uploaded,
+}: {
+ result: DebugBundleResult;
+ uploaded: boolean;
+}) {
+ const uploadFailed = uploaded && !result.uploadedKey;
+ return (
+
+ {uploaded && result.uploadedKey && (
+
+
+ Bundle uploaded
+
+
+ Share this key with NetBird support so they can find
+ your bundle.
+
+
+
+ )}
+
+ {uploadFailed && (
+
+ Upload failed
+ {result.uploadFailureReason
+ ? `: ${result.uploadFailureReason}`
+ : "."}{" "}
+ The bundle is still saved locally.
+
+ )}
+
+ {result.path && (
+
+
+ {uploaded && result.uploadedKey
+ ? "A local copy was also saved at:"
+ : "Bundle saved to:"}
+
+
+
+ You may need admin privileges to open this file.
+
+
+ )}
+
+ );
+}
+
+function CopyableValue({
+ value,
+ mono = false,
+ large = false,
+}: {
+ value: string;
+ mono?: boolean;
+ large?: boolean;
+}) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // ignore
+ }
+ };
+ const onReveal = () => {
+ void Browser.OpenURL(`file://${value}`).catch(() => {});
+ };
+ return (
+
+
+ {value}
+
+
+ {copied ? : }
+
+ {value.startsWith("/") || value.match(/^[A-Za-z]:\\/) ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts b/client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts
new file mode 100644
index 000000000..a5ffc93ec
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts
@@ -0,0 +1,124 @@
+import { useState } from "react";
+import {
+ Connection as ConnectionSvc,
+ Debug as DebugSvc,
+} from "@bindings/services";
+import type { DebugBundleResult } from "@bindings/services/models.js";
+import { useProfile } from "@/modules/profile/ProfileContext.tsx";
+
+const NETBIRD_UPLOAD_URL = "https://debug.netbird.io/upload";
+const TRACE_LOG_FILE_COUNT = 5;
+const PLAIN_LOG_FILE_COUNT = 1;
+
+export type DebugStage =
+ | { kind: "idle" }
+ | { kind: "preparing-trace" }
+ | { kind: "reconnecting" }
+ | { kind: "capturing"; remainingSec: number; totalSec: number }
+ | { kind: "restoring-level" }
+ | { kind: "bundling" }
+ | { kind: "uploading" }
+ | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
+ | { kind: "error"; message: string };
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+export const useDebugBundle = () => {
+ const { activeProfile, username } = useProfile();
+ const [anonymize, setAnonymize] = useState(true);
+ const [systemInfo, setSystemInfo] = useState(true);
+ const [upload, setUpload] = useState(false);
+ const [trace, setTrace] = useState(false);
+ const [traceMinutes, setTraceMinutes] = useState(3);
+ const [stage, setStage] = useState({ kind: "idle" });
+
+ const isRunning =
+ stage.kind !== "idle" &&
+ stage.kind !== "done" &&
+ stage.kind !== "error";
+
+ const reset = () => setStage({ kind: "idle" });
+
+ const run = async () => {
+ const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
+ try {
+ let originalLevel = "info";
+ if (trace) {
+ setStage({ kind: "preparing-trace" });
+ try {
+ const cur = await DebugSvc.GetLogLevel();
+ if (cur?.level) originalLevel = cur.level;
+ } catch {
+ // best effort
+ }
+ await DebugSvc.SetLogLevel({ level: "trace" });
+
+ setStage({ kind: "reconnecting" });
+ try {
+ await ConnectionSvc.Down();
+ } catch {
+ // already down
+ }
+ 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);
+ }
+
+ setStage({ kind: "restoring-level" });
+ try {
+ await DebugSvc.SetLogLevel({ level: originalLevel });
+ } catch {
+ // restore is best-effort
+ }
+ }
+
+ setStage({ kind: "bundling" });
+ const logFileCount = trace
+ ? TRACE_LOG_FILE_COUNT
+ : PLAIN_LOG_FILE_COUNT;
+
+ if (uploadUrl) setStage({ kind: "uploading" });
+ const result = await DebugSvc.Bundle({
+ anonymize,
+ systemInfo,
+ uploadUrl,
+ logFileCount,
+ });
+ setStage({
+ kind: "done",
+ result,
+ uploadAttempted: Boolean(uploadUrl),
+ });
+ } catch (e) {
+ setStage({ kind: "error", message: String(e) });
+ }
+ };
+
+ return {
+ anonymize,
+ setAnonymize,
+ systemInfo,
+ setSystemInfo,
+ upload,
+ setUpload,
+ trace,
+ setTrace,
+ traceMinutes,
+ setTraceMinutes,
+ stage,
+ isRunning,
+ run,
+ reset,
+ };
+};
diff --git a/client/ui-wails/frontend/src/screens/Debug.tsx b/client/ui-wails/frontend/src/screens/Debug.tsx
index 929e4325f..bff36c245 100644
--- a/client/ui-wails/frontend/src/screens/Debug.tsx
+++ b/client/ui-wails/frontend/src/screens/Debug.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
-import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+import { Debug as DebugSvc } from "@bindings/services";
+import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
diff --git a/client/ui-wails/frontend/src/screens/Networks.tsx b/client/ui-wails/frontend/src/screens/Networks.tsx
index ea3bd055e..0c64f506b 100644
--- a/client/ui-wails/frontend/src/screens/Networks.tsx
+++ b/client/ui-wails/frontend/src/screens/Networks.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
-import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
-import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+import { Networks as NetworksSvc } from "@bindings/services";
+import type { Network } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Tabs } from "../components/Tabs";
diff --git a/client/ui-wails/frontend/src/screens/Peers.tsx b/client/ui-wails/frontend/src/screens/Peers.tsx
index f1522ca87..327b6ac38 100644
--- a/client/ui-wails/frontend/src/screens/Peers.tsx
+++ b/client/ui-wails/frontend/src/screens/Peers.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
-import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+import type { PeerStatus } from "@bindings/services/models.js";
import { Card } from "../components/Card";
import { Input } from "../components/Input";
import { cn } from "../lib/cn";
diff --git a/client/ui-wails/frontend/src/screens/Profiles.tsx b/client/ui-wails/frontend/src/screens/Profiles.tsx
index 3a1035afa..5c9204480 100644
--- a/client/ui-wails/frontend/src/screens/Profiles.tsx
+++ b/client/ui-wails/frontend/src/screens/Profiles.tsx
@@ -3,23 +3,23 @@ import { Plus, RefreshCw } from "lucide-react";
import {
Profiles as ProfilesSvc,
Connection,
-} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
-import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+} from "@bindings/services";
+import type { Profile } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Card } from "../components/Card";
+import { useProfile } from "@/modules/profile/ProfileContext.tsx";
export default function Profiles() {
- const [username, setUsername] = useState("");
+ const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile();
const [profiles, setProfiles] = useState([]);
const [error, setError] = useState(null);
const [adding, setAdding] = useState(false);
const refresh = useCallback(async () => {
+ if (!username) return;
try {
- const u = username || (await ProfilesSvc.Username());
- if (!username) setUsername(u);
- const list = await ProfilesSvc.List(u);
+ const list = await ProfilesSvc.List(username);
setProfiles(list);
setError(null);
} catch (e) {
@@ -28,12 +28,12 @@ export default function Profiles() {
}, [username]);
useEffect(() => {
- refresh();
- }, [refresh]);
+ if (loaded) refresh();
+ }, [loaded, refresh]);
const select = async (name: string) => {
try {
- await ProfilesSvc.Switch({ profileName: name, username });
+ await switchProfile(name);
await Connection.Up({ profileName: name, username });
await refresh();
} catch (e) {
@@ -54,6 +54,7 @@ export default function Profiles() {
if (name === "default") return;
try {
await ProfilesSvc.Remove({ profileName: name, username });
+ await refreshProfile();
await refresh();
} catch (e) {
setError(String(e));
diff --git a/client/ui-wails/frontend/src/screens/QuickActions.tsx b/client/ui-wails/frontend/src/screens/QuickActions.tsx
index 749b4bf8a..1f867b154 100644
--- a/client/ui-wails/frontend/src/screens/QuickActions.tsx
+++ b/client/ui-wails/frontend/src/screens/QuickActions.tsx
@@ -1,6 +1,6 @@
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
-import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
+import { Connection } from "@bindings/services";
import { Button } from "../components/Button";
import { cn } from "../lib/cn";
diff --git a/client/ui-wails/frontend/src/screens/Settings.tsx b/client/ui-wails/frontend/src/screens/Settings.tsx
index 3781611b6..f4729e3d7 100644
--- a/client/ui-wails/frontend/src/screens/Settings.tsx
+++ b/client/ui-wails/frontend/src/screens/Settings.tsx
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import {
Settings as SettingsSvc,
Profiles as ProfilesSvc,
-} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
-import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+} from "@bindings/services";
+import type { Config } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
diff --git a/client/ui-wails/frontend/src/screens/Status.tsx b/client/ui-wails/frontend/src/screens/Status.tsx
index a666f755c..e844a1b8d 100644
--- a/client/ui-wails/frontend/src/screens/Status.tsx
+++ b/client/ui-wails/frontend/src/screens/Status.tsx
@@ -1,7 +1,7 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
-import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
-import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
+import { Connection } from "@bindings/services";
+import type { SystemEvent } from "@bindings/services/models.js";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { cn } from "../lib/cn";
diff --git a/client/ui-wails/frontend/src/screens/Update.tsx b/client/ui-wails/frontend/src/screens/Update.tsx
index 04d9eb245..ed7473503 100644
--- a/client/ui-wails/frontend/src/screens/Update.tsx
+++ b/client/ui-wails/frontend/src/screens/Update.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
-import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
+import { Update as UpdateSvc } from "@bindings/services";
const TIMEOUT_MS = 15 * 60 * 1000;
diff --git a/client/ui-wails/frontend/tsconfig.json b/client/ui-wails/frontend/tsconfig.json
index ad912d829..8b9974133 100644
--- a/client/ui-wails/frontend/tsconfig.json
+++ b/client/ui-wails/frontend/tsconfig.json
@@ -22,7 +22,8 @@
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
- "@/*": ["src/*"]
+ "@/*": ["src/*"],
+ "@bindings/*": ["bindings/github.com/netbirdio/netbird/client/ui-wails/*"]
}
},
"include": ["src", "bindings"],
diff --git a/client/ui-wails/frontend/vite.config.ts b/client/ui-wails/frontend/vite.config.ts
index 2df5ee457..02b41be2c 100644
--- a/client/ui-wails/frontend/vite.config.ts
+++ b/client/ui-wails/frontend/vite.config.ts
@@ -8,6 +8,10 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
+ "@bindings": path.resolve(
+ __dirname,
+ "./bindings/github.com/netbirdio/netbird/client/ui-wails",
+ ),
},
},
plugins: [react(), wails("./bindings")],