update debug bundle setting page

This commit is contained in:
Eduard Gert
2026-06-12 12:09:10 +02:00
parent f8ccbb07bb
commit 860be01ebe
18 changed files with 431 additions and 277 deletions

View File

@@ -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,
)}
>

View File

@@ -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 (

View File

@@ -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,

View 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");
}
}

View File

@@ -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));

View File

@@ -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>

View File

@@ -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={

View File

@@ -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":