[client] Keep the delete-profile dialog open until the delete finishes (#7752)

* [client] Keep the delete-profile dialog open until the delete finishes

Confirming a profile deletion closed the dialog straight away and left the
daemon call running in the background. A slow delete then looked like nothing
had happened: the dialog was gone, the profile was still listed, and the row
only disappeared whenever the refresh landed.

The confirm dialog now owns the action. It stays open with the confirm button
spinning, closes once the call resolves, and surfaces a failure after it is
gone rather than behind it. Nothing on the daemon path carries a deadline, so
the wait is bounded in the dialog instead: Cancel comes back after five seconds
and the wait is abandoned at thirty, which keeps a hung daemon from trapping
the user in a modal that cannot be dismissed.

A loading button keeps its own variant colours rather than the disabled skin,
which dimmed the spinner to grey on the danger variant, and blocks input
through aria-disabled and a click guard instead.

* [client] Match the theme and anonymize pickers to the language switcher

* [client] Settle a confirm dialog only from the run that opened it

Cancelling at the stall point leaves the action running, and the provider is
mounted once: take() read whichever settler the ref held when the stale run
finally finished. Open another prompt in the meantime and that run answered it
— a hung delete that later resolved confirmed a profile switch nobody accepted,
and its timeout closed the new dialog with an unrelated error.

Each run now remembers the settler it was dispatched for and settles only while
the ref still points at it. A late arrival finds a stranger there and answers
nothing.

* [client] Add the shared Select the theme and anonymize pickers use

The picker rework landed without the component both pickers import, so the
branch did not compile. Add it, and name it for what it is: a select of a few
options, with nothing settings-specific about it, so it sits with the other
input controls rather than under a name that discourages reuse.

* [client] Drop the Select header comment

* [client] Fix cubic comments

* [client] Name the Select trigger with the option it is showing

* [client] Name the language trigger with the language it is showing

* [client] Hold the confirm dialog for 15s before offering cancel
This commit is contained in:
Eduard Gert
2026-09-29 17:08:33 +02:00
committed by GitHub
parent 10a04fcccb
commit 7ff709f565
10 changed files with 397 additions and 153 deletions
@@ -1,6 +1,6 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cva } from "class-variance-authority";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -159,19 +159,23 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"my-0.5 flex cursor-default select-none items-center gap-2 rounded-md px-2 py-2 outline-none",
"text-xs font-semibold text-nb-gray-200 transition-colors",
"data-[highlighted]:bg-nb-gray-850 data-[highlighted]:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
{children}
<span
aria-hidden={"true"}
className={"ml-auto flex w-4 shrink-0 items-center justify-center"}
>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className={"h-2 w-2 fill-current"} />
<Check size={14} className={"text-netbird"} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
@@ -89,7 +89,11 @@ export function LanguagePicker() {
tabIndex={0}
disabled={busy || languages.length === 0}
onKeyDown={handleTriggerKeyDown}
aria-label={t("settings.general.language.label")}
aria-label={
current
? `${t("settings.general.language.label")}: ${labelFor(current)}`
: t("settings.general.language.label")
}
aria-haspopup={"listbox"}
aria-expanded={open}
className={cn(
@@ -1,18 +1,10 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import { Select } from "@/components/inputs/Select";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
@@ -25,16 +17,12 @@ export function ThemePicker() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const [busy, setBusy] = useState(false);
const isFocusVisible = useFocusVisible();
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
const CurrentIcon = current.icon;
const select = async (value: string) => {
const select = async (value: ThemePreference) => {
if (busy || value === theme) return;
setBusy(true);
try {
await setTheme(value as ThemePreference);
await setTheme(value);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
@@ -52,57 +40,17 @@ export function ThemePicker() {
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={busy}
aria-label={t("settings.general.theme.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
)}
>
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
{t(current.labelKey)}
</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
>
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon
size={14}
aria-hidden={"true"}
className={"mr-2 shrink-0 text-nb-gray-300"}
/>
{t(labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={theme}
options={OPTIONS.map(({ value, icon, labelKey }) => ({
value,
icon,
label: t(labelKey),
}))}
onChange={(v) => void select(v)}
ariaLabel={t("settings.general.theme.label")}
disabled={busy}
/>
</div>
</div>
);
@@ -147,7 +147,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
tabIndex={0}
disabled={disabled || loading}
disabled={disabled}
aria-disabled={loading || undefined}
aria-busy={loading || undefined}
className={cn(
buttonVariants({
@@ -156,10 +157,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
border: border ? 1 : 0,
size,
}),
loading && "pointer-events-none",
className,
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
if (loading) {
e.preventDefault();
return;
}
if (copy !== undefined) {
void navigator.clipboard
.writeText(copy)
@@ -14,6 +14,7 @@ type ConfirmModalProps = {
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
cancellable?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
@@ -26,11 +27,13 @@ export const ConfirmModal = ({
cancelLabel,
danger = false,
busy = false,
cancellable,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const canCancel = cancellable ?? !busy;
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
@@ -39,7 +42,7 @@ export const ConfirmModal = ({
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
if (!next && canCancel) onCancel();
}}
>
<Dialog.Content
@@ -62,7 +65,7 @@ export const ConfirmModal = ({
<Button
variant={"secondary"}
size={"sm"}
disabled={busy}
disabled={!canCancel}
onClick={onCancel}
>
{resolvedCancel}
@@ -71,7 +74,7 @@ export const ConfirmModal = ({
autoFocus
variant={danger ? "danger" : "primary"}
size={"sm"}
disabled={busy}
loading={busy}
onClick={onConfirm}
>
{confirmLabel}
@@ -0,0 +1,99 @@
import type { LucideIcon } from "lucide-react";
import { ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
export type SelectOption<T extends string> = {
value: T;
label: string;
icon?: LucideIcon;
};
type SelectProps<T extends string> = {
value: T;
options: SelectOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
disabled?: boolean;
className?: string;
};
export function Select<T extends string>({
value,
options,
onChange,
ariaLabel,
disabled,
className,
}: SelectProps<T>) {
const isFocusVisible = useFocusVisible();
const current = options.find((o) => o.value === value) ?? options[0];
const CurrentIcon = current?.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={disabled}
aria-label={current ? `${ariaLabel}: ${current.label}` : ariaLabel}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
className,
)}
>
{CurrentIcon && (
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
)}
<span className={"flex-1 truncate text-left"}>{current?.label ?? "—"}</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"start"}
sideOffset={6}
className={
"w-[var(--radix-dropdown-menu-trigger-width)] border-nb-gray-850 bg-nb-gray-920"
}
>
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
{options.map(({ value: optionValue, label, icon: Icon }) => (
<DropdownMenuRadioItem key={optionValue} value={optionValue}>
{Icon && (
<Icon
size={14}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-300"}
/>
)}
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -8,6 +8,28 @@ import {
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
import i18next from "@/lib/i18n";
// Nothing on the daemon path carries a deadline, so a hung call would leave the
// modal spinning with no way out. Cancel comes back once the wait stops looking
// normal, and the wait is abandoned entirely at the deadline.
const CANCELLABLE_AFTER_MS = 15_000;
const TIMEOUT_MS = 30_000;
const withTimeout = async (action: () => Promise<unknown>) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const expiry = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(i18next.t("error.daemon_unreachable"))),
TIMEOUT_MS,
);
});
try {
await Promise.race([action(), expiry]);
} finally {
clearTimeout(timer);
}
};
export type ConfirmOptions = {
title: ReactNode;
@@ -15,6 +37,7 @@ export type ConfirmOptions = {
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
onConfirm?: () => Promise<unknown>;
};
type DialogContextValue = {
@@ -23,23 +46,50 @@ type DialogContextValue = {
const DialogContext = createContext<DialogContextValue | null>(null);
type Settler = { resolve: (result: boolean) => void; reject: (reason: unknown) => void };
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [stalled, setStalled] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const resolverRef = useRef<Settler | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
return new Promise<boolean>((resolve, reject) => {
resolverRef.current = { resolve, reject };
});
}, []);
const settle = (result: boolean) => {
resolverRef.current?.(result);
const take = (expected?: Settler | null) => {
const settler = resolverRef.current;
if (expected && settler !== expected) return null;
resolverRef.current = null;
setBusy(false);
setStalled(false);
setOpen(false);
return settler;
};
const handleConfirm = async () => {
const action = options?.onConfirm;
if (!action) {
take()?.resolve(true);
return;
}
const dispatched = resolverRef.current;
setBusy(true);
const stallTimer = setTimeout(() => setStalled(true), CANCELLABLE_AFTER_MS);
try {
await withTimeout(action);
take(dispatched)?.resolve(true);
} catch (e) {
take(dispatched)?.reject(e);
} finally {
clearTimeout(stallTimer);
}
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
@@ -54,8 +104,10 @@ export function DialogProvider({ children }: Readonly<{ children: ReactNode }>)
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
busy={busy}
cancellable={!busy || stalled}
onConfirm={() => void handleConfirm()}
onCancel={() => take()?.resolve(false)}
/>
</DialogContext.Provider>
);
@@ -78,7 +78,7 @@ export function ProfilesTab() {
return items;
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
const guarded = async (title: string, fn: () => Promise<unknown>) => {
if (busy) return;
setBusy(true);
try {
@@ -115,14 +115,15 @@ export function ProfilesTab() {
const handleDelete = async (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
await guarded(i18next.t("profile.error.deleteTitle"), () =>
confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
onConfirm: () => removeProfile(id),
}),
);
};
const handleCreate = async (name: string, managementUrl: string) => {
@@ -1,5 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import netbirdLogo from "@/assets/logos/netbird.svg";
const scratch = new Uint32Array(1);
function random() {
crypto.getRandomValues(scratch);
return scratch[0] / 2 ** 32;
}
type Mask = {
cols: number;
rows: number;
cells: Uint8Array;
seeds: Uint8Array;
glow: Float32Array;
};
export function useAccentTrigger() {
const clicksRef = useRef(0);
@@ -50,24 +66,45 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const chars = "DRIBTENMAET".split("").reverse().join("");
let disposed = false;
let mask: Mask | null = null;
const dpr = window.devicePixelRatio || 1;
let columns = 0;
let drops: number[] = [];
let latestBuild = 0;
let rebuild: ReturnType<typeof setTimeout> | undefined;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const next = Math.floor(window.innerWidth / 15);
if (next !== columns) {
columns = next;
drops = Array.from({ length: columns }, () => random() * -60);
mask = null;
}
globalThis.clearTimeout(rebuild);
rebuild = globalThis.setTimeout(() => {
const build = ++latestBuild;
void buildMask().then((m) => {
if (!disposed && build === latestBuild) mask = m;
});
}, 100);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
let frame = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
@@ -77,18 +114,26 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
ctx.font = "15px ui-monospace, monospace";
ctx.textBaseline = "top";
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(246, 131, 48, 0.5)";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
const ch = chars[Math.floor(random() * chars.length)];
const y = drops[i] * 15;
ctx.fillText(ch, i * 15, y);
igniteTrail(mask, i, Math.floor(drops[i]));
if (y > window.innerHeight && random() > 0.86) {
drops[i] = random() * -12;
}
drops[i]++;
}
drawGlow(ctx, mask, frame, chars);
frame++;
}
raf = requestAnimationFrame(draw);
};
@@ -100,8 +145,10 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
}, 9000);
return () => {
disposed = true;
cancelAnimationFrame(raf);
globalThis.clearTimeout(timeout);
globalThis.clearTimeout(rebuild);
window.removeEventListener("resize", resize);
};
}, [onDone]);
@@ -114,3 +161,103 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
</div>
);
}
function igniteTrail(mask: Mask | null, col: number, row: number) {
if (!mask || col < 0 || col >= mask.cols) return;
for (let k = 0; k < 6; k++) {
const r = row - k;
if (r < 0 || r >= mask.rows) continue;
const idx = r * mask.cols + col;
if (mask.cells[idx] === 0) continue;
const heat = 1 - k / 6;
if (heat > mask.glow[idx]) mask.glow[idx] = heat;
}
}
function drawGlow(ctx: CanvasRenderingContext2D, mask: Mask | null, frame: number, chars: string) {
if (!mask) return;
for (let idx = 0; idx < mask.cells.length; idx++) {
const heat = fade(mask, idx);
if (heat === 0) continue;
const seed = mask.seeds[idx];
const core = mask.cells[idx] === 2;
ctx.shadowColor = core ? "#f05252" : "#f68330";
ctx.shadowBlur = 10 * heat;
ctx.fillStyle = core ? `rgba(255, 226, 210, ${heat})` : `rgba(255, 255, 255, ${heat})`;
ctx.fillText(
chars[(seed + Math.floor(frame / (3 + (seed % 5)))) % chars.length],
(idx % mask.cols) * 15,
Math.floor(idx / mask.cols) * 15,
);
}
ctx.shadowBlur = 0;
}
function fade(mask: Mask, idx: number) {
if (mask.cells[idx] === 0) return 0;
const heat = mask.glow[idx];
if (heat <= 0.02) {
mask.glow[idx] = 0;
return 0;
}
mask.glow[idx] = heat * 0.94;
return heat;
}
function loadLogo() {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = netbirdLogo;
});
}
async function buildMask(): Promise<Mask | null> {
const cols = Math.floor(window.innerWidth / 15);
const rows = Math.ceil(window.innerHeight / 15);
if (cols <= 0 || rows <= 0) return null;
let img: HTMLImageElement;
try {
img = await loadLogo();
} catch {
return null;
}
const off = document.createElement("canvas");
off.width = cols;
off.height = rows;
const offCtx = off.getContext("2d", { willReadFrequently: true });
if (!offCtx) return null;
const aspect = (img.naturalWidth || 31) / (img.naturalHeight || 23);
let w = cols * 0.8;
let h = w / aspect;
if (h > rows * 0.8) {
h = rows * 0.8;
w = h * aspect;
}
offCtx.imageSmoothingEnabled = false;
offCtx.drawImage(img, (cols - w) / 2, (rows - h) / 2, w, h);
const { data } = offCtx.getImageData(0, 0, cols, rows);
const cells = new Uint8Array(cols * rows);
const seeds = new Uint8Array(cols * rows);
const glow = new Float32Array(cols * rows);
for (let i = 0; i < cells.length; i++) {
seeds[i] = Math.floor(random() * 251);
const alpha = data[i * 4 + 3];
if (alpha < 64) continue;
const r = data[i * 4];
const g = data[i * 4 + 1];
const b = data[i * 4 + 2];
cells[i] = r > 180 && g < 130 && b < 130 && g <= b + 24 ? 2 : 1;
}
return { cols, rows, cells, seeds, glow };
}
@@ -1,6 +1,15 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
import {
CircleCheckBig,
FolderOpen,
Info,
Loader2,
Shield,
ShieldCheck,
ShieldOff,
type LucideIcon,
} from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,20 +17,13 @@ import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { Select } from "@/components/inputs/Select";
import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters";
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
@@ -29,6 +31,12 @@ import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSect
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
const ANONYMIZE_LEVELS: { value: AnonymizeLevel; icon: LucideIcon }[] = [
{ value: "none", icon: ShieldOff },
{ value: "default", icon: Shield },
{ value: "strict", icon: ShieldCheck },
];
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
@@ -89,44 +97,16 @@ export function SettingsTroubleshooting() {
</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
<ChevronDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
<DropdownMenuRadioGroup
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
>
<DropdownMenuRadioItem value={"none"}>
{t("settings.troubleshooting.anonymize.none")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"default"}>
{t("settings.troubleshooting.anonymize.default")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"strict"}>
{t("settings.troubleshooting.anonymize.strict")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={anonymizeLevel}
options={ANONYMIZE_LEVELS.map(({ value, icon }) => ({
value,
icon,
label: t(`settings.troubleshooting.anonymize.${value}`),
}))}
onChange={setAnonymizeLevel}
ariaLabel={t("settings.troubleshooting.anonymize.label")}
/>
</div>
</div>
<FancyToggleSwitch