mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-18 06:39:54 +00:00
update troubleshooting
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import { cva, VariantProps } from "class-variance-authority";
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
import classNames from "classnames";
|
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 type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
stopPropagation?: boolean;
|
stopPropagation?: boolean;
|
||||||
|
copy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
@@ -84,7 +86,7 @@ export const buttonVariants = cva(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
xs: "text-xs py-2 px-4",
|
xs: "text-xs py-2 px-3.5",
|
||||||
xs2: "text-[0.78rem] py-2 px-4",
|
xs2: "text-[0.78rem] py-2 px-4",
|
||||||
sm: "text-sm py-[9px] px-4",
|
sm: "text-sm py-[9px] px-4",
|
||||||
md: "text-md py-[9px] px-4",
|
md: "text-md py-[9px] px-4",
|
||||||
@@ -115,10 +117,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
copy,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const iconSize = size === "xs" ? 12 : 14;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -135,10 +140,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (stopPropagation) e.stopPropagation();
|
if (stopPropagation) e.stopPropagation();
|
||||||
|
if (copy !== undefined) {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(copy)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{copy !== undefined &&
|
||||||
|
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { cva, VariantProps } from "class-variance-authority";
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
import { ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react";
|
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||||
import {
|
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
|
||||||
forwardRef,
|
|
||||||
InputHTMLAttributes,
|
|
||||||
ReactNode,
|
|
||||||
useId,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Label } from "@/components/Label";
|
import { Label } from "@/components/Label";
|
||||||
|
|
||||||
type InputVariants = VariantProps<typeof inputVariants>;
|
type InputVariants = VariantProps<typeof inputVariants>;
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
|
||||||
extends InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
InputVariants {
|
|
||||||
label?: string;
|
label?: string;
|
||||||
customPrefix?: ReactNode;
|
customPrefix?: ReactNode;
|
||||||
customSuffix?: ReactNode;
|
customSuffix?: ReactNode;
|
||||||
@@ -24,6 +15,7 @@ export interface InputProps
|
|||||||
error?: string;
|
error?: string;
|
||||||
prefixClassName?: string;
|
prefixClassName?: string;
|
||||||
showPasswordToggle?: boolean;
|
showPasswordToggle?: boolean;
|
||||||
|
copy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputVariants = cva("", {
|
const inputVariants = cva("", {
|
||||||
@@ -46,9 +38,7 @@ const inputVariants = cva("", {
|
|||||||
default: [
|
default: [
|
||||||
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
|
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
|
||||||
],
|
],
|
||||||
error: [
|
error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"],
|
||||||
"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",
|
variant = "default",
|
||||||
prefixClassName,
|
prefixClassName,
|
||||||
showPasswordToggle = false,
|
showPasswordToggle = false,
|
||||||
|
copy = false,
|
||||||
id,
|
id,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const isPasswordType = type === "password";
|
const isPasswordType = type === "password";
|
||||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||||
const isNumber = type === "number";
|
const isNumber = type === "number";
|
||||||
|
|
||||||
const reactId = useId();
|
const reactId = useId();
|
||||||
const inputId =
|
const inputId = id ?? (label ? `input-${reactId}` : undefined);
|
||||||
id ?? (label ? `input-${reactId}` : undefined);
|
|
||||||
|
|
||||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||||
const setRefs = (el: HTMLInputElement | null) => {
|
const setRefs = (el: HTMLInputElement | null) => {
|
||||||
@@ -118,7 +109,30 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
</button>
|
</button>
|
||||||
) : null;
|
) : 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;
|
const showStepper = isNumber;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,9 +143,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
inputVariants({
|
inputVariants({
|
||||||
prefixSuffixVariant: error
|
prefixSuffixVariant: error ? "error" : "default",
|
||||||
? "error"
|
|
||||||
: "default",
|
|
||||||
}),
|
}),
|
||||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||||
"border items-center whitespace-nowrap",
|
"border items-center whitespace-nowrap",
|
||||||
@@ -173,7 +185,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
icon && "!pl-10",
|
icon && "!pl-10",
|
||||||
"border",
|
"border",
|
||||||
props.readOnly &&
|
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 &&
|
showStepper &&
|
||||||
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||||
className,
|
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 { Outlet } from "react-router-dom";
|
||||||
import { Header } from "@/layouts/Header.tsx";
|
import { Header } from "@/layouts/Header.tsx";
|
||||||
import { AutoUpdate } from "@/modules/auto-update/AutoUpdate.tsx";
|
import { AutoUpdate } from "@/modules/auto-update/AutoUpdate.tsx";
|
||||||
|
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||||
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||||
|
|
||||||
export const AppLayout = () => {
|
export const AppLayout = () => {
|
||||||
return (
|
return (
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
<div className={"relative flex h-full flex-col"}>
|
<DebugBundleProvider>
|
||||||
<Header />
|
<div className={"relative flex h-full flex-col"}>
|
||||||
<Outlet />
|
<Header />
|
||||||
<AutoUpdate />
|
<Outlet />
|
||||||
</div>
|
<AutoUpdate />
|
||||||
|
</div>
|
||||||
|
</DebugBundleProvider>
|
||||||
</ProfileProvider>
|
</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 {
|
import {
|
||||||
Connection as ConnectionSvc,
|
Connection as ConnectionSvc,
|
||||||
Debug as DebugSvc,
|
Debug as DebugSvc,
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
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 TRACE_LOG_FILE_COUNT = 5;
|
||||||
const PLAIN_LOG_FILE_COUNT = 1;
|
const PLAIN_LOG_FILE_COUNT = 1;
|
||||||
|
|
||||||
@@ -18,19 +18,40 @@ export type DebugStage =
|
|||||||
| { kind: "restoring-level" }
|
| { kind: "restoring-level" }
|
||||||
| { kind: "bundling" }
|
| { kind: "bundling" }
|
||||||
| { kind: "uploading" }
|
| { kind: "uploading" }
|
||||||
|
| { kind: "cancelling" }
|
||||||
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
|
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
|
||||||
| { kind: "error"; message: string };
|
| { 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 = () => {
|
export const useDebugBundle = () => {
|
||||||
const { activeProfile, username } = useProfile();
|
const { activeProfile, username } = useProfile();
|
||||||
const [anonymize, setAnonymize] = useState(true);
|
const [anonymize, setAnonymize] = useState(false);
|
||||||
const [systemInfo, setSystemInfo] = useState(true);
|
const [systemInfo, setSystemInfo] = useState(true);
|
||||||
const [upload, setUpload] = useState(false);
|
const [upload, setUpload] = useState(true);
|
||||||
const [trace, setTrace] = useState(false);
|
const [trace, setTrace] = useState(true);
|
||||||
const [traceMinutes, setTraceMinutes] = useState(3);
|
const [traceMinutes, setTraceMinutes] = useState(1);
|
||||||
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
||||||
|
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const isRunning =
|
const isRunning =
|
||||||
stage.kind !== "idle" &&
|
stage.kind !== "idle" &&
|
||||||
@@ -39,10 +60,26 @@ export const useDebugBundle = () => {
|
|||||||
|
|
||||||
const reset = () => setStage({ kind: "idle" });
|
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 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 : "";
|
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||||
|
let originalLevel = "info";
|
||||||
|
let raisedLevel = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let originalLevel = "info";
|
|
||||||
if (trace) {
|
if (trace) {
|
||||||
setStage({ kind: "preparing-trace" });
|
setStage({ kind: "preparing-trace" });
|
||||||
try {
|
try {
|
||||||
@@ -51,14 +88,18 @@ export const useDebugBundle = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// best effort
|
// best effort
|
||||||
}
|
}
|
||||||
|
checkAbort();
|
||||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||||
|
raisedLevel = true;
|
||||||
|
|
||||||
|
checkAbort();
|
||||||
setStage({ kind: "reconnecting" });
|
setStage({ kind: "reconnecting" });
|
||||||
try {
|
try {
|
||||||
await ConnectionSvc.Down();
|
await ConnectionSvc.Down();
|
||||||
} catch {
|
} catch {
|
||||||
// already down
|
// already down
|
||||||
}
|
}
|
||||||
|
checkAbort();
|
||||||
await ConnectionSvc.Up({
|
await ConnectionSvc.Up({
|
||||||
profileName: activeProfile,
|
profileName: activeProfile,
|
||||||
username,
|
username,
|
||||||
@@ -72,17 +113,19 @@ export const useDebugBundle = () => {
|
|||||||
remainingSec: remaining,
|
remainingSec: remaining,
|
||||||
totalSec,
|
totalSec,
|
||||||
});
|
});
|
||||||
await sleep(1000);
|
await sleep(1000, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStage({ kind: "restoring-level" });
|
setStage({ kind: "restoring-level" });
|
||||||
try {
|
try {
|
||||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||||
|
raisedLevel = false;
|
||||||
} catch {
|
} catch {
|
||||||
// restore is best-effort
|
// restore is best-effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkAbort();
|
||||||
setStage({ kind: "bundling" });
|
setStage({ kind: "bundling" });
|
||||||
const logFileCount = trace
|
const logFileCount = trace
|
||||||
? TRACE_LOG_FILE_COUNT
|
? TRACE_LOG_FILE_COUNT
|
||||||
@@ -95,16 +138,36 @@ export const useDebugBundle = () => {
|
|||||||
uploadUrl,
|
uploadUrl,
|
||||||
logFileCount,
|
logFileCount,
|
||||||
});
|
});
|
||||||
|
checkAbort();
|
||||||
|
if (result.path) setLastBundlePath(result.path);
|
||||||
setStage({
|
setStage({
|
||||||
kind: "done",
|
kind: "done",
|
||||||
result,
|
result,
|
||||||
uploadAttempted: Boolean(uploadUrl),
|
uploadAttempted: Boolean(uploadUrl),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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) });
|
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 {
|
return {
|
||||||
anonymize,
|
anonymize,
|
||||||
setAnonymize,
|
setAnonymize,
|
||||||
@@ -118,7 +181,10 @@ export const useDebugBundle = () => {
|
|||||||
setTraceMinutes,
|
setTraceMinutes,
|
||||||
stage,
|
stage,
|
||||||
isRunning,
|
isRunning,
|
||||||
|
lastBundlePath,
|
||||||
run,
|
run,
|
||||||
|
cancel,
|
||||||
reset,
|
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 Button from "@/components/Button";
|
||||||
import { HelpText } from "@/components/HelpText";
|
import { HelpText } from "@/components/HelpText";
|
||||||
import { Input } from "@/components/Input";
|
import { Input } from "@/components/Input";
|
||||||
@@ -9,7 +9,7 @@ import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
|||||||
export function SettingsAdvanced() {
|
export function SettingsAdvanced() {
|
||||||
const { config, saveFields } = useSettings();
|
const { config, saveFields } = useSettings();
|
||||||
|
|
||||||
const [draft, setDraft] = useState({
|
const [values, setValues] = useState({
|
||||||
interfaceName: config.interfaceName,
|
interfaceName: config.interfaceName,
|
||||||
wireguardPort: config.wireguardPort,
|
wireguardPort: config.wireguardPort,
|
||||||
mtu: config.mtu,
|
mtu: config.mtu,
|
||||||
@@ -17,27 +17,17 @@ export function SettingsAdvanced() {
|
|||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Re-sync the draft when the underlying config changes from elsewhere (e.g. reload).
|
const hasChanges =
|
||||||
useEffect(() => {
|
values.interfaceName !== config.interfaceName ||
|
||||||
setDraft({
|
values.wireguardPort !== config.wireguardPort ||
|
||||||
interfaceName: config.interfaceName,
|
values.mtu !== config.mtu ||
|
||||||
wireguardPort: config.wireguardPort,
|
values.preSharedKey !== config.preSharedKey;
|
||||||
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 handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!isDirty || saving) return;
|
if (!hasChanges || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveFields(draft);
|
await saveFields(values);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -48,17 +38,19 @@ export function SettingsAdvanced() {
|
|||||||
<SectionGroup title={"Interface"}>
|
<SectionGroup title={"Interface"}>
|
||||||
<Input
|
<Input
|
||||||
label={"Name"}
|
label={"Name"}
|
||||||
value={draft.interfaceName}
|
value={values.interfaceName}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, interfaceName: e.target.value }))}
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className={"grid grid-cols-2 gap-4"}>
|
<div className={"grid grid-cols-2 gap-4"}>
|
||||||
<Input
|
<Input
|
||||||
label={"Port"}
|
label={"Port"}
|
||||||
type={"number"}
|
type={"number"}
|
||||||
value={draft.wireguardPort}
|
value={values.wireguardPort}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({
|
setValues((v) => ({
|
||||||
...d,
|
...v,
|
||||||
wireguardPort: Number(e.target.value),
|
wireguardPort: Number(e.target.value),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -66,12 +58,9 @@ export function SettingsAdvanced() {
|
|||||||
<Input
|
<Input
|
||||||
label={"MTU"}
|
label={"MTU"}
|
||||||
type={"number"}
|
type={"number"}
|
||||||
value={draft.mtu}
|
value={values.mtu}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({
|
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
|
||||||
...d,
|
|
||||||
mtu: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,19 +71,16 @@ export function SettingsAdvanced() {
|
|||||||
<Label as={"div"}>Pre-shared Key</Label>
|
<Label as={"div"}>Pre-shared Key</Label>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
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
|
NetBird Setup Key. You will only communicate with peers that use the same
|
||||||
connect to each other.
|
pre-shared key.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Input
|
<Input
|
||||||
type={"password"}
|
type={"password"}
|
||||||
showPasswordToggle
|
showPasswordToggle
|
||||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||||
value={draft.preSharedKey}
|
value={values.preSharedKey}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({
|
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
|
||||||
...d,
|
|
||||||
preSharedKey: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +91,7 @@ export function SettingsAdvanced() {
|
|||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
size={"md"}
|
size={"md"}
|
||||||
disabled={!isDirty || saving}
|
disabled={!hasChanges || saving}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -62,9 +62,15 @@ const useSettingsState = () => {
|
|||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (next: Config) => {
|
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 {
|
try {
|
||||||
await SettingsSvc.SetConfig({
|
await SettingsSvc.SetConfig({
|
||||||
...next,
|
...rest,
|
||||||
|
...(preSharedKey === "**********" ? {} : { preSharedKey }),
|
||||||
profileName: activeProfile,
|
profileName: activeProfile,
|
||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const SectionGroup = ({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
disabled?: boolean;
|
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"}>
|
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { useState } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Browser } from "@wailsio/runtime";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { Check, Copy, FolderOpen, Loader2 } from "lucide-react";
|
import { Debug as DebugSvc } from "@bindings/services";
|
||||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import HelpText from "@/components/HelpText.tsx";
|
||||||
import { Input } from "@/components/Input";
|
import { Input } from "@/components/Input";
|
||||||
import { Label } from "@/components/Label";
|
import { Label } from "@/components/Label";
|
||||||
|
import { StatusPanel } from "@/components/StatusPanel";
|
||||||
import { cn } from "@/lib/cn";
|
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 { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
import {
|
|
||||||
useDebugBundle,
|
|
||||||
type DebugStage,
|
|
||||||
} from "@/modules/settings/useDebugBundle.ts";
|
|
||||||
|
|
||||||
export function SettingsTroubleshooting() {
|
export function SettingsTroubleshooting() {
|
||||||
const bundle = useDebugBundle();
|
|
||||||
const {
|
const {
|
||||||
anonymize,
|
anonymize,
|
||||||
setAnonymize,
|
setAnonymize,
|
||||||
@@ -26,163 +25,249 @@ export function SettingsTroubleshooting() {
|
|||||||
setTrace,
|
setTrace,
|
||||||
traceMinutes,
|
traceMinutes,
|
||||||
setTraceMinutes,
|
setTraceMinutes,
|
||||||
stage,
|
|
||||||
isRunning,
|
|
||||||
run,
|
run,
|
||||||
|
stage,
|
||||||
|
cancel,
|
||||||
reset,
|
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 (
|
return (
|
||||||
<SectionGroup title={"Debug bundle"}>
|
<SectionGroup title={"Debug bundle"}>
|
||||||
<p className={"text-sm text-nb-gray-300 mb-2"}>
|
<HelpText className={"-mt-2 mb-2"}>
|
||||||
A debug bundle helps NetBird support investigate connection
|
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
|
||||||
problems. It's a zip file with logs and system details from
|
.zip file with logs, system details and debug information from your device.
|
||||||
this device.
|
</HelpText>
|
||||||
</p>
|
|
||||||
|
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={anonymize}
|
value={anonymize}
|
||||||
onChange={setAnonymize}
|
onChange={setAnonymize}
|
||||||
label={"Anonymize personal data"}
|
label={"Anonymize Sensitive Information"}
|
||||||
helpText={
|
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
|
||||||
"Replace IPs, hostnames, and peer names before saving."
|
|
||||||
}
|
|
||||||
disabled={isRunning}
|
|
||||||
/>
|
/>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={systemInfo}
|
value={systemInfo}
|
||||||
onChange={setSystemInfo}
|
onChange={setSystemInfo}
|
||||||
label={"Include system info"}
|
label={"Include System Information"}
|
||||||
helpText={
|
helpText={"Include OS, kernel, network interfaces, and routing tables."}
|
||||||
"Include OS, kernel, network interfaces, and routing tables."
|
|
||||||
}
|
|
||||||
disabled={isRunning}
|
|
||||||
/>
|
/>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={upload}
|
value={upload}
|
||||||
onChange={setUpload}
|
onChange={setUpload}
|
||||||
label={"Send to NetBird support"}
|
label={"Upload Bundle to NetBird Servers"}
|
||||||
helpText={
|
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
|
<FancyToggleSwitch
|
||||||
value={trace}
|
value={trace}
|
||||||
onChange={setTrace}
|
onChange={setTrace}
|
||||||
label={"Capture detailed (trace) logs"}
|
label={"Capture Trace Logs"}
|
||||||
helpText={
|
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
|
||||||
<div className={"flex items-center gap-3 max-w-sm"}>
|
className={cn(
|
||||||
<Label as={"div"} className={"!mb-0"}>
|
"flex items-center gap-6 justify-between",
|
||||||
Capture for
|
!trace && "opacity-50 pointer-events-none",
|
||||||
</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={"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>
|
</div>
|
||||||
|
|
||||||
<BundleStatus stage={stage} />
|
<BottomBar>
|
||||||
|
<Button variant={"primary"} size={"md"} onClick={run}>
|
||||||
|
Create Bundle
|
||||||
|
</Button>
|
||||||
|
</BottomBar>
|
||||||
</SectionGroup>
|
</SectionGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BundleStatus({ stage }: { stage: DebugStage }) {
|
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||||
if (stage.kind === "idle") return null;
|
const cancelling = stage.kind === "cancelling";
|
||||||
|
return (
|
||||||
if (
|
<StatusPanel
|
||||||
stage.kind === "preparing-trace" ||
|
variant={"loading"}
|
||||||
stage.kind === "reconnecting" ||
|
title={stageLabel(stage)}
|
||||||
stage.kind === "capturing" ||
|
description={
|
||||||
stage.kind === "restoring-level" ||
|
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
|
||||||
stage.kind === "bundling" ||
|
}
|
||||||
stage.kind === "uploading"
|
actions={
|
||||||
) {
|
<Button
|
||||||
return (
|
variant={"secondary"}
|
||||||
<div
|
size={"xs"}
|
||||||
className={
|
onClick={onCancel}
|
||||||
"mt-4 flex items-center gap-3 rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-3"
|
disabled={cancelling}
|
||||||
}
|
>
|
||||||
>
|
{cancelling ? "Cancelling…" : "Cancel"}
|
||||||
<Loader2
|
</Button>
|
||||||
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: 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) {
|
switch (stage.kind) {
|
||||||
case "preparing-trace":
|
case "preparing-trace":
|
||||||
return "Switching to trace logging…";
|
return "Switching to trace logging…";
|
||||||
case "reconnecting":
|
case "reconnecting":
|
||||||
return "Reconnecting NetBird…";
|
return "Reconnecting NetBird…";
|
||||||
case "capturing": {
|
case "capturing": {
|
||||||
const fmt = (s: number) =>
|
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||||
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
||||||
return `Capturing logs — ${fmt(
|
return `Capturing logs — ${fmt(
|
||||||
stage.totalSec - stage.remainingSec,
|
stage.totalSec - stage.remainingSec,
|
||||||
)} / ${fmt(stage.totalSec)}`;
|
)} / ${fmt(stage.totalSec)}`;
|
||||||
@@ -190,131 +275,12 @@ function stageLabel(stage: DebugStage): string {
|
|||||||
case "restoring-level":
|
case "restoring-level":
|
||||||
return "Restoring previous log level…";
|
return "Restoring previous log level…";
|
||||||
case "bundling":
|
case "bundling":
|
||||||
return "Building bundle…";
|
return "Generating debug bundle…";
|
||||||
case "uploading":
|
case "uploading":
|
||||||
return "Uploading to NetBird…";
|
return "Uploading to NetBird…";
|
||||||
|
case "cancelling":
|
||||||
|
return "Cancelling…";
|
||||||
default:
|
default:
|
||||||
return "";
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"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
|
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 {
|
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user