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

@@ -17,6 +17,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@wailsio/runtime": "latest",
"chroma-js": "^3.2.0",

View File

@@ -23,6 +23,9 @@ dependencies:
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
@@ -1131,6 +1134,33 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1):
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:

View File

@@ -121,13 +121,32 @@ Everything you reach for when something is wrong.
### Debug bundle
- **Anonymize** — *toggle switch*
- Strip IPs, hostnames, and peer names from the bundle before saving.
- **Include system info** — *toggle switch*
- Add OS, kernel, and network interface details to the bundle.
- **Upload on create** — *toggle switch*
- When on, reveals an upload URL field and uploads the bundle after creation.
- **Create Bundle** — *button* → progress indicator → resulting path or upload URL.
Friendly intro line on top: *"A debug bundle helps NetBird support investigate connection problems. It's a zip file with logs and system details from this device."*
Toggle rows:
- **Anonymize personal data** — `anonymize` · *toggle switch* · default **on**
- Replace IPs, hostnames, and peer names before saving.
- **Include system info** — `systemInfo` · *toggle switch* · default **on**
- Include OS, kernel, network interfaces, and routing tables.
- **Send to NetBird support** — *toggle switch* · default **off**
- Uploads the bundle to a hardcoded NetBird endpoint (`NETBIRD_UPLOAD_URL` constant). On success the user gets a short upload key to share with support. Local copy is always kept too.
- **Capture detailed (trace) logs** — *toggle switch* · default **off**
- Nested *Capture for [N] minutes* number input (130, suffix "min", default 3).
- When enabled, the daemon's log level is switched to trace, NetBird is brought down and back up, the UI captures for the configured duration, the original log level is restored, then the bundle is created with `logFileCount: 5` (vs 1 in plain mode).
- User-facing warning baked into the help text: "NetBird will briefly disconnect."
**Create bundle** — primary button. Disabled while running. Shows "Creating bundle…" label.
### Status / result block
Renders below the button while running and after completion.
- **Running** — bordered card with spinner + stage text. Stages: *Switching to trace logging…**Reconnecting NetBird…**Capturing logs — m:ss / m:ss* (countdown) → *Restoring previous log level…**Building bundle…**Uploading to NetBird…* (last only when upload toggle on; trace stages skipped when trace off).
- **Done — uploaded**: bordered card with the upload key in a copyable code block + "Share this key with NetBird support so they can find your bundle.". Below, a smaller card with the local path + Copy + Reveal (file://) buttons + admin-privilege note.
- **Done — local only**: single card with "Bundle saved to:" + path + Copy + Reveal + admin note.
- **Partial — upload failed**: red banner ("Upload failed: <reason>. The bundle is still saved locally.") above the local path card.
- **Error** (no bundle produced): red banner with the error message + a **Try again** button next to Create.
---

View File

@@ -9,18 +9,16 @@ type Props = HTMLMotionProps<"button"> & {
description?: string;
active?: boolean;
iconSize?: number;
iconBackground?: boolean;
};
export const NavItem = forwardRef<HTMLButtonElement, Props>(
function NavItem(
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
function CardNavItem(
{
icon: Icon,
title,
description,
active = false,
iconSize = 15,
iconBackground = true,
className,
type = "button",
...props
@@ -35,40 +33,26 @@ export const NavItem = forwardRef<HTMLButtonElement, Props>(
className={cn(
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
active
? "bg-nb-gray-930"
: "hover:bg-nb-gray-940",
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
className,
)}
{...props}
>
{iconBackground ? (
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active
? "text-nb-gray-200"
: "text-nb-gray-400",
)}
/>
</div>
) : (
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
)}
</div>
<div className={"min-w-0"}>
<h2
className={cn(

View File

@@ -0,0 +1,87 @@
import { ComponentType, forwardRef } from "react";
import * as Tabs from "@radix-ui/react-tabs";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
const Root = forwardRef<
HTMLDivElement,
Omit<Tabs.TabsProps, "orientation">
>(function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.Root
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0 gap-4", className)}
{...props}
/>
);
});
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
function VerticalTabsList({ className, ...props }, ref) {
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1", className)}
{...props}
/>
);
},
);
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
iconSize?: number;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
</Tabs.Trigger>
);
},
);
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
function VerticalTabsContent({ className, ...props }, ref) {
return (
<Tabs.Content
ref={ref}
className={cn("outline-none", className)}
{...props}
/>
);
},
);
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });

View File

@@ -1,11 +1,13 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
import { AutoUpdate } from "@/modules/auto-update/AutoUpdate.tsx";
export const AppLayout = () => {
return (
<div className={"flex h-full flex-col"}>
<div className={"relative flex h-full flex-col"}>
<Header />
<Outlet />
<AutoUpdate />
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { cn } from "@/lib/cn";
export const Header = () => {
const navigate = useNavigate();
const location = useLocation();
const settingsActive = location.pathname.startsWith("/settings");
const isSettingsPage = location.pathname.startsWith("/settings");
return (
<div
@@ -20,10 +20,10 @@ export const Header = () => {
</div>
<IconButton
icon={SettingsIcon}
onClick={() => navigate(settingsActive ? "/" : "/settings")}
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
className={cn(
settingsActive &&
"bg-nb-gray-930 text-nb-gray-200 hover:text-nb-gray-200",
isSettingsPage &&
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
)}
/>
</div>

View File

@@ -7,7 +7,7 @@ type Props = {
export const MainRightSide = ({ children }: Props) => {
return (
<div
className={"wails-no-draggable flex-1 min-h-0 min-w-0 flex flex-col bg-nb-gray-935 rounded-xl rounded-br-2xl border border-nb-gray-900"}
className={"wails-no-draggable flex-1 min-h-0 min-w-0 flex flex-col bg-nb-gray-935 rounded-xl rounded-br-2xl border border-nb-gray-910"}
>
{children}
</div>

View File

@@ -1,4 +1,4 @@
import { NavItem } from "@/components/NavItem.tsx";
import { CardNavItem } from "@/components/CardNavItem.tsx";
import {
Layers3Icon,
MonitorSmartphoneIcon,
@@ -13,20 +13,20 @@ type Props = {
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
<NavItem
<CardNavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}
description={"13 of 16 Online"}
active={peersActive}
onClick={onPeersClick}
/>
<NavItem
<CardNavItem
icon={Layers3Icon}
title={"Resources"}
description={"13 of 16 Active"}
iconSize={14}
/>
<NavItem
<CardNavItem
icon={SquareArrowUpRight}
title={"Exit Node Berlin"}
description={"192.168..."}

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>
);
};