diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx
index 8cedcea03..aa3ced05f 100644
--- a/client/ui/frontend/src/components/DropdownMenu.tsx
+++ b/client/ui/frontend/src/components/DropdownMenu.tsx
@@ -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<
-
+ {children}
+
-
+
- {children}
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx
index 35ef7d5b5..d0a95906f 100644
--- a/client/ui/frontend/src/components/LanguagePicker.tsx
+++ b/client/ui/frontend/src/components/LanguagePicker.tsx
@@ -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(
diff --git a/client/ui/frontend/src/components/ThemePicker.tsx b/client/ui/frontend/src/components/ThemePicker.tsx
index c3dc70d1e..3acb11a99 100644
--- a/client/ui/frontend/src/components/ThemePicker.tsx
+++ b/client/ui/frontend/src/components/ThemePicker.tsx
@@ -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() {
{t("settings.general.theme.help")}
-
-
-
-
-
- void select(v)}>
- {OPTIONS.map(({ value, icon: Icon, labelKey }) => (
-
-
- {t(labelKey)}
-
- ))}
-
-
-
+
);
diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx
index 931988ead..50e8c919d 100644
--- a/client/ui/frontend/src/components/buttons/Button.tsx
+++ b/client/ui/frontend/src/components/buttons/Button.tsx
@@ -147,7 +147,8 @@ export const Button = forwardRef(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(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)
diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
index 241a69ec9..7a8a1c906 100644
--- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
+++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
@@ -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 = ({
{
- if (!next && !busy) onCancel();
+ if (!next && canCancel) onCancel();
}}
>
{resolvedCancel}
@@ -71,7 +74,7 @@ export const ConfirmModal = ({
autoFocus
variant={danger ? "danger" : "primary"}
size={"sm"}
- disabled={busy}
+ loading={busy}
onClick={onConfirm}
>
{confirmLabel}
diff --git a/client/ui/frontend/src/components/inputs/Select.tsx b/client/ui/frontend/src/components/inputs/Select.tsx
new file mode 100644
index 000000000..953008455
--- /dev/null
+++ b/client/ui/frontend/src/components/inputs/Select.tsx
@@ -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 = {
+ value: T;
+ label: string;
+ icon?: LucideIcon;
+};
+
+type SelectProps = {
+ value: T;
+ options: SelectOption[];
+ onChange: (value: T) => void;
+ ariaLabel: string;
+ disabled?: boolean;
+ className?: string;
+};
+
+export function Select({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ disabled,
+ className,
+}: SelectProps) {
+ const isFocusVisible = useFocusVisible();
+ const current = options.find((o) => o.value === value) ?? options[0];
+ const CurrentIcon = current?.icon;
+
+ return (
+
+
+
+
+
+ onChange(v as T)}>
+ {options.map(({ value: optionValue, label, icon: Icon }) => (
+
+ {Icon && (
+
+ )}
+ {label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx
index 8a52e0dd0..9619b43d0 100644
--- a/client/ui/frontend/src/contexts/DialogContext.tsx
+++ b/client/ui/frontend/src/contexts/DialogContext.tsx
@@ -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) => {
+ let timer: ReturnType | undefined;
+ const expiry = new Promise((_, 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;
};
type DialogContextValue = {
@@ -23,23 +46,50 @@ type DialogContextValue = {
const DialogContext = createContext(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(null);
- const resolverRef = useRef<((result: boolean) => void) | null>(null);
+ const resolverRef = useRef(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
- return new Promise((resolve) => {
- resolverRef.current = resolve;
+ return new Promise((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(() => ({ 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)}
/>
);
diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
index ce3994d0c..f2110fda0 100644
--- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
+++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
@@ -78,7 +78,7 @@ export function ProfilesTab() {
return items;
}, [profiles, activeProfileId]);
- const guarded = async (title: string, fn: () => Promise) => {
+ const guarded = async (title: string, fn: () => Promise) => {
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) => {
diff --git a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx
index b7d9c96c8..37a22dcad 100644
--- a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx
@@ -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 | 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 }>) {
);
}
+
+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((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = netbirdLogo;
+ });
+}
+
+async function buildMask(): Promise {
+ 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 };
+}
diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
index 9eac41d08..63f5e8e3d 100644
--- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
@@ -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() {
-
-
-
-
-
- setAnonymizeLevel(v as AnonymizeLevel)}
- >
-
- {t("settings.troubleshooting.anonymize.none")}
-
-
- {t("settings.troubleshooting.anonymize.default")}
-
-
- {t("settings.troubleshooting.anonymize.strict")}
-
-
-
-
+