mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-12 03:39:55 +00:00
update troubleshooting
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
client/ui-wails/frontend/src/components/StatusPanel.tsx
Normal file
48
client/ui-wails/frontend/src/components/StatusPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user