From 062a183e4e2731b58ce3ef624fd4bd2af3679478 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 7 May 2026 12:40:04 +0200 Subject: [PATCH] update settings nav --- client/ui-wails/frontend/package.json | 1 + client/ui-wails/frontend/pnpm-lock.yaml | 30 ++ client/ui-wails/frontend/settings.md | 33 +- .../{NavItem.tsx => CardNavItem.tsx} | 40 +- .../frontend/src/components/VerticalTabs.tsx | 87 ++++ .../frontend/src/layouts/AppLayout.tsx | 4 +- .../ui-wails/frontend/src/layouts/Header.tsx | 8 +- .../frontend/src/layouts/MainRightSide.tsx | 2 +- .../frontend/src/layouts/Navigation.tsx | 8 +- .../src/modules/auto-update/AutoUpdate.tsx | 45 ++ .../src/modules/settings/Settings.tsx | 447 ++++++++++++++++-- .../modules/settings/SettingsNavigation.tsx | 59 --- .../settings/SettingsNavigationTriggers.tsx | 54 +++ 13 files changed, 686 insertions(+), 132 deletions(-) rename client/ui-wails/frontend/src/components/{NavItem.tsx => CardNavItem.tsx} (65%) create mode 100644 client/ui-wails/frontend/src/components/VerticalTabs.tsx create mode 100644 client/ui-wails/frontend/src/modules/auto-update/AutoUpdate.tsx delete mode 100644 client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx create mode 100644 client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx diff --git a/client/ui-wails/frontend/package.json b/client/ui-wails/frontend/package.json index 8e66a4d00..e3ae53e97 100644 --- a/client/ui-wails/frontend/package.json +++ b/client/ui-wails/frontend/package.json @@ -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", diff --git a/client/ui-wails/frontend/pnpm-lock.yaml b/client/ui-wails/frontend/pnpm-lock.yaml index d7383249f..e28fbb174 100644 --- a/client/ui-wails/frontend/pnpm-lock.yaml +++ b/client/ui-wails/frontend/pnpm-lock.yaml @@ -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: diff --git a/client/ui-wails/frontend/settings.md b/client/ui-wails/frontend/settings.md index 6cb80adf0..4df2329f4 100644 --- a/client/ui-wails/frontend/settings.md +++ b/client/ui-wails/frontend/settings.md @@ -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: . 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. --- diff --git a/client/ui-wails/frontend/src/components/NavItem.tsx b/client/ui-wails/frontend/src/components/CardNavItem.tsx similarity index 65% rename from client/ui-wails/frontend/src/components/NavItem.tsx rename to client/ui-wails/frontend/src/components/CardNavItem.tsx index e00030fcb..4885cf8e7 100644 --- a/client/ui-wails/frontend/src/components/NavItem.tsx +++ b/client/ui-wails/frontend/src/components/CardNavItem.tsx @@ -9,18 +9,16 @@ type Props = HTMLMotionProps<"button"> & { description?: string; active?: boolean; iconSize?: number; - iconBackground?: boolean; }; -export const NavItem = forwardRef( - function NavItem( +export const CardNavItem = forwardRef( + function CardNavItem( { icon: Icon, title, description, active = false, iconSize = 15, - iconBackground = true, className, type = "button", ...props @@ -35,40 +33,26 @@ export const NavItem = forwardRef( 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 ? ( -
- -
- ) : ( +
- )} +

+>(function VerticalTabsRoot({ className, ...props }, ref) { + return ( + + ); +}); + +const List = forwardRef( + function VerticalTabsList({ className, ...props }, ref) { + return ( + + ); + }, +); + +type TriggerProps = Tabs.TabsTriggerProps & { + icon: ComponentType; + title: string; + iconSize?: number; +}; + +const Trigger = forwardRef( + function VerticalTabsTrigger( + { icon: Icon, title, iconSize = 16, className, ...props }, + ref, + ) { + return ( + + +

+ {title} +

+
+ ); + }, +); + +const Content = forwardRef( + function VerticalTabsContent({ className, ...props }, ref) { + return ( + + ); + }, +); + +export const VerticalTabs = Object.assign(Root, { List, Trigger, Content }); diff --git a/client/ui-wails/frontend/src/layouts/AppLayout.tsx b/client/ui-wails/frontend/src/layouts/AppLayout.tsx index 4a6ebf07e..be8ec1121 100644 --- a/client/ui-wails/frontend/src/layouts/AppLayout.tsx +++ b/client/ui-wails/frontend/src/layouts/AppLayout.tsx @@ -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 ( -
+
+
); }; diff --git a/client/ui-wails/frontend/src/layouts/Header.tsx b/client/ui-wails/frontend/src/layouts/Header.tsx index 56e1601e9..94f3eeb8f 100644 --- a/client/ui-wails/frontend/src/layouts/Header.tsx +++ b/client/ui-wails/frontend/src/layouts/Header.tsx @@ -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 (
{
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", )} />
diff --git a/client/ui-wails/frontend/src/layouts/MainRightSide.tsx b/client/ui-wails/frontend/src/layouts/MainRightSide.tsx index 7b88f1b31..67877ce71 100644 --- a/client/ui-wails/frontend/src/layouts/MainRightSide.tsx +++ b/client/ui-wails/frontend/src/layouts/MainRightSide.tsx @@ -7,7 +7,7 @@ type Props = { export const MainRightSide = ({ children }: Props) => { return (
{children}
diff --git a/client/ui-wails/frontend/src/layouts/Navigation.tsx b/client/ui-wails/frontend/src/layouts/Navigation.tsx index 3dbad6617..3d8b51cdd 100644 --- a/client/ui-wails/frontend/src/layouts/Navigation.tsx +++ b/client/ui-wails/frontend/src/layouts/Navigation.tsx @@ -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 (

- + ); }; @@ -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({ 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 ( -

- Debug bundle creation is not yet wired up in this view. +

+ A debug bundle helps NetBird support investigate connection + problems. It's a zip file with logs and system details from + this device.

+ + + + + +
+ +
+ + setTraceMinutes( + Math.max( + 1, + Math.min( + 30, + Number(e.target.value) || 1, + ), + ), + ) + } + customSuffix={ + min + } + disabled={isRunning} + /> +
+
+
+ +
+ + {stage.kind === "error" && ( + + )} +
+ +
); } +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} + + + {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" }, diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx deleted file mode 100644 index 0e6be77d2..000000000 --- a/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx new file mode 100644 index 000000000..195512fc5 --- /dev/null +++ b/client/ui-wails/frontend/src/modules/settings/SettingsNavigationTriggers.tsx @@ -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 ( +
+ + + + + + + + + +
+ ); +};