mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-12 11:49:55 +00:00
update settings nav
This commit is contained in:
@@ -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",
|
||||
|
||||
30
client/ui-wails/frontend/pnpm-lock.yaml
generated
30
client/ui-wails/frontend/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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 (1–30, 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(
|
||||
87
client/ui-wails/frontend/src/components/VerticalTabs.tsx
Normal file
87
client/ui-wails/frontend/src/components/VerticalTabs.tsx
Normal 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 });
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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..."}
|
||||
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user