mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-02 13:01:29 +02:00
update debug bundle setting page
This commit is contained in:
@@ -5,13 +5,15 @@ type Props = {
|
||||
children?: ReactNode;
|
||||
margin?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const HelpText = ({ children, margin = true, className }: Props) => (
|
||||
export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
|
||||
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide transition-all duration-300",
|
||||
margin && "mb-2",
|
||||
disabled && "opacity-30 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -10,13 +10,19 @@ const labelVariants = cva(
|
||||
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & {
|
||||
as?: "label" | "div";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
|
||||
{ className, as = "label", children, ...props },
|
||||
{ className, as = "label", disabled = false, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const classes = cn(labelVariants(), className, "select-none");
|
||||
const classes = cn(
|
||||
labelVariants(),
|
||||
className,
|
||||
"select-none transition-all duration-300",
|
||||
disabled && "opacity-30 pointer-events-none",
|
||||
);
|
||||
|
||||
if (as === "div") {
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/servic
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { startConnection } from "@/lib/connection.ts";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
@@ -56,14 +56,21 @@ const setLogLevelBestEffort = async (level: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
type LevelState = { original: string; raised: boolean };
|
||||
const stopCaptureBestEffort = async () => {
|
||||
try {
|
||||
await DebugSvc.StopBundleCapture();
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
};
|
||||
|
||||
const runTracePhase = async (
|
||||
type LevelState = { original: string; raised: boolean };
|
||||
type CaptureState = { started: boolean };
|
||||
|
||||
const raiseToTrace = async (
|
||||
signal: AbortSignal,
|
||||
level: LevelState,
|
||||
setStage: (s: DebugStage) => void,
|
||||
target: { profileName: string; username: string },
|
||||
traceMinutes: number,
|
||||
) => {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
@@ -75,7 +82,9 @@ const runTracePhase = async (
|
||||
throwIfAborted(signal);
|
||||
await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
|
||||
level.raised = true;
|
||||
};
|
||||
|
||||
const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
@@ -84,14 +93,10 @@ const runTracePhase = async (
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await ConnectionSvc.Up(target);
|
||||
|
||||
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
await startConnection(undefined, signal);
|
||||
};
|
||||
|
||||
const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: level.original });
|
||||
@@ -101,13 +106,25 @@ const runTracePhase = async (
|
||||
}
|
||||
};
|
||||
|
||||
const waitCaptureWindow = async (
|
||||
signal: AbortSignal,
|
||||
setStage: (s: DebugStage) => void,
|
||||
totalSec: number,
|
||||
) => {
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const { activeProfile, username } = useProfile();
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(false);
|
||||
const [trace, setTrace] = useState(true);
|
||||
const [capture, setCapture] = useState(false);
|
||||
const [traceMinutes, setTraceMinutes] = useState(1);
|
||||
const [capturePackets, setCapturePackets] = useState(true);
|
||||
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
||||
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -129,16 +146,43 @@ const useDebugBundle = () => {
|
||||
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
|
||||
const pcap: CaptureState = { started: false };
|
||||
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
const hasWindow = capture && totalSec > 0;
|
||||
|
||||
try {
|
||||
if (trace) {
|
||||
await runTracePhase(
|
||||
signal,
|
||||
level,
|
||||
setStage,
|
||||
{ profileName: activeProfile, username },
|
||||
traceMinutes,
|
||||
);
|
||||
await raiseToTrace(signal, level, setStage);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (capture) {
|
||||
await cycleConnection(signal, setStage);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (hasWindow && capturePackets) {
|
||||
try {
|
||||
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
|
||||
await DebugSvc.StartBundleCapture(totalSec + 30);
|
||||
pcap.started = true;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (hasWindow) {
|
||||
await waitCaptureWindow(signal, setStage, totalSec);
|
||||
}
|
||||
|
||||
if (pcap.started) {
|
||||
await stopCaptureBestEffort();
|
||||
pcap.started = false;
|
||||
}
|
||||
|
||||
if (level.raised) {
|
||||
await restoreLogLevel(level, setStage);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
@@ -161,10 +205,13 @@ const useDebugBundle = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbort(e)) {
|
||||
setStage({ kind: "cancelling" });
|
||||
if (pcap.started) await stopCaptureBestEffort();
|
||||
if (level.raised) await setLogLevelBestEffort(level.original);
|
||||
setStage({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
if (pcap.started) await stopCaptureBestEffort();
|
||||
setStage({ kind: "idle" });
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
@@ -191,8 +238,12 @@ const useDebugBundle = () => {
|
||||
setUpload,
|
||||
trace,
|
||||
setTrace,
|
||||
capture,
|
||||
setCapture,
|
||||
traceMinutes,
|
||||
setTraceMinutes,
|
||||
capturePackets,
|
||||
setCapturePackets,
|
||||
stage,
|
||||
isRunning,
|
||||
lastBundlePath,
|
||||
|
||||
112
client/ui/frontend/src/lib/connection.ts
Normal file
112
client/ui/frontend/src/lib/connection.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
export const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
let connectionInFlight = false;
|
||||
|
||||
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
|
||||
if (connectionInFlight) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
connectionInFlight = true;
|
||||
|
||||
let cancelled = false;
|
||||
let offCancel: (() => void) | undefined;
|
||||
let offSignal: (() => void) | undefined;
|
||||
let connectError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
profileName: "",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
setupKey: "",
|
||||
preSharedKey: "",
|
||||
hostname: "",
|
||||
hint: "",
|
||||
});
|
||||
|
||||
if (signal?.aborted) cancelled = true;
|
||||
|
||||
if (!cancelled && result.needsSsoLogin) {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPromise = new Promise<void>((resolve) => {
|
||||
offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
|
||||
cancelled = true;
|
||||
resolve();
|
||||
});
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
cancelled = true;
|
||||
resolve();
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort);
|
||||
offSignal = () => signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const waitPromise = Connection.WaitSSOLogin({
|
||||
userCode: result.userCode,
|
||||
hostname: "",
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([waitPromise, cancelPromise]);
|
||||
} finally {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled && signal?.aborted) cancelled = true;
|
||||
|
||||
if (!cancelled) {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (!cancelled) connectError = e;
|
||||
} finally {
|
||||
offCancel?.();
|
||||
offSignal?.();
|
||||
connectionInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (connectError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(connectError),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled && signal) {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import {
|
||||
startConnection,
|
||||
EVENT_BROWSER_LOGIN_CANCEL,
|
||||
EVENT_TRIGGER_LOGIN,
|
||||
} from "@/lib/connection.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
@@ -16,89 +20,6 @@ import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-re
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
let loginInFlight = false;
|
||||
|
||||
// onSettled (re-arm guards) must fire before the error dialog, never gated on it:
|
||||
// a hanging dialog would silently drop every later login until restart.
|
||||
async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
if (loginInFlight) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
loginInFlight = true;
|
||||
|
||||
let cancelled = false;
|
||||
let offCancel: (() => void) | undefined;
|
||||
let loginError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
profileName: "",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
setupKey: "",
|
||||
preSharedKey: "",
|
||||
hostname: "",
|
||||
hint: "",
|
||||
});
|
||||
|
||||
if (result.needsSsoLogin) {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPromise = new Promise<void>((resolve) => {
|
||||
offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
|
||||
cancelled = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const waitPromise = Connection.WaitSSOLogin({
|
||||
userCode: result.userCode,
|
||||
hostname: "",
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([waitPromise, cancelPromise]);
|
||||
} finally {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (!cancelled) loginError = e;
|
||||
} finally {
|
||||
offCancel?.();
|
||||
loginInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (loginError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(loginError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
@@ -136,7 +57,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
void startLogin(() => {
|
||||
void startConnection(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
|
||||
|
||||
@@ -52,7 +52,7 @@ export const SettingsPage = () => {
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<div className={"py-6 px-7"}>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SectionGroup = ({
|
||||
|
||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={"h-[4rem] shrink-0"} aria-hidden />
|
||||
<div className={"h-[3.2rem] shrink-0"} aria-hidden />
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -13,7 +13,6 @@ import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
@@ -32,8 +31,12 @@ export function SettingsTroubleshooting() {
|
||||
setUpload,
|
||||
trace,
|
||||
setTrace,
|
||||
capture,
|
||||
setCapture,
|
||||
traceMinutes,
|
||||
setTraceMinutes,
|
||||
capturePackets,
|
||||
setCapturePackets,
|
||||
run,
|
||||
stage,
|
||||
cancel,
|
||||
@@ -51,10 +54,6 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<HelpText className={"-mt-2 mb-2"}>
|
||||
<Trans i18nKey={"settings.troubleshooting.intro"} components={{ br: <br /> }} />
|
||||
</HelpText>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
@@ -79,30 +78,44 @@ export function SettingsTroubleshooting() {
|
||||
label={t("settings.troubleshooting.trace.label")}
|
||||
helpText={t("settings.troubleshooting.trace.help")}
|
||||
/>
|
||||
<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"}>{t("settings.troubleshooting.duration.label")}</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("settings.troubleshooting.duration.help")}
|
||||
</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={t("settings.troubleshooting.duration.suffix")}
|
||||
disabled={!trace}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={capture}
|
||||
onChange={setCapture}
|
||||
label={t("settings.troubleshooting.capture.label")}
|
||||
helpText={t("settings.troubleshooting.capture.help")}
|
||||
/>
|
||||
<div className={"flex flex-col gap-4"}>
|
||||
<FancyToggleSwitch
|
||||
value={capturePackets}
|
||||
onChange={setCapturePackets}
|
||||
label={t("settings.troubleshooting.packets.label")}
|
||||
helpText={t("settings.troubleshooting.packets.help")}
|
||||
disabled={!capture}
|
||||
/>
|
||||
<div className={"flex items-center gap-6 justify-between"}>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"} disabled={!capture}>
|
||||
{t("settings.troubleshooting.duration.label")}
|
||||
</Label>
|
||||
<HelpText margin={false} disabled={!capture}>
|
||||
{t("settings.troubleshooting.duration.help")}
|
||||
</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={t("settings.troubleshooting.duration.suffix")}
|
||||
disabled={!capture}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,14 +310,10 @@ const stageLabel = (
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string => {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return t("settings.troubleshooting.stage.preparingTrace");
|
||||
case "reconnecting":
|
||||
return t("settings.troubleshooting.stage.reconnecting");
|
||||
case "capturing":
|
||||
return t("settings.troubleshooting.stage.capturing");
|
||||
case "restoring-level":
|
||||
return t("settings.troubleshooting.stage.restoring");
|
||||
case "bundling":
|
||||
return t("settings.troubleshooting.stage.bundling");
|
||||
case "uploading":
|
||||
|
||||
Reference in New Issue
Block a user