update troubleshooting

This commit is contained in:
Eduard Gert
2026-05-08 17:18:25 +02:00
parent 3953fee5a4
commit 4c3d4effbd
12 changed files with 479 additions and 325 deletions

View File

@@ -1,12 +1,14 @@
import { cva, VariantProps } from "class-variance-authority";
import classNames from "classnames";
import { ButtonHTMLAttributes, forwardRef } from "react";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
export type ButtonVariants = VariantProps<typeof buttonVariants>;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
}
export const buttonVariants = cva(
@@ -84,7 +86,7 @@ export const buttonVariants = cva(
],
},
size: {
xs: "text-xs py-2 px-4",
xs: "text-xs py-2 px-3.5",
xs2: "text-[0.78rem] py-2 px-4",
sm: "text-sm py-[9px] px-4",
md: "text-md py-[9px] px-4",
@@ -115,10 +117,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
className,
onClick,
disabled,
copy,
...props
},
ref,
) {
const [copied, setCopied] = useState(false);
const iconSize = size === "xs" ? 12 : 14;
return (
<button
ref={ref}
@@ -135,10 +140,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
if (copy !== undefined) {
void navigator.clipboard
.writeText(copy)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}
onClick?.(e);
}}
{...props}
>
{copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
</button>
);

View File

@@ -1,21 +1,12 @@
import { cva, VariantProps } from "class-variance-authority";
import { ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react";
import {
forwardRef,
InputHTMLAttributes,
ReactNode,
useId,
useRef,
useState,
} from "react";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Label } from "@/components/Label";
type InputVariants = VariantProps<typeof inputVariants>;
export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>,
InputVariants {
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
label?: string;
customPrefix?: ReactNode;
customSuffix?: ReactNode;
@@ -24,6 +15,7 @@ export interface InputProps
error?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
}
const inputVariants = cva("", {
@@ -46,9 +38,7 @@ const inputVariants = cva("", {
default: [
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
],
error: [
"dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500",
],
error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"],
},
},
});
@@ -66,19 +56,20 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
variant = "default",
prefixClassName,
showPasswordToggle = false,
copy = false,
id,
...props
},
ref,
) {
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPassword ? "text" : type;
const isNumber = type === "number";
const reactId = useId();
const inputId =
id ?? (label ? `input-${reactId}` : undefined);
const inputId = id ?? (label ? `input-${reactId}` : undefined);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
@@ -118,7 +109,30 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</button>
) : null;
const suffix = passwordToggle || customSuffix;
const onCopy = async () => {
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
const copyToggle = copy ? (
<button
type="button"
onClick={onCopy}
className="hover:text-white transition-all pointer-events-auto"
aria-label="Copy"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
) : null;
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
return (
@@ -129,9 +143,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<div
className={cn(
inputVariants({
prefixSuffixVariant: error
? "error"
: "default",
prefixSuffixVariant: error ? "error" : "default",
}),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
@@ -173,7 +185,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
"!bg-nb-gray-910 text-nb-gray-400 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { Check, Loader2, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "loading" | "success" | "error";
type Props = {
variant: Variant;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
};
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
loading: {
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
className: "bg-nb-gray-100",
},
success: {
icon: <Check className={"text-white"} size={18} />,
className: "bg-green-500",
},
error: {
icon: <XCircle className={"text-white"} size={18} />,
className: "bg-red-500",
},
};
export function StatusPanel({ variant, title, description, children, actions }: Props) {
const { icon, className } = VARIANTS[variant];
return (
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
{icon}
</div>
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
</div>
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
</div>
);
}

View File

@@ -1,16 +1,19 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
import { AutoUpdate } from "@/modules/auto-update/AutoUpdate.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
export const AppLayout = () => {
return (
<ProfileProvider>
<div className={"relative flex h-full flex-col"}>
<Header />
<Outlet />
<AutoUpdate />
</div>
<DebugBundleProvider>
<div className={"relative flex h-full flex-col"}>
<Header />
<Outlet />
<AutoUpdate />
</div>
</DebugBundleProvider>
</ProfileProvider>
);
};

View File

@@ -0,0 +1,16 @@
import { createContext, type ReactNode } from "react";
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
export const DebugBundleContext =
createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
};

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
@@ -6,7 +6,7 @@ import {
import type { DebugBundleResult } from "@bindings/services/models.js";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://debug.netbird.io/upload";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
const TRACE_LOG_FILE_COUNT = 5;
const PLAIN_LOG_FILE_COUNT = 1;
@@ -18,19 +18,40 @@ export type DebugStage =
| { kind: "restoring-level" }
| { kind: "bundling" }
| { kind: "uploading" }
| { kind: "cancelling" }
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
| { kind: "error"; message: string };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("aborted", "AbortError"));
return;
}
const onAbort = () => {
clearTimeout(id);
reject(new DOMException("aborted", "AbortError"));
};
const id = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
export const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
const [anonymize, setAnonymize] = useState(true);
const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(false);
const [trace, setTrace] = useState(false);
const [traceMinutes, setTraceMinutes] = useState(3);
const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true);
const [traceMinutes, setTraceMinutes] = useState(1);
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
const [lastBundlePath, setLastBundlePath] = useState<string>("");
const abortRef = useRef<AbortController | null>(null);
const isRunning =
stage.kind !== "idle" &&
@@ -39,10 +60,26 @@ export const useDebugBundle = () => {
const reset = () => setStage({ kind: "idle" });
const cancel = () => {
if (!abortRef.current || abortRef.current.signal.aborted) return;
abortRef.current.abort();
setStage({ kind: "cancelling" });
};
const run = async () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const checkAbort = () => {
if (signal.aborted)
throw new DOMException("aborted", "AbortError");
};
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
let originalLevel = "info";
let raisedLevel = false;
try {
let originalLevel = "info";
if (trace) {
setStage({ kind: "preparing-trace" });
try {
@@ -51,14 +88,18 @@ export const useDebugBundle = () => {
} catch {
// best effort
}
checkAbort();
await DebugSvc.SetLogLevel({ level: "trace" });
raisedLevel = true;
checkAbort();
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// already down
}
checkAbort();
await ConnectionSvc.Up({
profileName: activeProfile,
username,
@@ -72,17 +113,19 @@ export const useDebugBundle = () => {
remainingSec: remaining,
totalSec,
});
await sleep(1000);
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
raisedLevel = false;
} catch {
// restore is best-effort
}
}
checkAbort();
setStage({ kind: "bundling" });
const logFileCount = trace
? TRACE_LOG_FILE_COUNT
@@ -95,16 +138,36 @@ export const useDebugBundle = () => {
uploadUrl,
logFileCount,
});
checkAbort();
if (result.path) setLastBundlePath(result.path);
setStage({
kind: "done",
result,
uploadAttempted: Boolean(uploadUrl),
});
} catch (e) {
if (isAbort(e)) {
if (raisedLevel) {
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
} catch {
// best effort
}
}
setStage({ kind: "idle" });
return;
}
setStage({ kind: "error", message: String(e) });
} finally {
if (abortRef.current === ctrl) abortRef.current = null;
}
};
const openBundleDir = () => {
if (!lastBundlePath) return;
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
};
return {
anonymize,
setAnonymize,
@@ -118,7 +181,10 @@ export const useDebugBundle = () => {
setTraceMinutes,
stage,
isRunning,
lastBundlePath,
run,
cancel,
reset,
openBundleDir,
};
};

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx";
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error(
"useDebugBundleContext must be used inside DebugBundleProvider",
);
}
return ctx;
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import Button from "@/components/Button";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
@@ -9,7 +9,7 @@ import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export function SettingsAdvanced() {
const { config, saveFields } = useSettings();
const [draft, setDraft] = useState({
const [values, setValues] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
@@ -17,27 +17,17 @@ export function SettingsAdvanced() {
});
const [saving, setSaving] = useState(false);
// Re-sync the draft when the underlying config changes from elsewhere (e.g. reload).
useEffect(() => {
setDraft({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
const isDirty =
draft.interfaceName !== config.interfaceName ||
draft.wireguardPort !== config.wireguardPort ||
draft.mtu !== config.mtu ||
draft.preSharedKey !== config.preSharedKey;
const hasChanges =
values.interfaceName !== config.interfaceName ||
values.wireguardPort !== config.wireguardPort ||
values.mtu !== config.mtu ||
values.preSharedKey !== config.preSharedKey;
const handleSave = async () => {
if (!isDirty || saving) return;
if (!hasChanges || saving) return;
setSaving(true);
try {
await saveFields(draft);
await saveFields(values);
} finally {
setSaving(false);
}
@@ -48,17 +38,19 @@ export function SettingsAdvanced() {
<SectionGroup title={"Interface"}>
<Input
label={"Name"}
value={draft.interfaceName}
onChange={(e) => setDraft((d) => ({ ...d, interfaceName: e.target.value }))}
value={values.interfaceName}
onChange={(e) =>
setValues((v) => ({ ...v, interfaceName: e.target.value }))
}
/>
<div className={"grid grid-cols-2 gap-4"}>
<Input
label={"Port"}
type={"number"}
value={draft.wireguardPort}
value={values.wireguardPort}
onChange={(e) =>
setDraft((d) => ({
...d,
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
@@ -66,12 +58,9 @@ export function SettingsAdvanced() {
<Input
label={"MTU"}
type={"number"}
value={draft.mtu}
value={values.mtu}
onChange={(e) =>
setDraft((d) => ({
...d,
mtu: Number(e.target.value),
}))
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
}
/>
</div>
@@ -82,19 +71,16 @@ export function SettingsAdvanced() {
<Label as={"div"}>Pre-shared Key</Label>
<HelpText>
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
NetBird Setup Key. Set the same value on every peer, otherwise they can't
connect to each other.
NetBird Setup Key. You will only communicate with peers that use the same
pre-shared key.
</HelpText>
<Input
type={"password"}
showPasswordToggle
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={draft.preSharedKey}
value={values.preSharedKey}
onChange={(e) =>
setDraft((d) => ({
...d,
preSharedKey: e.target.value,
}))
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
}
/>
</div>
@@ -105,7 +91,7 @@ export function SettingsAdvanced() {
<Button
variant={"primary"}
size={"md"}
disabled={!isDirty || saving}
disabled={!hasChanges || saving}
onClick={handleSave}
>
Save Changes

View File

@@ -62,9 +62,15 @@ const useSettingsState = () => {
const save = useCallback(
async (next: Config) => {
// The daemon masks an existing PSK as "**********" in GetConfig.
// Sending the mask back round-trips it into the saved config and
// wgtypes.ParseKey fails on the next connect. Drop the mask so
// unrelated toggles don't corrupt the stored PSK.
const { preSharedKey, ...rest } = next;
try {
await SettingsSvc.SetConfig({
...next,
...rest,
...(preSharedKey === "**********" ? {} : { preSharedKey }),
profileName: activeProfile,
username,
});

View File

@@ -10,7 +10,7 @@ export const SectionGroup = ({
children: ReactNode;
disabled?: boolean;
}) => (
<section className={cn("mb-8 px-1", disabled && "opacity-50 pointer-events-none")}>
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title}
</h2>

View File

@@ -1,20 +1,19 @@
import { useState } from "react";
import { Browser } from "@wailsio/runtime";
import { Check, Copy, FolderOpen, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { FolderOpen } from "lucide-react";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import HelpText from "@/components/HelpText.tsx";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { StatusPanel } from "@/components/StatusPanel";
import { cn } from "@/lib/cn";
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import {
useDebugBundle,
type DebugStage,
} from "@/modules/settings/useDebugBundle.ts";
export function SettingsTroubleshooting() {
const bundle = useDebugBundle();
const {
anonymize,
setAnonymize,
@@ -26,163 +25,249 @@ export function SettingsTroubleshooting() {
setTrace,
traceMinutes,
setTraceMinutes,
stage,
isRunning,
run,
stage,
cancel,
reset,
} = bundle;
} = useDebugBundleContext();
if (stage.kind === "done" || stage.kind === "error") {
return <ResultSection stage={stage} onClose={reset} />;
}
if (stage.kind !== "idle") {
return <ProgressSection stage={stage} onCancel={cancel} />;
}
return (
<SectionGroup title={"Debug bundle"}>
<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>
<HelpText className={"-mt-2 mb-2"}>
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
.zip file with logs, system details and debug information from your device.
</HelpText>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={"Anonymize personal data"}
helpText={
"Replace IPs, hostnames, and peer names before saving."
}
disabled={isRunning}
label={"Anonymize Sensitive Information"}
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
/>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}
label={"Include system info"}
helpText={
"Include OS, kernel, network interfaces, and routing tables."
}
disabled={isRunning}
label={"Include System Information"}
helpText={"Include OS, kernel, network interfaces, and routing tables."}
/>
<FancyToggleSwitch
value={upload}
onChange={setUpload}
label={"Send to NetBird support"}
label={"Upload Bundle to NetBird Servers"}
helpText={
"Uploads the bundle directly. You'll get a key to share with us."
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
}
disabled={isRunning}
/>
<FancyToggleSwitch
value={trace}
onChange={setTrace}
label={"Capture detailed (trace) logs"}
label={"Capture Trace Logs"}
helpText={
"Restart NetBird with extra logging for a few minutes, then create the bundle. NetBird will briefly disconnect."
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
}
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
className={cn(
"flex items-center gap-6 justify-between",
!trace && "opacity-50 pointer-events-none",
)}
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>Capture Duration</Label>
<HelpText margin={false}>
How long to capture trace logs before generating the bundle.
</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
type={"number"}
min={1}
max={30}
value={traceMinutes}
onChange={(e) =>
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
}
customSuffix={"Minutes"}
disabled={!trace}
/>
</div>
</div>
<BundleStatus stage={stage} />
<BottomBar>
<Button variant={"primary"} size={"md"} onClick={run}>
Create Bundle
</Button>
</BottomBar>
</SectionGroup>
);
}
function BundleStatus({ stage }: { stage: DebugStage }) {
if (stage.kind === "idle") return null;
if (
stage.kind === "preparing-trace" ||
stage.kind === "reconnecting" ||
stage.kind === "capturing" ||
stage.kind === "restoring-level" ||
stage.kind === "bundling" ||
stage.kind === "uploading"
) {
return (
<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 ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
const cancelling = stage.kind === "cancelling";
return (
<StatusPanel
variant={"loading"}
title={stageLabel(stage)}
description={
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
}
actions={
<Button
variant={"secondary"}
size={"xs"}
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? "Cancelling…" : "Cancel"}
</Button>
}
/>
);
}
function stageLabel(stage: DebugStage): string {
function ResultSection({
stage,
onClose,
}: {
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
onClose: () => void;
}) {
if (stage.kind === "error") {
return (
<StatusPanel
variant={"error"}
title={"Bundle failed"}
description={stage.message}
actions={
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
Close
</Button>
}
/>
);
}
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
}
function DoneResult({
result,
uploaded,
onClose,
}: {
result: DebugBundleResult;
uploaded: boolean;
onClose: () => void;
}) {
const showKey = uploaded && Boolean(result.uploadedKey);
const uploadFailed = uploaded && !result.uploadedKey;
const onRevealPath = () => {
if (!result.path) return;
void DebugSvc.RevealFile(result.path).catch(() => {});
};
return (
<StatusPanel
variant={"success"}
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
description={
showKey
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
: "Your debug bundle has been saved locally."
}
actions={
<>
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
Close
</Button>
{showKey ? (
<Button
variant={"primary"}
size={"xs"}
copy={result.uploadedKey}
>
Copy Key
</Button>
) : (
result.path && (
<Button
variant={"primary"}
size={"xs"}
onClick={onRevealPath}
>
<FolderOpen size={12} />
Open Folder
</Button>
)
)}
</>
}
>
<div className={"w-full max-w-xs mx-auto flex flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{result.path && (
<Input
value={result.path}
readOnly
customSuffix={
<button
type={"button"}
onClick={onRevealPath}
className={"pointer-events-auto hover:text-white transition-all"}
aria-label={"Open file location"}
>
<FolderOpen size={16} />
</button>
}
/>
)}
{uploadFailed && (
<div
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
}
>
Upload failed
{result.uploadFailureReason
? `: ${result.uploadFailureReason}`
: "."}{" "}
The bundle is still saved locally.
</div>
)}
</div>
</StatusPanel>
);
}
function BottomBar({ children }: { children: ReactNode }) {
return (
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"w-full flex justify-end gap-3 px-8 py-5 border-t border-nb-gray-900 bg-nb-gray-935"
}
>
{children}
</div>
</div>
);
}
const stageLabel = (stage: DebugStage): string => {
switch (stage.kind) {
case "preparing-trace":
return "Switching to trace logging…";
case "reconnecting":
return "Reconnecting NetBird…";
case "capturing": {
const fmt = (s: number) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
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)}`;
@@ -190,131 +275,12 @@ function stageLabel(stage: DebugStage): string {
case "restoring-level":
return "Restoring previous log level…";
case "bundling":
return "Building bundle";
return "Generating debug bundle…";
case "uploading":
return "Uploading to NetBird…";
case "cancelling":
return "Cancelling…";
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>
);
}
};

View File

@@ -4,6 +4,10 @@ package services
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"runtime"
"github.com/netbirdio/netbird/client/proto"
)
@@ -74,6 +78,25 @@ func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
return LogLevel{Level: resp.GetLevel().String()}, nil
}
// RevealFile opens the OS file manager focused on the given path. Wails'
// Browser.OpenURL refuses non-http(s) schemes, so the UI calls this binding
// instead of constructing a file:// URL.
func (s *Debug) RevealFile(_ context.Context, path string) error {
if path == "" {
return fmt.Errorf("empty path")
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", "-R", path)
case "windows":
cmd = exec.Command("explorer", "/select,"+path)
default:
cmd = exec.Command("xdg-open", filepath.Dir(path))
}
return cmd.Start()
}
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
cli, err := s.conn.Client()
if err != nil {