update settings nav

This commit is contained in:
Eduard Gert
2026-05-07 12:40:04 +02:00
parent a2be41caf8
commit 062a183e4e
13 changed files with 686 additions and 132 deletions

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { Button } from "@/components/Button";
import { useStatus } from "@/hooks/useStatus";
import { Update as UpdateSvc } from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
export const AutoUpdate = () => {
const { status } = useStatus();
const [dismissed, setDismissed] = useState(false);
if (import.meta.env.DEV) return null;
const updateVersion = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
if (!updateVersion || dismissed) return null;
const triggerUpdate = () => {
UpdateSvc.Trigger().catch(() => {});
};
return (
<div
className={
"absolute bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border border-nb-gray-800 bg-nb-gray-920/95 backdrop-blur px-4 py-2.5 shadow-lg"
}
>
<p className={"text-sm text-nb-gray-100 pr-2"}>
NetBird will update when you restart the app.
</p>
<Button
variant={"secondary"}
size={"xs"}
onClick={() => setDismissed(true)}
>
Later
</Button>
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
Restart now
</Button>
</div>
);
};
export default AutoUpdate;

View File

@@ -4,8 +4,14 @@ import {
Settings as SettingsSvc,
Profiles as ProfilesSvc,
Update as UpdateSvc,
Debug as DebugSvc,
Connection as ConnectionSvc,
} 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";
import type {
Config,
DebugBundleResult,
} from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Check, Copy, FolderOpen, Loader2 } from "lucide-react";
import netbirdAppIcon from "@/assets/logos/netbird-app-icon.svg";
import pkg from "../../../package.json";
import { useStatus } from "@/hooks/useStatus";
@@ -16,10 +22,8 @@ import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { cn } from "@/lib/cn";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import {
SettingsNavigation,
SettingsSection,
} from "@/modules/settings/SettingsNavigation.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
type Ctx = {
cfg: Config;
@@ -62,7 +66,7 @@ const buildPayload = (
});
export const Settings = () => {
const [active, setActive] = useState<SettingsSection>("general");
const [active, setActive] = useState("general");
const [username, setUsername] = useState("");
const [profile, setProfile] = useState("");
const [cfg, setCfg] = useState<Config | null>(null);
@@ -113,10 +117,12 @@ export const Settings = () => {
}, [cfg, saveNow]);
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div className={"flex flex-col w-52 shrink-0 items-center"}>
<SettingsNavigation active={active} onChange={setActive} />
</div>
<VerticalTabs
value={active}
onValueChange={setActive}
className={"wails-draggable p-4"}
>
<SettingsNavigationTriggers />
<MainRightSide>
{error && (
<p className={"px-6 py-2 text-sm text-red-500"}>{error}</p>
@@ -128,40 +134,45 @@ export const Settings = () => {
</div>
) : (
<div className={"px-6 py-5"}>
{active === "general" && (
<VerticalTabs.Content value={"general"}>
<GeneralSection
cfg={cfg}
setField={setField}
onSaveServer={saveNow}
/>
)}
{active === "network" && (
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<NetworkSection cfg={cfg} setField={setField} />
)}
{active === "security" && (
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SecuritySection
cfg={cfg}
setField={setField}
/>
)}
{active === "ssh" && (
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SshSection cfg={cfg} setField={setField} />
)}
{active === "advanced" && (
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<AdvancedSection
cfg={cfg}
setField={setField}
/>
)}
{active === "troubleshooting" && (
<TroubleshootingSection />
)}
{active === "about" && <AboutSection />}
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<TroubleshootingSection
profile={profile}
username={username}
/>
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<AboutSection />
</VerticalTabs.Content>
</div>
)}
</div>
</MainRightSide>
</div>
</VerticalTabs>
);
};
@@ -488,16 +499,396 @@ function AdvancedSection({ cfg, setField }: Ctx) {
);
}
function TroubleshootingSection() {
const NETBIRD_UPLOAD_URL = "https://debug.netbird.io/upload";
const TRACE_LOG_FILE_COUNT = 5;
const PLAIN_LOG_FILE_COUNT = 1;
type Stage =
| { 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));
function TroubleshootingSection({
profile,
username,
}: {
profile: string;
username: string;
}) {
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<Stage>({ 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 (
<SectionGroup title={"Debug bundle"}>
<p className={"text-sm text-nb-gray-400"}>
Debug bundle creation is not yet wired up in this view.
<p className={"text-sm text-nb-gray-300 mb-2"}>
A debug bundle helps NetBird support investigate connection
problems. It's a zip file with logs and system details from
this device.
</p>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={"Anonymize personal data"}
helpText={
"Replace IPs, hostnames, and peer names before saving."
}
disabled={isRunning}
/>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}
label={"Include system info"}
helpText={
"Include OS, kernel, network interfaces, and routing tables."
}
disabled={isRunning}
/>
<FancyToggleSwitch
value={upload}
onChange={setUpload}
label={"Send to NetBird support"}
helpText={
"Uploads the bundle directly. You'll get a key to share with us."
}
disabled={isRunning}
/>
<FancyToggleSwitch
value={trace}
onChange={setTrace}
label={"Capture detailed (trace) logs"}
helpText={
"Restart NetBird with extra logging for a few minutes, then create the bundle. NetBird will briefly disconnect."
}
disabled={isRunning}
>
<div className={"flex items-center gap-3 max-w-sm"}>
<Label as={"div"} className={"!mb-0"}>
Capture for
</Label>
<div className={"w-24"}>
<Input
type={"number"}
min={1}
max={30}
value={traceMinutes}
onChange={(e) =>
setTraceMinutes(
Math.max(
1,
Math.min(
30,
Number(e.target.value) || 1,
),
),
)
}
customSuffix={
<span className={"text-nb-gray-400"}>min</span>
}
disabled={isRunning}
/>
</div>
</div>
</FancyToggleSwitch>
<div className={"flex items-center gap-3 mt-2"}>
<Button
variant={"primary"}
size={"md"}
onClick={run}
disabled={isRunning}
>
{isRunning ? "Creating bundle" : "Create bundle"}
</Button>
{stage.kind === "error" && (
<Button
variant={"secondary"}
size={"md"}
onClick={reset}
>
Try again
</Button>
)}
</div>
<BundleStatus stage={stage} />
</SectionGroup>
);
}
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 (
<div
className={
"mt-4 flex items-center gap-3 rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-3"
}
>
<Loader2
className={"animate-spin text-netbird shrink-0"}
size={18}
/>
<p className={"text-sm text-nb-gray-200"}>
{stageLabel(stage)}
</p>
</div>
);
}
if (stage.kind === "error") {
return (
<div
className={
"mt-4 rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300"
}
>
{stage.message}
</div>
);
}
return <BundleResult result={stage.result} uploaded={stage.uploadAttempted} />;
}
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 (
<div className={"mt-4 flex flex-col gap-3"}>
{uploaded && result.uploadedKey && (
<div
className={
"rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-4"
}
>
<p className={"text-sm font-medium mb-1"}>
Bundle uploaded
</p>
<p className={"text-xs text-nb-gray-400 mb-3"}>
Share this key with NetBird support so they can find
your bundle.
</p>
<CopyableValue value={result.uploadedKey} mono large />
</div>
)}
{uploadFailed && (
<div
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300"
}
>
Upload failed
{result.uploadFailureReason
? `: ${result.uploadFailureReason}`
: "."}{" "}
The bundle is still saved locally.
</div>
)}
{result.path && (
<div
className={
"rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-3"
}
>
<p className={"text-xs text-nb-gray-400 mb-2"}>
{uploaded && result.uploadedKey
? "A local copy was also saved at:"
: "Bundle saved to:"}
</p>
<CopyableValue value={result.path} mono />
<p className={"text-xs text-nb-gray-500 mt-2"}>
You may need admin privileges to open this file.
</p>
</div>
)}
</div>
);
}
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 (
<div className={"flex items-center gap-2"}>
<code
className={cn(
"flex-1 min-w-0 truncate rounded bg-nb-gray-900 px-3 py-2 border border-nb-gray-800",
mono && "font-mono",
large ? "text-sm" : "text-xs",
)}
>
{value}
</code>
<button
type={"button"}
onClick={onCopy}
className={
"p-2 rounded-md border border-nb-gray-800 text-nb-gray-300 hover:text-white hover:bg-nb-gray-900"
}
aria-label={"Copy"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
{value.startsWith("/") || value.match(/^[A-Za-z]:\\/) ? (
<button
type={"button"}
onClick={onReveal}
className={
"p-2 rounded-md border border-nb-gray-800 text-nb-gray-300 hover:text-white hover:bg-nb-gray-900"
}
aria-label={"Reveal"}
>
<FolderOpen size={14} />
</button>
) : null}
</div>
);
}
const LEGAL_LINKS: { label: string; url: string }[] = [
{ label: "Imprint", url: "https://netbird.io/imprint" },
{ label: "Privacy", url: "https://netbird.io/privacy" },

View File

@@ -1,59 +0,0 @@
import { NavItem } from "@/components/NavItem.tsx";
import {
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
TerminalIcon,
WrenchIcon,
} from "lucide-react";
export type SettingsSection =
| "general"
| "network"
| "security"
| "ssh"
| "advanced"
| "troubleshooting"
| "about";
type Props = {
active: SettingsSection;
onChange: (section: SettingsSection) => void;
};
const ITEMS: {
id: SettingsSection;
icon: typeof SlidersHorizontalIcon;
title: string;
}[] = [
{ id: "general", icon: SlidersHorizontalIcon, title: "General" },
{ id: "network", icon: NetworkIcon, title: "Network" },
{ id: "security", icon: ShieldIcon, title: "Security" },
{ id: "ssh", icon: TerminalIcon, title: "SSH" },
{ id: "advanced", icon: WrenchIcon, title: "Advanced" },
{ id: "troubleshooting", icon: LifeBuoyIcon, title: "Troubleshooting" },
{ id: "about", icon: InfoIcon, title: "About" },
];
export const SettingsNavigation = ({ active, onChange }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1"}>
{ITEMS.map(({ id, icon, title }) => (
<NavItem
key={id}
icon={icon}
title={title}
iconSize={14}
iconBackground={false}
className={"py-2.5"}
active={active === id}
onClick={() => {
if (active !== id) onChange(id);
}}
/>
))}
</nav>
);
};

View File

@@ -0,0 +1,54 @@
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import {
BoltIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center"}>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={"General"}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={"Network"}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={"Security"}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={"SSH"}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={"Advanced"}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={"Troubleshooting"}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={"About"}
/>
</VerticalTabs.List>
</div>
);
};