mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-07 14:09:07 +02:00
[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user