refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
+14 -16
View File
@@ -17,47 +17,45 @@ import { initI18n } from "@/lib/i18n";
import { initPlatform } from "@/lib/platform";
import { initLogForwarding } from "@/lib/logs";
// Install console.* + uncaught-error forwarding before anything else runs
// so even init-time logs reach the Go log pipeline.
// Must run first so even init-time logs reach the Go log pipeline.
initLogForwarding();
welcome();
Promise.all([
initI18n().catch((e) => {
// Surface init failures in the console so a misconfigured glob
// doesn't quietly blank the UI; render anyway with i18next in
// whatever state it ended up in (t() will fall back to keys).
console.error("i18n init failed:", e);
}),
initPlatform().catch((e) => {
console.error("platform init failed:", e);
}),
])
.finally(() => {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
]).finally(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="dialog">
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
<Route
path="browser-login"
element={<LoginWaitingForBrowserDialog />}
/>
<Route path="install-progress" element={<UpdateInProgressDialog />} />
<Route path="session-expiration" element={<SessionExpirationDialog />} />
<Route
path="session-expiration"
element={<SessionExpirationDialog />}
/>
<Route path="welcome" element={<WelcomeDialog />} />
<Route path="error" element={<ErrorDialog />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<MainPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}
/>
<Route path="*" element={<Navigate to={"/"} replace />} />
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
);
});
);
});
@@ -5,12 +5,8 @@ import { cn } from "@/lib/cn";
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
type Props = HTMLAttributes<HTMLSpanElement> & {
/** Visual color scheme. Defaults to `info` (sky), used as the
* "Active profile" indicator. */
variant?: BadgeVariant;
/** Optional leading lucide icon. */
icon?: ComponentType<LucideProps>;
/** Override icon size. Defaults to 10px to match the compact pill. */
iconSize?: number;
};
@@ -23,10 +19,6 @@ const VARIANT_CLASSES: Record<BadgeVariant, string> = {
danger: "bg-red-900 border border-red-700 text-red-200",
};
// Pill shape sized for inline use next to text. `top-px` nudges the badge
// down so its midline aligns with the surrounding text baseline; `leading-none`
// lets the small text sit flush in the pill without the line-height padding
// inflating it.
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
ref,
@@ -1,9 +1,7 @@
import { useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
// Static map — Tailwind JIT only picks up literal class names, so dynamic
// template strings would be invisible to it.
const VARIANT_HOVER = {
default: "group-hover/copy:[&_*]:text-nb-gray-300",
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
@@ -19,10 +17,6 @@ type CopyToClipboardProps = {
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
// variant picks the text colour the wrapped content fades into on hover.
// - "default" → nb-gray-300 (peer-details, settings, etc.)
// - "bright" → nb-gray-200 (deeper-surface contexts like the main
// connection card where text needs more lift)
variant?: CopyToClipboardVariant;
};
@@ -36,8 +30,15 @@ export const CopyToClipboard = ({
alwaysShowIcon = false,
variant = "default",
}: CopyToClipboardProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -47,29 +48,25 @@ export const CopyToClipboard = ({
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 500);
} catch {
//
}
};
return (
<div
<button
type="button"
ref={wrapperRef}
onClick={handleClick}
className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable",
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left",
className,
)}
>
<span
className={cn(
"relative truncate min-w-0",
// [&_*] is Tailwind's arbitrary descendant variant: & is
// this element, _ is the CSS descendant combinator, * is
// every descendant. The generated selector has higher
// specificity than a child's own text-nb-gray-* class, so
// the hover colour wins the cascade.
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
@@ -105,6 +102,6 @@ export const CopyToClipboard = ({
)}
/>
</span>
</div>
</button>
);
};
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
@@ -11,14 +10,9 @@ import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
// Intentionally no flag icons here: flags represent countries, not
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
// etc.). Each label shows the endonym followed by the englishName in
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
// the trigger and the dropdown rows.
// See: https://www.flagsarenotlanguages.com/blog/
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
const labelFor = (lang: Language): string =>
lang.englishName && lang.englishName !== lang.displayName
@@ -7,10 +7,6 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
// fullWidth stretches the segmented control to fill its container —
// the SettingsGeneral row uses the default (shrink-to-content) layout,
// the welcome dialog asks for the wide variant so the picker spans the
// narrow dialog width.
fullWidth?: boolean;
};
@@ -2,11 +2,6 @@ import { ComponentType } from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
// icon centered inside. The `variant` selects the semantic colour scheme — all
// variants keep the neutral dark tile + border; only the icon colour changes
// to match the action's severity.
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
const variantClass: Record<SquareIconVariant, string> = {
+2 -11
View File
@@ -12,11 +12,7 @@ type Props = {
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
// Overrides the default tooltip-content chrome (background, padding,
// border, radius). Use when a richer body needs popover-style layout.
contentClassName?: string;
// Ms to wait after pointer-leave before closing. Lets the user cross
// a gap between trigger and content without the tooltip snapping shut.
closeDelay?: number;
};
@@ -60,10 +56,7 @@ export const Tooltip = ({
};
return (
<RTooltip.Provider
delayDuration={delayDuration}
disableHoverableContent={!interactive}
>
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
<RTooltip.Trigger
asChild
@@ -86,9 +79,7 @@ export const Tooltip = ({
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={
interactive ? undefined : (e) => e.preventDefault()
}
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
className={cn(
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
@@ -8,17 +8,7 @@ type Props = {
delayDuration?: number;
};
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
// the "tooltip on hover even though everything fits" annoyance. The caller
// supplies the wrapper styling (font, max-width, etc.) via className — this
// component only owns the truncate + measure + tooltip behavior.
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
}: Props) => {
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
@@ -3,32 +3,32 @@ import * as Tabs from "@radix-ui/react-tabs";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
const Root = forwardRef<
HTMLDivElement,
Omit<Tabs.TabsProps, "orientation">
>(function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.Root
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
{...props}
/>
);
});
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
function VerticalTabsList({ className, ...props }, ref) {
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.List
<Tabs.Root
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
{...props}
/>
);
},
);
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
{ className, ...props },
ref,
) {
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
{...props}
/>
);
});
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
@@ -36,54 +36,47 @@ type TriggerProps = Tabs.TabsTriggerProps & {
adornment?: ReactNode;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
},
);
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
function VerticalTabsContent({ className, ...props }, ref) {
return (
<Tabs.Content
ref={ref}
className={cn("outline-none", className)}
{...props}
/>
);
},
);
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
});
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
{ className, ...props },
ref,
) {
return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />;
});
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
@@ -1,6 +1,6 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, Copy, Loader2 } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
import { ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
@@ -10,9 +10,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
// When true, the content is replaced by a centered spinner while keeping
// the button's rendered width/height (the content stays in the layout,
// just hidden). Also disables the button.
loading?: boolean;
}
@@ -134,6 +131,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref,
) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const iconSize = size === "xs" ? 12 : 14;
return (
<button
@@ -156,7 +160,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
.writeText(copy)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}
@@ -2,15 +2,6 @@ import { ReactNode, forwardRef } from "react";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
// ConfirmDialog is the shared layout wrapper used by dialog-style window
// surfaces (SessionExpiration, …). Purely a layout
// primitive — callers compose the contents (SquareIcon, DialogHeading,
// DialogDescription, DialogActions) so each dialog can tweak its own
// internal structure without growing the ConfirmDialog API.
//
// Callers that mount the dialog inside its own Wails window pair this
// with useAutoSizeWindow by forwarding the returned ref onto the content
// wrapper so the window height tracks the rendered content.
type ConfirmDialogProps = {
children: ReactNode;
};
@@ -6,25 +6,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to a native confirm dialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
// single instance at the provider level. Render ConfirmModal yourself only
// when you need bespoke control over its open/busy lifecycle.
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
/** Confirm button label. */
confirmLabel: string;
/** Cancel button label; defaults to the shared "Cancel" string. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
/** Disable the buttons (and ignore dismiss) while an action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
@@ -43,9 +31,7 @@ export const ConfirmModal = ({
}: ConfirmModalProps) => {
const { t } = useTranslation();
// Retain the last shown content so it stays rendered through Radix's
// close animation instead of blanking out the instant the caller clears
// its state on close.
// Retain last content so it survives Radix's close animation.
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
cancelLabel: string;
};
@@ -1,21 +1,13 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogActions wraps a vertical stack of Buttons inside a dialog surface.
// The wails-no-draggable class lets the user click the buttons even when
// the dialog window itself is draggable from any background region.
type DialogActionsProps = {
children: ReactNode;
className?: string;
};
export const DialogActions = ({ children, className }: DialogActionsProps) => (
<div
className={cn(
"wails-no-draggable flex flex-col gap-3 w-full mx-auto",
className,
)}
>
<div className={cn("wails-no-draggable flex flex-col gap-3 w-full mx-auto", className)}>
{children}
</div>
);
@@ -1,8 +1,6 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
@@ -17,18 +15,12 @@ type DialogDescriptionProps = {
align?: DialogAlign;
};
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
// w-full for the same reason DialogHeading carries it — see the
// comment there. The default text-center remains visually identical
// to before; left/right alignment now anchors to the dialog content
// edge instead of collapsing to no-op on a content-width box.
<p
className={cn(
"w-full text-sm text-nb-gray-300 select-none",
alignClass[align],
className,
)}
>
export const DialogDescription = ({
children,
className,
align = "center",
}: DialogDescriptionProps) => (
<p className={cn("w-full text-sm text-nb-gray-300 select-none", alignClass[align], className)}>
{children}
</p>
);
@@ -1,9 +1,6 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogHeading is the title text used inside ConfirmDialog (and any other
// dialog-style surface with the same shape). Pair with DialogDescription
// for the standard title/description stack.
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
@@ -19,12 +16,6 @@ type DialogHeadingProps = {
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
// w-full so the alignClass actually has a box to anchor against.
// The wrapping <p> defaulted to content width inside a flex column,
// which made `text-left` a no-op (nothing to push the text away
// from). Stretching the element is invisible for the default
// text-center case (center of content == center of box) and lets
// text-left/right line up with the dialog's content edge.
<p
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
@@ -7,7 +7,7 @@ import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
}
export const DaemonUnavailableOverlay = () => {
@@ -21,10 +21,6 @@ export const DaemonUnavailableOverlay = () => {
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
<div
@@ -3,6 +3,10 @@ import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "@/components/SquareIcon";
// Knob to shift the centered main-window content up/down together.
export const CONTENT_VERTICAL_OFFSET = "-1.4rem";
export const contentTop = (base: string) => `calc(${base} + ${CONTENT_VERTICAL_OFFSET})`;
type Props = {
icon: ComponentType<LucideProps>;
title: string;
@@ -15,8 +19,9 @@ export const EmptyState = ({ icon, title, description, className }: Props) => {
<div className={cn("py-12 text-center", className)}>
<div
className={
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]"
"flex flex-col items-center justify-start max-w-sm mx-auto relative"
}
style={{ top: contentTop("7.8rem") }}
>
<SquareIcon icon={icon} className={"mb-3"} />
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
+191 -109
View File
@@ -1,6 +1,14 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import {
forwardRef,
InputHTMLAttributes,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/Label";
@@ -14,9 +22,6 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
// e.g. for "couldn't reach this server" where the value is syntactically
// fine and the user may still proceed. `error` takes precedence.
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
@@ -52,6 +57,151 @@ const inputVariants = cva("", {
},
});
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
const stepAttr = el.step === "" ? 1 : Number(el.step);
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min === "" ? -Infinity : Number(el.min);
const max = el.max === "" ? Infinity : Number(el.max);
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
return next;
}
function buildInputClassName(
opts: Readonly<{
variant: InputVariants["variant"];
hasCustomPrefix: boolean;
hasSuffix: boolean;
hasIcon: boolean;
readOnly?: boolean;
showStepper: boolean;
className?: string;
}>,
): string {
return cn(
inputVariants({ variant: opts.variant }),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!border-l-0 !rounded-l-none",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
"border",
opts.readOnly && "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
opts.showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
opts.className,
);
}
function InputAffix({
content,
error,
disabled,
className,
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
return (
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
disabled && "opacity-40",
className,
)}
>
{content}
</div>
);
}
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
disabled && "opacity-40",
)}
>
{icon}
</div>
);
}
function InputSuffixSlot({
suffix,
disabled,
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
disabled && "opacity-30",
)}
>
{suffix}
</div>
);
}
function NumberStepper({
error,
disabled,
onStep,
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => onStep(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => onStep(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
);
}
function FieldMessage({ error, warning }: Readonly<{ error?: string; warning?: string }>) {
if (!error && !warning) return null;
return (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
@@ -82,28 +232,29 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const reactId = useId();
const inputId = id ?? (label ? `input-${reactId}` : undefined);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
else if (ref) ref.current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
const stepAttr = el.step !== "" ? Number(el.step) : 1;
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min !== "" ? Number(el.min) : -Infinity;
const max = el.max !== "" ? Number(el.max) : Infinity;
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
const next = computeNextStepValue(el, delta);
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
@@ -121,14 +272,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
) : null;
const onCopy = async () => {
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
@@ -145,37 +296,33 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
const warningVariant = warning ? "warning" : variant;
const resolvedVariant = error ? "error" : warningVariant;
const inputClassName = buildInputClassName({
variant: resolvedVariant,
hasCustomPrefix: !!customPrefix,
hasSuffix: !!suffix,
hasIcon: !!icon,
readOnly: props.readOnly,
showStepper,
className,
});
return (
<div className="flex flex-col w-full min-w-0">
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<div
className={cn(
inputVariants({
prefixSuffixVariant: error ? "error" : "default",
}),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
props.disabled && "opacity-40",
prefixClassName,
)}
>
{customPrefix}
</div>
<InputAffix
content={customPrefix}
error={error}
disabled={props.disabled}
className={prefixClassName}
/>
)}
{icon && (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
props.disabled && "opacity-40",
)}
>
{icon}
</div>
)}
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
<div className="relative flex flex-grow min-w-0">
<input
@@ -183,82 +330,17 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type={inputType}
ref={setRefs}
{...props}
className={cn(
inputVariants({
variant: error ? "error" : warning ? "warning" : variant,
}),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-9",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,
)}
className={inputClassName}
/>
{suffix && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
props.disabled && "opacity-30",
)}
>
{suffix}
</div>
)}
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
</div>
{showStepper && (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
props.disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
)}
</div>
{(error || warning) && (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
)}
<FieldMessage error={error} warning={warning} />
</div>
);
});
@@ -7,49 +7,39 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
shortcut?: ReactNode;
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(
function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
className={cn(
"flex items-center gap-2 px-1 h-10",
disabled && "opacity-50",
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
)}
>
<SearchIcon
size={iconSize}
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
/>
{shortcut && (
<span
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
/>
{shortcut && (
<span
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
>
{shortcut}
</span>
)}
</div>
);
},
);
>
{shortcut}
</span>
)}
</div>
);
});
@@ -31,39 +31,27 @@ export default function FancyToggleSwitch({
labelClassName,
textWrapperClassName = "max-w-lg",
}: Readonly<Props>) {
const childrenRef = React.useRef<HTMLDivElement>(null);
if (loading) {
// Match the global SkeletonTheme in app.tsx (#25282d base /
// #33373e highlight) so the loading row blends in with
// SettingsSkeleton. box-decoration-clone gives every wrapped line
// of text its own rounded corners instead of just the first/last.
const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div
className={cn("inline-block text-left w-full", className)}
aria-busy
>
<div className={cn("inline-block text-left w-full", className)} aria-busy>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
<span className={shimmer}>{label}</span>
</Label>
<HelpText margin={false}>
<span
className={cn(
shimmer,
"text-[0.6rem] leading-relaxed",
)}
>
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
{helpText}
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div
className={
"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"
}
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
/>
</div>
</div>
@@ -71,16 +59,19 @@ export default function FancyToggleSwitch({
);
}
const handleToggle = () => {
if (disabled) return;
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (disabled || fromChildren(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
onChange(!value);
}
};
@@ -108,7 +99,7 @@ export default function FancyToggleSwitch({
</div>
</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
<div className="mt-4" ref={childrenRef}>
{children}
</div>
) : null}
@@ -1,11 +1,10 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, ReactNode, useContext, useId } from "react";
import { createContext, ReactNode, useContext, useId, useMemo } from "react";
import { cn } from "@/lib/cn";
type SwitchItemGroupContextValue = {
value: string;
layoutId: string;
disabled: boolean;
};
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
@@ -34,9 +33,10 @@ export const SwitchItemGroup = ({
disabled = false,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}>
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
value={value}
onValueChange={onChange}
@@ -27,11 +27,7 @@ export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
}
return (
<LabelPrimitive.Root
ref={ref as Ref<HTMLLabelElement>}
className={classes}
{...props}
>
<LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}>
{children}
</LabelPrimitive.Root>
);
@@ -9,18 +9,12 @@ import {
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
// (for Trigger) handled by the install window's polling-grace branch; a
// second popup on top of those is pure noise. Every Update RPC routes
// through the shared gRPC conn, so the Unavailable code is the marker.
const isDaemonUnavailable = (e: unknown): boolean => {
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
@@ -81,9 +75,6 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
};
}, []);
// Force-install branch: daemon's progress_window:show flipped installing
// to true while the UI was idle. Open the install window so the user
// sees the progress UI without having to click anything.
const prevInstallingRef = useRef(false);
useEffect(() => {
if (state.installing && !prevInstallingRef.current) {
@@ -92,19 +83,11 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
prevInstallingRef.current = state.installing;
}, [state.installing, state.version]);
// Enforced user-driven branch: kick Trigger() in the background, then
// hand off to the install window. The window owns the polling loop and
// the final Quit() — this provider just fires the trigger.
const triggerUpdate = useCallback(() => {
setUpdating(true);
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
UpdateSvc.Trigger()
.catch(async (e) => {
// The daemon may already be down (force-install branch raced
// us). The install window's polling loop handles that case.
// Anything else is a real failure — close the install window
// (otherwise it spins forever on a daemon that won't ever
// produce a result) and surface the error.
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await errorDialog({
@@ -127,9 +110,5 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
[state, triggerUpdate, updating],
);
return (
<ClientVersionContext.Provider value={value}>
{children}
</ClientVersionContext.Provider>
);
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
};
@@ -1,18 +1,8 @@
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
} from "@bindings/services";
import { createContext, useContext, useRef, useState, type ReactNode } from "react";
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { useProfile } from "@/contexts/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
@@ -47,8 +37,64 @@ const sleep = (ms: number, signal: AbortSignal) =>
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
const throwIfAborted = (signal: AbortSignal) => {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
};
const setLogLevelBestEffort = async (level: string) => {
try {
await DebugSvc.SetLogLevel({ level });
} catch {
// empty
}
};
type LevelState = { original: string; raised: boolean };
const runTracePhase = async (
signal: AbortSignal,
level: LevelState,
setStage: (s: DebugStage) => void,
target: { profileName: string; username: string },
traceMinutes: number,
) => {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) level.original = cur.level;
} catch {
// empty
}
throwIfAborted(signal);
await DebugSvc.SetLogLevel({ level: "trace" });
level.raised = true;
throwIfAborted(signal);
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// 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);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: level.original });
level.raised = false;
} catch {
// empty
}
};
const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
@@ -75,66 +121,24 @@ const useDebugBundle = () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const checkAbort = () => {
if (signal.aborted)
throw new DOMException("aborted", "AbortError");
};
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
let originalLevel = "info";
let raisedLevel = false;
const level: LevelState = { original: "info", raised: false };
try {
if (trace) {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) originalLevel = cur.level;
} catch {
// best effort
}
checkAbort();
await DebugSvc.SetLogLevel({ level: "trace" });
raisedLevel = true;
checkAbort();
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// already down
}
checkAbort();
await ConnectionSvc.Up({
profileName: activeProfile,
username,
});
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);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
raisedLevel = false;
} catch {
// restore is best-effort
}
await runTracePhase(
signal,
level,
setStage,
{ profileName: activeProfile, username },
traceMinutes,
);
}
checkAbort();
throwIfAborted(signal);
setStage({ kind: "bundling" });
const logFileCount = trace
? TRACE_LOG_FILE_COUNT
: PLAIN_LOG_FILE_COUNT;
const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
if (uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
@@ -143,7 +147,7 @@ const useDebugBundle = () => {
uploadUrl,
logFileCount,
});
checkAbort();
throwIfAborted(signal);
if (result.path) setLastBundlePath(result.path);
setStage({
kind: "done",
@@ -152,13 +156,7 @@ const useDebugBundle = () => {
});
} catch (e) {
if (isAbort(e)) {
if (raisedLevel) {
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
} catch {
// best effort
}
}
if (level.raised) await setLogLevelBestEffort(level.original);
setStage({ kind: "idle" });
return;
}
@@ -174,7 +172,9 @@ const useDebugBundle = () => {
const openBundleDir = () => {
if (!lastBundlePath) return;
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
console.error("[DebugBundleContext] reveal failed", err),
);
};
return {
@@ -204,19 +204,13 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
};
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error(
"useDebugBundleContext must be used inside DebugBundleProvider",
);
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
}
return ctx;
};
@@ -1,24 +1,19 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of a native confirmation dialog. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.
//
// const confirm = useConfirm();
// if (await confirm({ title, description, confirmLabel })) { …do it… }
//
// Mounted once (outermost in AppLayout) so it's available in every in-window
// route across both the main and settings windows.
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
};
@@ -28,7 +23,7 @@ type DialogContextValue = {
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
@@ -41,17 +36,16 @@ export function DialogProvider({ children }: { children: ReactNode }) {
});
}, []);
// Resolve the pending promise and start the close animation. The options
// stay in state so ConfirmModal still has content to render while it
// animates out.
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
return (
<DialogContext.Provider value={{ confirm }}>
<DialogContext.Provider value={value}>
{children}
<ConfirmModal
open={open}
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
@@ -12,18 +12,13 @@ const NavSectionContext = createContext<NavSectionContextValue | null>(null);
export const useNavSection = (): NavSectionContextValue => {
const ctx = useContext(NavSectionContext);
if (!ctx) {
throw new Error(
"useNavSection must be used inside NavSectionProvider",
);
throw new Error("useNavSection must be used inside NavSectionProvider");
}
return ctx;
};
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
const [section, setSection] = useState<NavSection>("peers");
return (
<NavSectionContext.Provider value={{ section, setSection }}>
{children}
</NavSectionContext.Provider>
);
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
};
@@ -12,10 +12,9 @@ import { Networks as NetworksSvc } from "@bindings/services";
import type { Network } from "@bindings/services/models.js";
import { useStatus } from "@/contexts/StatusContext";
// A range is treated as an exit-node candidate when any of its CIDRs is a
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
// comma-joined range string for one peer.
export const isDefaultRoute = (range: string): boolean =>
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
export const isExitNode = (range: string): boolean =>
range.split(",").some((part) => {
const trimmed = part.trim();
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
@@ -45,33 +44,61 @@ export const useNetworks = () => {
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
const [routes, setRoutes] = useState<Network[]>([]);
// Optimistic overrides: id → expected `selected` value. Applied on top of
// the server-side `routes` so toggles paint instantly. Entries are cleared
// either when the next server snapshot agrees (success path) or when the
// RPC throws (rollback). Linear-style optimistic mutation tracking.
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
// Mirror of `pending` for use inside async callbacks without re-binding
// them on every change.
const pendingRef = useRef(pending);
useEffect(() => {
pendingRef.current = pending;
}, [pending]);
const setPendingFor = useCallback((updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
const STUCK_OVERRIDE_MS = 4000;
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((id: string) => {
const tid = timersRef.current.get(id);
if (tid !== undefined) {
clearTimeout(tid);
timersRef.current.delete(id);
}
}, []);
const clearPendingFor = useCallback((ids: string[]) => {
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
const clearPendingFor = useCallback(
(ids: string[]) => {
for (const id of ids) clearTimer(id);
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
},
[clearTimer],
);
const setPendingFor = useCallback(
(updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
for (const [id] of updates) {
clearTimer(id);
timersRef.current.set(
id,
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
);
}
},
[clearTimer, clearPendingFor],
);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const tid of timers.values()) clearTimeout(tid);
timers.clear();
};
}, []);
const refresh = useCallback(async () => {
@@ -83,19 +110,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
// The daemon bumps networksRevision whenever the routed-network set or a
// selection changes (from any surface) and pushes it on the status stream.
// Refetch on every bump so the list stays live without polling — and on
// mount, since the revision is already defined by the time this provider
// renders (StatusProvider only mounts children once the daemon is reachable).
const networksRevision = status?.networksRevision;
useEffect(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
}, [refresh, networksRevision]);
// When the server snapshot agrees with a pending optimistic value, the
// mutation is confirmed — drop the override so the row tracks the server
// again. Runs whenever routes change.
useEffect(() => {
if (pendingRef.current.size === 0) return;
const confirmed: string[] = [];
@@ -116,13 +135,10 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
}
// Don't clear pending here — let the revision-driven refresh
// confirm via the snapshot-match effect. That avoids a flash
// back to old state if the refresh races the RPC return.
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
await refresh();
} catch (e) {
console.error(e);
// Roll back to the last server-observed value for each id.
setPending((prev) => {
const next = new Map(prev);
for (const [id] of rollback) next.delete(id);
@@ -143,9 +159,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor],
);
// Batch toggle for the bottom-bar select-all switch. The daemon's
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
// per-ID calls — one round-trip + one refresh.
const setNetworksSelected = useCallback(
async (ids: string[], selected: boolean) => {
if (ids.length === 0) return;
@@ -160,11 +173,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor, routes],
);
// Exit nodes are mutually exclusive, but the daemon enforces that now —
// selecting one deselects the other exit nodes. Append so activating an
// exit node doesn't wipe the user's network-route selections. We also
// mirror that mutual-exclusion locally so the optimistic paint matches
// the daemon's eventual state.
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
const toggleExitNode = useCallback(
async (id: string, selected: boolean) => {
const target = !selected;
@@ -172,7 +181,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const rollback: Array<[string, boolean]> = [[id, selected]];
if (target) {
for (const r of routes) {
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
if (r.id !== id && isExitNode(r.range) && r.selected) {
updates.push([r.id, false]);
rollback.push([r.id, true]);
}
@@ -185,9 +194,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
);
const value = useMemo<NetworksContextValue>(() => {
// Apply pending overrides on top of the server snapshot. The override
// map is usually empty or tiny (one entry per in-flight toggle), so
// the per-route lookup is effectively free.
const effective =
pending.size === 0
? routes
@@ -197,8 +203,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
? r
: { ...r, selected: override };
});
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
const exitNodes = effective.filter((r) => isExitNode(r.range));
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
return {
routes: effective,
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import type { PeerStatus } from "@bindings/services/models.js";
type PeerDetailContextValue = {
@@ -11,18 +11,13 @@ const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
export const usePeerDetail = (): PeerDetailContextValue => {
const ctx = useContext(PeerDetailContext);
if (!ctx) {
throw new Error(
"usePeerDetail must be used inside PeerDetailProvider",
);
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
}
return ctx;
};
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
const [selected, setSelected] = useState<PeerStatus | null>(null);
return (
<PeerDetailContext.Provider value={{ selected, setSelected }}>
{children}
</PeerDetailContext.Provider>
);
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
};
@@ -3,19 +3,15 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection,
ProfileSwitcher,
Profiles as ProfilesSvc,
} from "@bindings/services";
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
@@ -58,10 +54,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
setActiveProfile(active.profileName || "default");
setProfiles(list);
} catch (e) {
// Daemon-down is already surfaced globally by
// DaemonUnavailableOverlay; a second popup on top of it is
// pure noise. Every profile RPC routes through the same gRPC
// conn, so the Unavailable code is the reliable marker.
// Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here.
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("code = Unavailable")) {
return;
@@ -76,16 +69,14 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
}, []);
useEffect(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
}, [refresh]);
useEffect(() => {
// The tray and other windows drive switches through the same
// ProfileSwitcher.SwitchActive RPC, which emits this event on success.
// Without the subscription, a tray-initiated switch leaves this
// window painting the old activeProfile until the next mount.
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
void refresh();
refresh().catch((err: unknown) =>
console.error("[ProfileContext] refresh failed", err),
);
});
return () => {
off();
@@ -124,21 +115,30 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
return (
<ProfileContext.Provider
value={{
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}}
>
{children}
</ProfileContext.Provider>
const value = useMemo<ProfileContextValue>(
() => ({
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}),
[
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
],
);
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
};
@@ -3,20 +3,22 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
export type AutostartState = { supported: boolean; enabled: boolean };
type SettingsContextValue = {
@@ -47,9 +49,7 @@ export const useSettings = () => {
export const useAutostartSetting = () => {
const ctx = useContext(AutostartContext);
if (!ctx) {
throw new Error(
"useAutostartSetting must be used inside AutostartSettingsProvider",
);
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
}
return ctx;
};
@@ -97,10 +97,7 @@ const useSettingsState = () => {
const save = useCallback(
async (next: Config) => {
// The daemon masks an existing PSK as "**********" in GetConfig.
// Sending the mask back round-trips it into the saved config and
// wgtypes.ParseKey fails on the next connect. Drop the mask so
// unrelated toggles don't corrupt the stored PSK.
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
const { preSharedKey, ...rest } = next;
try {
await SettingsSvc.SetConfig({
@@ -126,7 +123,7 @@ const useSettingsState = () => {
const next = { ...c, [k]: v };
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
void save(next);
save(next).catch(logSaveError);
}, SAVE_DEBOUNCE_MS);
return next;
});
@@ -175,26 +172,19 @@ const useSettingsState = () => {
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveNow } =
useSettingsState();
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
return (
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SettingsSkeleton />
{value ? (
<SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
) : (
<SettingsContext.Provider
value={{
config,
guiVersion,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
<SettingsSkeleton />
)}
</div>
);
@@ -232,9 +222,10 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
}
}, []);
return (
<AutostartContext.Provider value={{ autostart, setAutostartEnabled }}>
{children}
</AutostartContext.Provider>
const value = useMemo<AutostartContextValue>(
() => ({ autostart, setAutostartEnabled }),
[autostart, setAutostartEnabled],
);
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
};
@@ -1,21 +1,19 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { DaemonFeed } from "@bindings/services";
import type { Status } from "@bindings/services/models.js";
import { Status } from "@bindings/services/models.js";
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
const EVENT_STATUS = "netbird:status";
// StatusContext is the single subscription point for the daemon status
// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener,
// and the synthetic DaemonUnavailable handling. The provider also renders
// the DaemonUnavailableOverlay so every layout that mounts it inherits the
// same blocker without re-importing the component.
//
// Boolean flags consumers should prefer over hand-rolled checks:
// - isReady first DaemonFeed.Get has resolved
// - isDaemonUnavailable ready and status === "DaemonUnavailable"
// - isDaemonAvailable ready and status !== "DaemonUnavailable"
type StatusContextValue = {
status: Status | null;
error: string | null;
@@ -45,20 +43,14 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
setStatus(s);
setError(null);
} catch (e) {
// DaemonFeed.Get returns a gRPC error when the socket itself is
// unreachable (daemon not running, missing socket, etc.); only
// the streaming path synthesizes a DaemonUnavailable status.
// Synthesize one here too so the overlay paints on cold start
// without a daemon — otherwise the whole UI stays blank since
// `isReady` would never flip and StatusProvider's short-circuit
// wouldn't render either children or the overlay.
setStatus({ status: "DaemonUnavailable" } as Status);
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
setError(String(e));
}
}, []);
useEffect(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
@@ -72,23 +64,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
const isDaemonAvailable = isReady && !isDaemonUnavailable;
// Don't mount children until the first DaemonFeed.Get has resolved and the
// daemon is reachable. Consumers (ProfileContext, SettingsContext, …)
// can then assume any daemon RPC they make at mount will reach the
// socket — no per-context availability gating. When the daemon flips
// back to unavailable the children unmount and remount fresh once it
// returns.
const value = useMemo<StatusContextValue>(
() => ({
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}),
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable],
);
return (
<StatusContext.Provider
value={{
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}}
>
<StatusContext.Provider value={value}>
{isDaemonAvailable && children}
<DaemonUnavailableOverlay />
</StatusContext.Provider>
@@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -13,13 +14,8 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
export type ViewMode = "default" | "advanced";
// Window widths per view. Height stays at whatever the window was first
// created with — we deliberately don't pass a fixed height to
// Window.SetSize because Wails' macOS implementation interprets it as the
// outer frame (windowSetSize → setFrame:), while the initial creation
// uses initWithContentRect:. The two differ by one title-bar height
// (~28px), so re-asserting 640 here would chop ~28px off the content
// area on the first switch and visually shift everything inside.
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
export const VIEW_WIDTH: Record<ViewMode, number> = {
default: 380,
advanced: 900,
@@ -33,18 +29,12 @@ type ViewModeContextValue = {
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const [viewMode, setMode] = useState<ViewMode>("default");
// Mirror of viewMode for dedup inside the async setViewMode without
// adding the state to the callback's dep array (which would re-create
// the callback on every change).
const [mode, setMode] = useState<ViewMode>("default");
const modeRef = useRef<ViewMode>("default");
// Hydrate from the persisted preference. The Go side has already sized
// the main window to match (see main.go), so this only catches the
// React state and dropdown checkmark up — no resize is triggered here.
useEffect(() => {
let cancelled = false;
void Preferences.Get()
Preferences.Get()
.then((prefs) => {
if (cancelled) return;
const saved = prefs?.viewMode as ViewMode | undefined;
@@ -59,31 +49,30 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
};
}, []);
// Resize the window BEFORE flipping React state — otherwise the new
// layout (e.g., advanced-mode right panel mounting) paints into a
// window that hasn't grown yet, causing a brief flex-overflow that
// wobbles the connect toggle's position. Cost: one IPC roundtrip
// (~30ms) before the dropdown checkmark updates.
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
const setViewMode = useCallback((mode: ViewMode) => {
if (modeRef.current === mode) return;
modeRef.current = mode;
void (async () => {
// Reuse the live frame height instead of asserting a
// constant — keeps content area stable across switches
// (see VIEW_WIDTH comment above).
(async () => {
const size = await Window.Size().catch(() => null);
const width = VIEW_WIDTH[mode];
const height = size?.height ?? 640;
await Window.SetSize(width, height).catch(() => {});
setMode(mode);
void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {});
})();
const pref =
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
Preferences.SetViewMode(pref).catch((err: unknown) =>
console.error("[ViewModeContext] SetViewMode failed", err),
);
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
}, []);
return (
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
{children}
</ViewModeContext.Provider>
const value = useMemo<ViewModeContextValue>(
() => ({ viewMode: mode, setViewMode }),
[mode, setViewMode],
);
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
};
export const useViewMode = () => {
+14 -14
View File
@@ -1,15 +1,15 @@
@font-face {
font-family: "Inter Variable";
font-style: normal;
font-weight: 100 900;
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
font-family: "Inter Variable";
font-style: normal;
font-weight: 100 900;
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
}
@font-face {
font-family: "JetBrains Mono Variable";
font-style: normal;
font-weight: 100 800;
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
font-family: "JetBrains Mono Variable";
font-style: normal;
font-weight: 100 800;
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
}
@tailwind base;
@@ -19,8 +19,8 @@
html,
body,
#root {
height: 100%;
overflow: hidden;
height: 100%;
overflow: hidden;
}
/*
@@ -32,14 +32,14 @@ body,
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
*/
body {
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
.wails-draggable {
--wails-draggable: drag;
cursor: default;
--wails-draggable: drag;
cursor: default;
}
.wails-no-draggable {
--wails-draggable: no-drag;
--wails-draggable: no-drag;
}
@@ -2,35 +2,8 @@ import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
// useAutoSizeWindow resizes the current Wails window so its height matches
// the measured height of the content element the returned ref is attached
// to. Width stays fixed (Wails has no "fit-content-width" notion and the
// dialog-style session windows want a stable horizontal footprint).
//
// On first measurement the hook also calls Window.Show()/Focus() — the
// Go-side opens the window with Hidden: true so the user never sees the
// initial placeholder size snap to the measured size. Subsequent
// measurements (content changes after mount) only adjust the size.
//
// Re-measures via ResizeObserver so adding/removing content (e.g. the
// SessionExpiration title swapping at countdown zero) keeps the chrome
// tight to the content with no scrollbar.
//
// Also re-measures on i18next `languageChanged`. The ResizeObserver in
// theory catches the same reflow when translated strings replace each
// other (DE/HU strings often wrap to more lines than EN), but in practice
// the observer can settle on a stale size before React's commit and the
// font's glyph metrics finish updating. An explicit double-rAF after the
// language flip guarantees the final layout is the one we measure.
//
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
// while the caller is still resolving its initial content (e.g. waiting
// on an async probe) so the window stays Hidden instead of briefly
// rendering placeholder padding at the wrong size — Linux/GNOME in
// particular paints whatever the frame ends up at, and a transient
// half-height frame can leak through. Flip ready=true once the real
// content is in the DOM; the effect re-runs, measures the final size,
// and shows the window.
// Sizes the current Wails window to the measured content height (keeping `width`),
// then shows it. Re-applies on content resize and language change.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
@@ -39,39 +12,25 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
let shown = false;
let raf1 = 0;
let raf2 = 0;
const showOnce = () => {
if (shown) return;
shown = true;
Window.Show().catch(() => {});
Window.Focus().catch(() => {});
};
const apply = () => {
if (!ready) return;
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
// Wails Window.SetSize takes the *frame* size on every platform
// (Windows: SetWindowPos, macOS: setFrame:, Linux: GTK frame).
// The OS title bar lives inside the frame, so we have to add the
// chrome height before calling SetSize, or the title bar eats
// pixels from the bottom and the rendered content gets clipped.
//
// window.outerHeight / window.innerHeight are useless here:
// WebView2 (and WKWebView) report the WebView's own outer == inner
// because the WebView itself has no chrome — the OS title bar is
// outside the WebView's window object entirely. The only way to
// recover the chrome height is to compare the OS frame height
// (Wails-side Window.Size()) against the WebView viewport
// (window.innerHeight).
void Window.Size()
// Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
Window.Size()
.then((frame) => {
const chrome = Math.max(0, frame.height - window.innerHeight);
return Window.SetSize(width, h + chrome);
})
.then(() => {
if (shown) return;
shown = true;
void Window.Show().catch(() => {});
void Window.Focus().catch(() => {});
})
.then(showOnce)
.catch(() => {});
};
// Double rAF: first frame lands after React commits the new
// translated strings, second frame lands after the browser has
// recomputed layout, so apply() sees the final box.
const scheduleApply = () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
@@ -1,22 +1,15 @@
import { useEffect } from "react";
import { isMacOS } from "@/lib/platform";
export type Shortcut = {
key: string; // e.g. "k", "Escape", "/"
cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
key: string;
cmd?: boolean;
shift?: boolean;
alt?: boolean;
// When true (default), preventDefault is called on a match.
preventDefault?: boolean;
};
// Listens for a keyboard shortcut on the window and invokes `callback` on
// match. Disable conditionally via `enabled` to avoid stealing keys while a
// dialog/panel is in the foreground.
export const useKeyboardShortcut = (
shortcut: Shortcut,
callback: () => void,
enabled = true,
) => {
export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
@@ -28,8 +21,8 @@ export const useKeyboardShortcut = (
if (shortcut.preventDefault !== false) e.preventDefault();
callback();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [
shortcut.key,
shortcut.cmd,
@@ -41,16 +34,13 @@ export const useKeyboardShortcut = (
]);
};
// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
export const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
export const formatShortcut = (shortcut: Shortcut): string => {
// navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
const mac = isMacOS();
const parts: string[] = [];
if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
return parts.join(isMac ? "" : "+");
return parts.join(mac ? "" : "+");
};
@@ -5,21 +5,18 @@ import { useConfirm } from "@/contexts/DialogContext.tsx";
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
// reachability is checked separately via checkManagementUrlReachable.
// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
export const URL_PATTERN = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
"(\\?[;&a-z\\d%_.~+=-]*)?" +
"(\\#[-a-z\\d_]*)?$",
String.raw`^(https?:\/\/)?` +
String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
String.raw`((\d{1,3}\.){3}\d{1,3}))` +
String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
String.raw`(\#[-a-z\d_]*)?$`,
"i",
);
// normalizeManagementUrl prefixes an https:// scheme when the user omits
// it. Empty input stays empty.
export function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
@@ -27,28 +24,18 @@ export function normalizeManagementUrl(input: string): string {
return `https://${trimmed}`;
}
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
// touch the network.
export function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
// isCloudManagementUrl reports whether the stored URL is the NetBird
// Cloud default (or an empty/unset URL, which the daemon also treats as
// cloud-defaulting on first boot).
export function isCloudManagementUrl(url: string): boolean {
if (!url || url.trim() === "") return true;
return url === CLOUD_MANAGEMENT_URL;
}
// checkManagementUrlReachable does a best-effort no-cors GET against the
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
// as unreachable. Self-hosted deployments behind internal-only DNS or
// with self-signed certs may return false positives — callers should
// surface this as a soft warning, not a hard block.
// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block.
export async function checkManagementUrlReachable(
url: string,
timeoutMs: number = 5000,
@@ -80,15 +67,10 @@ export function useManagementUrl() {
const { t } = useTranslation();
const confirm = useConfirm();
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
);
const [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl));
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
// Self-hosted reachability soft-check, mirrored from the onboarding /
// profile-creation flows: a failed probe is a non-blocking orange warning,
// and a second Save with the same URL goes through regardless.
const [checking, setChecking] = useState(false);
const [unreachable, setUnreachable] = useState(false);
@@ -99,19 +81,12 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
// Clear the stale warning whenever the target changes.
useEffect(() => {
setUnreachable(false);
}, [url, mode]);
}, [url, modeState]);
const setMode = async (next: ManagementMode) => {
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm via the in-app modal before applying.
if (next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL) {
const ok = await confirm({
title: t("settings.general.management.switchCloudTitle"),
description: t("settings.general.management.switchCloudMessage"),
@@ -119,7 +94,9 @@ export function useManagementUrl() {
});
if (!ok) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
console.error("save managementUrl failed", err),
);
return;
}
setModeState(next);
@@ -127,19 +104,14 @@ export function useManagementUrl() {
const normalizedUrl = normalizeManagementUrl(url);
const urlValid = isValidManagementUrl(url);
const targetUrl =
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const dirty = targetUrl !== config.managementUrl;
const showError =
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = async () => {
// Self-hosted: probe the server first. A failed probe surfaces a soft
// warning and bails; a second Save (unreachable already set) skips the
// re-check and saves anyway, so the user can override a false negative.
if (mode === ManagementMode.SelfHosted && !unreachable) {
if (modeState === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(targetUrl);
setChecking(false);
@@ -153,7 +125,7 @@ export function useManagementUrl() {
};
return {
mode,
mode: modeState,
setMode,
url,
setUrl,
@@ -5,13 +5,6 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
// Shared shell for every in-window route (main + settings). Owns the daemon-
// availability gate (via StatusProvider) and the providers every page needs.
// Order matters: SettingsContext depends on ProfileContext; ClientVersionContext
// reads StatusContext events.
//
// Page-specific surface (the main Header, the settings draggable strip,
// view-mode + nav-section providers) lives inside the page components, not here.
export const AppLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
@@ -9,9 +9,6 @@ type Props = {
className?: string;
};
// iOS-style push transition: incoming pane slides in from the right while
// the outgoing pane shifts slightly left. Same easing on both sides so
// they feel like one motion.
const PANEL_TRANSITION = {
duration: 0.32,
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
+1 -1
View File
@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
-30
View File
@@ -1,30 +0,0 @@
import { WindowManager } from "@bindings/services";
// Options for errorDialog. Kept as a {Title, Message} object so the many
// existing call sites read unchanged after the switch from the native OS
// MessageBox to the custom window below.
export type ErrorDialogOptions = {
Title: string;
Message: string;
};
// errorDialog surfaces a user-actionable failure. It opens the custom,
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
// more, despite the name.
//
// Why the native box is gone: on Windows a native MessageBox attached to a
// parent window disables that window (WS_DISABLED) for its lifetime, and the
// main window's WindowClosing hook hides instead of closing — the two raced
// and could leave the main window unable to process its close (X) button after
// an error was shown. The custom window has its own chrome and never touches
// another window's enabled state, so that class of bug is gone (and with it
// the old `Detached: true` Windows-only workaround, plus the warning/info/
// question wrappers that nothing called).
//
// Title and message must already be localised. Resolves as soon as the window
// is opened (it does not block until the user dismisses it), so `await`ing
// callers continue immediately after the dialog appears.
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
return WindowManager.OpenError(options.Title, options.Message);
}
+36 -33
View File
@@ -1,40 +1,34 @@
// Shared error formatter for native dialog bodies.
//
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
// wraps daemon errors in a ClientError struct exposed to the TS side as
// {code, short, long}. Short is already localised (Go reads the current
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
// Long always carries the unwrapped raw daemon message so the operator can
// see the JWT / mgm stack when the short text is too generic.
//
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
// .message holds the JSON-stringified payload and the structured object
// lives on .cause — Object.keys(err) is empty in that case. We therefore
// probe .cause first, then fall back to parsing .message as JSON, then
// to plain .message text for callers that still hand us a raw Error.
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
import { WindowManager } from "@bindings/services";
type ClientError = { short?: string; long?: string };
const asClientError = (obj: object): ClientError => {
const withCause = obj as { cause?: unknown };
if (withCause.cause && typeof withCause.cause === "object") {
return withCause.cause;
}
return obj;
};
const parseMessageJson = (message: unknown): ClientError | null => {
if (typeof message !== "string") return null;
const m = message.trim();
if (!m.startsWith("{") || !m.endsWith("}")) return null;
try {
const parsed: unknown = JSON.parse(m);
if (parsed && typeof parsed === "object") return asClientError(parsed);
} catch {
}
return null;
};
const extractClientError = (e: unknown): ClientError | null => {
if (!e || typeof e !== "object") return null;
const withCause = e as { cause?: unknown; message?: unknown };
if (withCause.cause && typeof withCause.cause === "object") {
return withCause.cause as { short?: string; long?: string };
return withCause.cause;
}
if (typeof withCause.message === "string") {
const m = withCause.message.trim();
if (m.startsWith("{") && m.endsWith("}")) {
try {
const parsed = JSON.parse(m);
if (parsed && typeof parsed === "object") {
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
return parsed.cause as { short?: string; long?: string };
}
return parsed as { short?: string; long?: string };
}
} catch {
// not JSON — fall through to plain-message handling
}
}
}
return null;
return parseMessageJson(withCause.message);
};
export const formatErrorMessage = (e: unknown): string => {
@@ -50,3 +44,12 @@ export const formatErrorMessage = (e: unknown): string => {
if (e instanceof Error) return e.message;
return String(e);
};
export type ErrorDialogOptions = {
Title: string;
Message: string;
};
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
return WindowManager.OpenError(options.Title, options.Message);
}
+20 -22
View File
@@ -1,19 +1,11 @@
export const formatBytes = (bytes: number, decimals: number = 2): string => {
try {
if (bytes === 0) return "0 B";
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
return (
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) +
" " +
sizes[i]
);
} catch {
return "0 B";
}
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
};
export const latencyColor = (ms: number): string => {
@@ -22,10 +14,7 @@ export const latencyColor = (ms: number): string => {
return "text-yellow-400";
};
export const formatRelative = (
unixSeconds: number,
nowMs: number = Date.now(),
): string | null => {
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
if (diff < 60) return `${diff}s ago`;
@@ -34,13 +23,22 @@ export const formatRelative = (
return `${Math.floor(diff / 86400)}d ago`;
};
// shortenDns drops the domain suffix off a DNS name, returning just the
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
// is operator-configurable so we keep everything before the first dot rather
// than matching against a known suffix. The full DNS name still lands on
// the clipboard via the copy helpers' explicit message prop.
// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
export const shortenDns = (fqdn: string | undefined | null): string => {
if (!fqdn) return "";
const dot = fqdn.indexOf(".");
return dot === -1 ? fqdn : fqdn.slice(0, dot);
};
// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
export const formatRemaining = (seconds: number): string => {
const s = Math.max(0, Math.trunc(seconds));
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const secs = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
return `${pad(minutes)}:${pad(secs)}`;
};
+7 -37
View File
@@ -5,21 +5,7 @@ import { Events } from "@wailsio/runtime";
import { Preferences, I18n } from "@bindings/services";
import { LanguageCode } from "@bindings/i18n/models.js";
// Vite glob-imports every shipped bundle at build time. The locales tree
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
// and the React app share one JSON source. Adding a language only
// requires dropping the new folder there and the row in `_index.json` —
// no edit to this file. The `eager: true` import keeps the bundles
// inlined in the main JS chunk, same shape as a static import. Path is
// relative on purpose — alias-based globs (`@/…`) silently resolve to an
// empty match in some Vite dev-mode setups. `server.fs.allow` in
// `vite.config.ts` whitelists the parent directory so the dev server
// serves the JSON.
//
// Each bundle is Chrome-extension JSON: every key maps to
// `{ message, description? }`. `description` exists only so Crowdin can
// show translator context — it's stripped here and i18next sees a flat
// key->message map exactly as before.
// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
type BundleEntry = { message: string; description?: string };
const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
"../../../i18n/locales/*/common.json",
@@ -28,7 +14,7 @@ const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
const resources: Record<string, { common: Record<string, string> }> = {};
for (const path in bundleModules) {
const match = path.match(/locales\/([^/]+)\/common\.json$/);
const match = /locales\/([^/]+)\/common\.json$/.exec(path);
if (match) {
const entries = bundleModules[path];
const messages: Record<string, string> = {};
@@ -39,11 +25,6 @@ for (const path in bundleModules) {
}
}
// detectBrowserLanguage walks navigator.language + navigator.languages
// and returns the first shipped bundle that matches. We try an exact
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
// null when nothing matches, so the caller can fall back to English.
function detectBrowserLanguage(available: string[]): string | null {
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
(tag): tag is string => typeof tag === "string" && tag.length > 0,
@@ -59,13 +40,7 @@ function detectBrowserLanguage(available: string[]): string | null {
return null;
}
// initI18n is awaited from app.tsx before the first render. The Go-side
// preferences.Store returns an empty language code when no preference has
// ever been persisted — that's the signal for first-run browser-locale
// detection. We pick a shipped bundle that matches navigator.language /
// navigator.languages (falling back to "en" when nothing matches) and
// fire-and-forget the persist via Preferences.SetLanguage so subsequent
// launches read the value back without re-detecting.
// An empty persisted language code is the Go-side signal for first run.
export async function initI18n(): Promise<void> {
const available = Object.keys(resources);
let language = "en";
@@ -79,13 +54,10 @@ export async function initI18n(): Promise<void> {
language = detectBrowserLanguage(available) ?? "en";
}
} catch {
// Daemon / preferences store unreachable — fall through with "en".
}
if (firstRun) {
// Fire-and-forget: the chosen language already drives this session;
// persisting just locks it in so the next launch skips detection.
void Preferences.SetLanguage(language as LanguageCode).catch(() => {});
Preferences.SetLanguage(language as LanguageCode).catch(() => {});
}
await i18next.use(initReactI18next).init({
@@ -102,14 +74,12 @@ export async function initI18n(): Promise<void> {
returnNull: false,
});
// The event name + payload type come from Wails' generated module
// augmentation (bindings/.../wails/v3/internal/eventdata.d.ts) which
// extends @wailsio/runtime's CustomEvents interface, so e.data is
// typed as UIPreferences without any hand-written cast.
Events.On("netbird:preferences:changed", (e) => {
const next = e.data?.language;
if (next && next !== i18next.language) {
void i18next.changeLanguage(next);
i18next.changeLanguage(next).catch((err: unknown) => {
console.error("changeLanguage failed", err);
});
}
});
}
+22 -13
View File
@@ -1,9 +1,5 @@
import { UILog } from "@bindings/services";
// Forwards browser console output and uncaught errors into the Go logrus
// pipeline. Originals still fire, so DevTools is unchanged; the Go
// --log-level does the gating.
type Level = "trace" | "debug" | "info" | "warn" | "error";
const METHOD_LEVELS: Record<string, Level> = {
@@ -15,10 +11,15 @@ const METHOD_LEVELS: Record<string, Level> = {
error: "error",
};
// Sources whose output is noise and shouldn't be forwarded.
const IGNORED_SOURCES = new Set(["welcome.ts"]);
const RATE_LIMIT = 50;
const RATE_WINDOW_MS = 1000;
let installed = false;
let inForward = false;
let windowStart = 0;
let windowCount = 0;
function format(args: unknown[]): string {
return args
@@ -34,27 +35,35 @@ function format(args: unknown[]): string {
.join(" ");
}
// First stack frame outside this module as "<file>:<line>" (best-effort;
// minified prod stacks degrade to chunk names).
function callerSource(): string {
const stack = new Error().stack;
if (!stack) return "";
for (const line of stack.split("\n").slice(1)) {
if (line.includes("/logs.ts")) continue;
const m = line.match(/([^/\\() ]+\.[a-z]+):(\d+):\d+/i);
const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line);
if (m) return `${m[1]}:${m[2]}`;
}
return "";
}
function forward(level: Level, args: unknown[]) {
if (inForward) return;
inForward = true;
try {
const now = Date.now();
if (now - windowStart >= RATE_WINDOW_MS) {
windowStart = now;
windowCount = 0;
}
if (++windowCount > RATE_LIMIT) return;
const source = callerSource();
if (IGNORED_SOURCES.has(source.split(":")[0])) return;
// Fire-and-forget; don't touch console here (would recurse).
void UILog.Log(level, source, format(args));
// Don't touch console here — it would recurse back into forward().
UILog.Log(level, source, format(args)).catch(() => {});
} catch {
// swallow
} finally {
inForward = false;
}
}
@@ -71,10 +80,10 @@ export function initLogForwarding() {
};
}
window.addEventListener("error", (e) => {
globalThis.addEventListener("error", (e) => {
forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
});
window.addEventListener("unhandledrejection", (e) => {
globalThis.addEventListener("unhandledrejection", (e) => {
forward("error", ["unhandled promise rejection:", e.reason]);
});
}
-416
View File
@@ -1,416 +0,0 @@
import { Network, PeerStatus } from "@bindings/services/models.js";
// Flip to true to override the live daemon data in the Peers / Resources /
// Exit Nodes tabs with the hand-crafted fixtures below. The fixtures are
// designed to surface overflow / truncation bugs (very long FQDNs, ICE
// endpoints, domain lists, network ids) and exercise the three connection
// states + relayed / P2P / Rosenpass variants.
export const MOCK_ENABLED = false;
// Replace `real` with `mock` when MOCK_ENABLED. Pulled out so call sites read
// as "use the live X, or the mock if enabled" without per-site if/else noise.
export const mockOr = <T>(real: T, mock: T): T => (MOCK_ENABLED ? mock : real);
const SECONDS = (s: number) => Math.floor(Date.now() / 1000) - s;
const MINUTES = (m: number) => SECONDS(m * 60);
const HOURS = (h: number) => MINUTES(h * 60);
const DAYS = (d: number) => HOURS(d * 24);
export const mockPeers: PeerStatus[] = [
// Kitchen-sink peer — every optional field populated at the same time so
// the detail panel renders every Row (latency + bytes + handshake + ICE
// local/remote + relay + networks + rosenpass + pubkey). Useful for
// eyeballing layout at maximum density.
new PeerStatus({
ip: "100.64.0.1",
ipv6: "fd00:dead:beef::1",
pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=",
connStatus: "Connected",
connStatusUpdateUnix: SECONDS(4),
relayed: true,
localIceCandidateType: "prflx",
remoteIceCandidateType: "relay",
localIceCandidateEndpoint: "[2001:db8:abcd:0012:0000:0000:0000:0001]:51820",
remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
fqdn: "everything-maxed-out-kitchen-sink-peer-with-long-name.subdomain.dev.example-company.netbird.cloud",
bytesRx: 87_654_321_098,
bytesTx: 43_210_987_654,
latencyMs: 213,
relayAddress:
"rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment/with/many/parts",
lastHandshakeUnix: SECONDS(3),
rosenpassEnabled: true,
networks: [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.100.0.0/16",
"10.50.50.0/24",
"2001:db8::/32",
"203.0.113.0/24",
"198.51.100.0/24",
],
}),
new PeerStatus({
ip: "100.64.0.2",
pubKey: "MockKeyAlpha000000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: MINUTES(7),
relayed: false,
localIceCandidateType: "host",
remoteIceCandidateType: "srflx",
localIceCandidateEndpoint: "192.168.1.10:51820",
remoteIceCandidateEndpoint: "203.0.113.42:51820",
fqdn: "alpha.netbird.cloud",
bytesRx: 12_345_678,
bytesTx: 9_876_543,
latencyMs: 18,
relayAddress: "",
lastHandshakeUnix: SECONDS(12),
rosenpassEnabled: false,
networks: ["10.0.0.0/24"],
}),
new PeerStatus({
ip: "100.64.0.3",
pubKey: "MockKeyLongFqdn000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: HOURS(2),
relayed: false,
localIceCandidateType: "srflx",
remoteIceCandidateType: "srflx",
localIceCandidateEndpoint: "198.51.100.7:41234",
remoteIceCandidateEndpoint: "203.0.113.99:51820",
fqdn: "very-long-hostname-with-many-segments-to-test-overflow-handling.subdomain.example-company.netbird.cloud",
bytesRx: 4_500_000_000,
bytesTx: 1_200_000_000,
latencyMs: 87,
relayAddress: "",
lastHandshakeUnix: SECONDS(45),
rosenpassEnabled: false,
networks: [],
}),
new PeerStatus({
ip: "100.64.0.4",
pubKey: "MockKeyConnecting00000000000000000000000000=",
connStatus: "Connecting",
connStatusUpdateUnix: SECONDS(3),
relayed: false,
localIceCandidateType: "",
remoteIceCandidateType: "",
localIceCandidateEndpoint: "",
remoteIceCandidateEndpoint: "",
fqdn: "edge-server.netbird.cloud",
bytesRx: 0,
bytesTx: 0,
latencyMs: 0,
relayAddress: "",
lastHandshakeUnix: 0,
rosenpassEnabled: false,
networks: [],
}),
new PeerStatus({
ip: "100.64.0.5",
pubKey: "MockKeyOfflineOld0000000000000000000000000=",
connStatus: "Idle",
connStatusUpdateUnix: DAYS(4),
relayed: false,
localIceCandidateType: "",
remoteIceCandidateType: "",
localIceCandidateEndpoint: "",
remoteIceCandidateEndpoint: "",
fqdn: "old-peer-offline.netbird.cloud",
bytesRx: 0,
bytesTx: 0,
latencyMs: 0,
relayAddress: "",
lastHandshakeUnix: DAYS(4),
rosenpassEnabled: false,
networks: [],
}),
new PeerStatus({
ip: "100.64.0.6",
pubKey: "MockKeyRelayed00000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: MINUTES(30),
relayed: true,
localIceCandidateType: "relay",
remoteIceCandidateType: "relay",
localIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443",
fqdn: "relayed-host-behind-strict-nat.corp.example.com",
bytesRx: 250_000,
bytesTx: 180_000,
latencyMs: 142,
relayAddress: "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment",
lastHandshakeUnix: SECONDS(8),
rosenpassEnabled: false,
networks: ["10.10.0.0/16", "192.168.50.0/24"],
}),
new PeerStatus({
ip: "100.64.0.7",
pubKey: "MockKeyIPv6000000000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: HOURS(1),
relayed: false,
localIceCandidateType: "host",
remoteIceCandidateType: "host",
localIceCandidateEndpoint: "[2001:db8:85a3:0000:0000:8a2e:0370:7334]:51820",
remoteIceCandidateEndpoint: "[fe80::1ff:fe23:4567:890a]:51820",
fqdn: "ipv6-only-host.netbird.cloud",
bytesRx: 999_999,
bytesTx: 1_500_000,
latencyMs: 64,
relayAddress: "",
lastHandshakeUnix: SECONDS(22),
rosenpassEnabled: false,
networks: [],
}),
new PeerStatus({
ip: "100.64.0.8",
pubKey: "MockKeyRosenpass0000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: MINUTES(15),
relayed: false,
localIceCandidateType: "prflx",
remoteIceCandidateType: "prflx",
localIceCandidateEndpoint: "10.0.0.50:51820",
remoteIceCandidateEndpoint: "203.0.113.200:51820",
fqdn: "rosenpass-secure.netbird.cloud",
bytesRx: 50_000,
bytesTx: 50_000,
latencyMs: 24,
relayAddress: "",
lastHandshakeUnix: SECONDS(5),
rosenpassEnabled: true,
networks: [],
}),
new PeerStatus({
ip: "100.64.0.9",
pubKey: "MockKeyMultiNet00000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: HOURS(5),
relayed: false,
localIceCandidateType: "host",
remoteIceCandidateType: "srflx",
localIceCandidateEndpoint: "10.0.0.51:51820",
remoteIceCandidateEndpoint: "203.0.113.201:51820",
fqdn: "multi-network-router.netbird.cloud",
bytesRx: 12_000_000_000,
bytesTx: 8_000_000_000,
latencyMs: 31,
relayAddress: "",
lastHandshakeUnix: SECONDS(2),
rosenpassEnabled: false,
networks: [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.100.0.0/16",
"10.50.50.0/24",
"2001:db8::/32",
],
}),
new PeerStatus({
ip: "100.64.0.10",
pubKey: "MockKeyA000000000000000000000000000000000000=",
connStatus: "Idle",
connStatusUpdateUnix: MINUTES(45),
relayed: false,
localIceCandidateType: "",
remoteIceCandidateType: "",
localIceCandidateEndpoint: "",
remoteIceCandidateEndpoint: "",
fqdn: "a.nb",
bytesRx: 0,
bytesTx: 0,
latencyMs: 0,
relayAddress: "",
lastHandshakeUnix: MINUTES(45),
rosenpassEnabled: false,
networks: [],
}),
// IPv6-NetBird-IP peer: the daemon assigns a v6 ULA inside fd00::/8 when
// running on a v6-native overlay. Exercises the row layout when the IP
// column is wide.
new PeerStatus({
ip: "fd00:1234:5678:abcd::42",
pubKey: "MockKeyV6Native0000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: MINUTES(20),
relayed: false,
localIceCandidateType: "host",
remoteIceCandidateType: "host",
localIceCandidateEndpoint: "[2001:db8:cafe:0001::10]:51820",
remoteIceCandidateEndpoint: "[2001:db8:cafe:0002::20]:51820",
fqdn: "ipv6-overlay-peer.netbird.cloud",
bytesRx: 800_000_000,
bytesTx: 950_000_000,
latencyMs: 41,
relayAddress: "",
lastHandshakeUnix: SECONDS(7),
rosenpassEnabled: false,
networks: ["2001:db8:1::/48", "2001:db8:2::/48", "fc00:dead:beef::/48"],
}),
// Dual-stack peer with mixed IPv4 / IPv6 ICE endpoints to test rows
// where local and remote columns differ in width.
new PeerStatus({
ip: "100.64.0.11",
pubKey: "MockKeyDualStack0000000000000000000000000000=",
connStatus: "Connected",
connStatusUpdateUnix: MINUTES(10),
relayed: false,
localIceCandidateType: "host",
remoteIceCandidateType: "srflx",
localIceCandidateEndpoint: "10.0.0.99:51820",
remoteIceCandidateEndpoint: "[2606:4700:4700:0000:0000:0000:0000:1111]:51820",
fqdn: "dual-stack.netbird.cloud",
bytesRx: 320_000_000,
bytesTx: 410_000_000,
latencyMs: 28,
relayAddress: "",
lastHandshakeUnix: SECONDS(14),
rosenpassEnabled: false,
networks: ["10.20.30.0/24", "2001:db8:beef::/48"],
}),
];
// Resources / routed networks. Mixes the three resource types the UI knows
// about (host = /32 or /128, subnet, domain) plus a deliberately overlapping
// pair to exercise the "overlapping" badge in NetworkFilters.
export const mockNetworkRoutes: Network[] = [
new Network({
id: "host-jenkins",
range: "10.0.0.1/32",
selected: true,
domains: [],
resolvedIps: {},
}),
new Network({
id: "subnet-corp-lan",
range: "192.168.1.0/24",
selected: true,
domains: [],
resolvedIps: {},
}),
new Network({
id: "subnet-wide-internal",
range: "10.0.0.0/8",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "subnet-overlap-a",
range: "172.16.0.0/16",
selected: true,
domains: [],
resolvedIps: {},
}),
new Network({
id: "subnet-overlap-b",
range: "172.16.0.0/16",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "dns-example",
range: "invalid Prefix",
selected: true,
domains: ["example.com"],
resolvedIps: { "example.com": ["93.184.216.34"] },
}),
new Network({
id: "dns-very-long-internal-domain-with-many-segments",
range: "invalid Prefix",
selected: false,
domains: [
"very-long-internal-service-name.dev.subdomain.example-company.internal",
"another-long-domain-for-overflow-testing.example-company.internal",
"third-long-domain-in-the-list.example-company.internal",
],
resolvedIps: {
"very-long-internal-service-name.dev.subdomain.example-company.internal": [
"10.20.30.40",
"10.20.30.41",
],
},
}),
new Network({
id: "ipv6-host",
range: "2001:db8::1/128",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "ipv6-subnet-corp",
range: "2001:db8:abcd::/48",
selected: true,
domains: [],
resolvedIps: {},
}),
new Network({
id: "ipv6-subnet-large",
range: "fc00:dead:beef::/32",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "dns-dual-stack",
range: "invalid Prefix",
selected: true,
domains: ["dual-stack.internal.example.com"],
resolvedIps: {
"dual-stack.internal.example.com": [
"10.20.30.40",
"10.20.30.41",
"2001:db8:abcd::40",
"2001:db8:abcd::41",
],
},
}),
new Network({
id: "dns-ipv6-only",
range: "invalid Prefix",
selected: false,
domains: ["ipv6-only-service.example.com"],
resolvedIps: {
"ipv6-only-service.example.com": ["2606:4700:4700::1111", "2606:4700:4700::1001"],
},
}),
];
// Exit nodes are radio-style (mutually exclusive in the UI). Include one
// selected and one absurdly-long-id to test row truncation.
export const mockExitNodes: Network[] = [
new Network({
id: "us-east-1",
range: "0.0.0.0/0",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "eu-central-frankfurt-primary",
range: "0.0.0.0/0",
selected: true,
domains: [],
resolvedIps: {},
}),
new Network({
id: "very-long-exit-node-region-identifier-with-multiple-segments-and-numbers-12345-test",
range: "0.0.0.0/0",
selected: false,
domains: [],
resolvedIps: {},
}),
new Network({
id: "ap-southeast-2",
range: "0.0.0.0/0",
selected: false,
domains: [],
resolvedIps: {},
}),
];
+20 -25
View File
@@ -1,42 +1,37 @@
import { System } from "@wailsio/runtime";
export type Platform = {
isWindows: boolean;
isMacOS: boolean;
isWindows: boolean;
isMacOS: boolean;
};
let cached: Platform | null = null;
export async function initPlatform(): Promise<void> {
if (cached) return;
if (cached) return;
// Sync getters read the page-injected `window._wails.environment`, which can
// be empty if the injection hasn't landed yet — keep them only as a fallback.
const syncIsMac = System.IsMac();
const syncIsWindows = System.IsWindows();
const syncIsMac = System.IsMac();
const syncIsWindows = System.IsWindows();
// The async Environment() call round-trips to the Go backend and is the
// authoritative source for OS.
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
try {
env = await System.Environment();
} catch (e) {
console.error("[platform] System.Environment() threw:", e);
}
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
try {
env = await System.Environment();
} catch (e) {
console.error("[platform] System.Environment() threw:", e);
}
// Prefer the async env.OS; fall back to the sync getters if it's missing.
const os = (env?.OS ?? "").toLowerCase();
cached = {
isWindows: os ? os === "windows" : syncIsWindows,
isMacOS: os ? os === "darwin" : syncIsMac,
};
const os = (env?.OS ?? "").toLowerCase();
cached = {
isWindows: os ? os === "windows" : syncIsWindows,
isMacOS: os ? os === "darwin" : syncIsMac,
};
}
function get(): Platform {
if (!cached) {
throw new Error("platform: initPlatform() must complete before sync getters are used");
}
return cached;
if (!cached) {
throw new Error("platform: initPlatform() must complete before sync getters are used");
}
return cached;
}
export const isWindows = (): boolean => get().isWindows;
+27
View File
@@ -0,0 +1,27 @@
// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
// on every status push (peers, networks, profiles). Re-sorting on each refresh would
// make rows jump around under the user, so instead:
// - items already on screen keep their existing order (from `prev`),
// - items that vanished are dropped,
// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
// Net effect: the only visible movement is new rows landing at the bottom.
//
// Must stay pure and idempotent: callers write the returned `order` into a ref
// during render (useMemo), so a rerun must reproduce the first pass — never
// branch on run count or read external mutable state.
export function reconcileOrder<T>(
prev: string[],
items: T[],
keyOf: (item: T) => string,
compareFresh: (a: T, b: T) => number,
): { order: string[]; items: T[] } {
const byKey = new Map(items.map((i) => [keyOf(i), i]));
const kept = prev.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = items
.filter((i) => !known.has(keyOf(i)))
.sort(compareFresh)
.map(keyOf);
const order = [...kept, ...fresh];
return { order, items: order.map((k) => byKey.get(k)!) };
}
@@ -13,9 +13,7 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
// Sustained gRPC failure during install is taken as success — the daemon
// gets restarted by the installer mid-flight, mirroring the legacy Fyne
// UI's branch in client/ui/update.go.
// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight).
const DAEMON_DOWN_GRACE_MS = 5000;
const WINDOW_WIDTH = 360;
@@ -36,79 +34,87 @@ export default function UpdateInProgressDialog() {
useEffect(() => {
let cancelled = false;
let done = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const start = Date.now();
let firstUnreachableAt: number | null = null;
const timer = setInterval(async () => {
if (cancelled) return;
const poll = async () => {
if (cancelled || done) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
clearInterval(timer);
done = true;
setPhase({ kind: "timeout" });
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
if (cancelled || done || phaseRef.current.kind !== "running") return;
firstUnreachableAt = null;
if (r.success) {
clearInterval(timer);
UpdateSvc.Quit();
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
if (r.errorMsg) {
clearInterval(timer);
done = true;
setPhase(mapInstallError(r.errorMsg));
return;
}
} catch {
if (cancelled || done || phaseRef.current.kind !== "running") return;
const now = Date.now();
if (firstUnreachableAt === null) {
firstUnreachableAt = now;
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
clearInterval(timer);
UpdateSvc.Quit();
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
}
}, POLL_INTERVAL_MS);
if (!cancelled && !done) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
if (timer) clearTimeout(timer);
};
}, []);
const isError = phase.kind !== "running";
const errorInfo = isError ? classifyPhase(phase, version, t) : null;
const updatingHeading = version
? t("update.overlay.updatingVersion", { version })
: t("update.overlay.updating");
return (
<ConfirmDialog ref={contentRef}>
{isError ? (
<SquareIcon
icon={XCircle}
className={"bg-red-500 [&_svg]:text-white"}
/>
<SquareIcon icon={XCircle} className={"bg-red-500 [&_svg]:text-white"} />
) : (
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
)}
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{isError
? errorInfo!.title
: version
? t("update.overlay.updatingVersion", { version })
: t("update.overlay.updating")}
{errorInfo ? errorInfo.title : updatingHeading}
</DialogHeading>
<DialogDescription>
{isError ? (
{errorInfo ? (
<>
{errorInfo!.description}
{errorInfo!.message && (
{errorInfo.description}
{errorInfo.message && (
<>
<br />
<span className={"first-letter:uppercase"}>
{errorInfo!.message}
{errorInfo.message}
</span>
</>
)}
@@ -126,9 +132,7 @@ export default function UpdateInProgressDialog() {
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={() =>
WindowManager.CloseInstallProgress().catch(console.error)
}
onClick={() => WindowManager.CloseInstallProgress().catch(console.error)}
>
{t("common.close")}
</Button>
@@ -9,7 +9,9 @@ import { cn } from "@/lib/cn";
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function UpdateVersionCard() {
@@ -62,7 +64,7 @@ export function UpdateVersionCard() {
);
}
function Card({ children, className }: { children: ReactNode; className?: string }) {
function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
return (
<div
className={cn(
@@ -75,11 +77,11 @@ function Card({ children, className }: { children: ReactNode; className?: string
);
}
function Title({ children }: { children: ReactNode }) {
function Title({ children }: Readonly<{ children: ReactNode }>) {
return <p className={"text-sm font-semibold"}>{children}</p>;
}
function Link({ url, children }: { url: string; children: ReactNode }) {
function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
return (
<button
type={"button"}
@@ -13,16 +13,6 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 380;
// ErrorDialog is the app's error surface — a frameless, always-on-top
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
// native OS MessageBox. Title and message arrive as query params (see
// services/windowmanager.go errorDialogURL); both are caller-localised. The
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
// it's repeated as the heading here so it stays visible on macOS, where the
// hidden-inset title bar doesn't render the chrome title. The single Close
// button (and the Escape key) dismisses the window via WindowManager.CloseError
// — the Go side destroys it on close.
export default function ErrorDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
@@ -35,15 +25,12 @@ export default function ErrorDialog() {
WindowManager.CloseError().catch(console.error);
}, []);
// Escape closes — keyboard-accessible cancellation, matching the native
// dialog's behaviour. The primary button is autoFocused below so Enter
// also dismisses.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [close]);
return (
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
@@ -12,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
@@ -34,12 +33,7 @@ export default function LoginWaitingForBrowserDialog() {
[t],
);
// Open the system browser only after the dialog has mounted (which
// means useAutoSizeWindow has called Window.Show). startLogin used to
// fire OpenURL itself but the browser typically beat React's mount
// and landed on top of the still-hidden NetBird popup. The ref guard
// keeps StrictMode's intentional double-invoke in dev (and any future
// remount) from launching two browser tabs.
// Open the browser only after mount, or it lands on top of the still-hidden popup.
useEffect(() => {
if (!uri || openedRef.current) return;
openedRef.current = true;
@@ -52,20 +46,17 @@ export default function LoginWaitingForBrowserDialog() {
}, [uri, reportOpenFailure]);
const cancel = useCallback(() => {
void Events.Emit(EVENT_CANCEL);
Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel", err),
);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon
icon={Loader2}
className={"[&_svg]:animate-spin"}
/>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{t("browserLogin.title")}
</DialogHeading>
<DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading>
<DialogDescription>
{t("browserLogin.notSeeing")}{" "}
<button
@@ -3,70 +3,29 @@ import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { errorDialog } from "@/lib/dialogs.ts";
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 { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { contentTop } from "@/components/empty-state/EmptyState";
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
// device code.
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
// The tray emits it from menu items so the React UI (which owns the SSO
// orchestration and the browser-login window) takes over.
const EVENT_TRIGGER_LOGIN = "trigger-login";
// loginInFlight is a module-level guard. SSO login involves multiple async
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
// call would race on the daemon's pending device code and on the popup
// window's singleton, leading to confusing UX. Calls past the first are
// dropped silently — the first invocation owns the flow until it settles.
let loginInFlight = false;
// startLogin drives the daemon's SSO login end-to-end:
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
// 3. Race WaitSSOLogin vs the user clicking Cancel.
// 4. On success: Connection.Up.
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
// drops the abandoned device code (avoids an Idle blink on the tray).
//
// Errors that aren't user cancellations surface via errorDialog. Concurrent
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
// all exit paths so a stray popup doesn't outlive the flow.
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
// instant the flow itself is over (success, cancel, or error) — BEFORE the
// error dialog is shown. Every guard that gates re-arming the login path
// (the module-level loginInFlight here, and the caller's React-level
// loginGuard via onSettled) must be released at that point, never gated on
// the dialog.
//
// Why the dialog must be outside the guards: the native Windows MessageBox
// disables its parent for its whole lifetime, and the main window's
// WindowClosing hook hides instead of closing — the two race and the dialog
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
// release awaited the dialog, that guard would stay held for as long as the
// box is open (or forever if it hangs), and every later Connect / tray
// trigger-login would be silently dropped at the guard check until the
// client is restarted. That was the original "can't log in again until
// restart" bug.
// 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) {
// The caller's guard must still be released — it was set before this
// call. Without this the React-level loginGuard would wedge on a
// dropped concurrent invocation.
onSettled?.();
return;
}
@@ -117,7 +76,7 @@ async function startLogin(onSettled?: () => void): Promise<void> {
if (cancelled) {
waitPromise.cancel?.();
void waitPromise.catch(() => {});
waitPromise.catch(() => {});
return;
}
}
@@ -128,9 +87,6 @@ async function startLogin(onSettled?: () => void): Promise<void> {
if (!cancelled) loginError = e;
} finally {
offCancel?.();
// Release every guard before any UI work below — never gate re-arming
// the login path on a dialog that can hang. loginInFlight is ours;
// onSettled releases the caller's React-level loginGuard.
loginInFlight = false;
onSettled?.();
}
@@ -150,8 +106,6 @@ enum ConnectionState {
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
@@ -161,8 +115,6 @@ const STATUS_KEY: Record<ConnectionState, string> = {
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
@@ -176,37 +128,18 @@ export const MainConnectionStatusSwitch = () => {
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
const unreachable = daemonState === "DaemonUnavailable";
// Tracks an in-flight user action so we can show a transitional label
// and disable the switch without lying about the daemon's actual state.
//
// "connect" — user clicked Up; waiting for daemon to settle
// "logging-in" — SSO flow is driving the daemon (Login → browser →
// Up). Keeps the switch in "Connecting" while the
// daemon flaps NeedsLogin → Idle → NeedsLogin →
// Connecting that Login's internal Down causes.
// "disconnect" — user clicked Down; waiting for daemon to settle
type Action = "connect" | "logging-in" | "disconnect" | null;
const [action, setAction] = useState<Action>(null);
// Guards startLogin from being fired twice in parallel (effect path +
// tray trigger-login + handleSwitch). startLogin's module-level
// loginInFlight already drops the second daemon call, but its
// Promise would resolve immediately and the .finally clear our
// "logging-in" latch while the first flow is still running.
const loginGuard = useRef(false);
const driveLogin = useCallback(() => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
// Release the React-level guard via onSettled — fired the instant the
// flow ends, before startLogin's error dialog. Gating it on the full
// startLogin() promise would keep loginGuard wedged for the whole
// dialog lifetime, leaving the tray's trigger-login dropped at the
// guard check until the client is restarted.
void startLogin(() => {
loginGuard.current = false;
setAction(null);
void refresh();
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
});
}, [refresh]);
@@ -227,11 +160,6 @@ export const MainConnectionStatusSwitch = () => {
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
// NeedsLogin / SessionExpired without an in-flight user
// action read as Disconnected — the switch only flips to
// Connecting once the user (or the tray's trigger-login)
// kicks off the SSO flow, which sets action = "logging-in"
// and is handled by the guard above.
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;
@@ -254,11 +182,6 @@ export const MainConnectionStatusSwitch = () => {
Message: errorMessage(e),
});
}
// Don't clear action here on success — the daemon's first status
// push (Connecting / NeedsLogin / ...) may land after Up returns,
// and clearing eagerly would let connState fall back to
// Disconnected for one render. The effect below clears the latch
// once daemonState catches up.
};
const disconnect = async () => {
@@ -274,23 +197,10 @@ export const MainConnectionStatusSwitch = () => {
Message: errorMessage(e),
});
}
// See connect() above — clear via the effect, not eagerly.
};
// Tracks whether the daemon has entered Connecting during the
// current "connect" action. Lets us distinguish "still waiting for
// the daemon to start" (Idle → Idle) from "the connect flow was
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
// while the UI was Connecting). Reset whenever action returns to
// null.
const sawConnectingRef = useRef(false);
// Release the action latch when the daemon settles on a terminal
// state for the user's intent — and, in the connect → NeedsLogin
// case, hand off to driveLogin so the user doesn't have to click
// the switch a second time. "logging-in" is cleared by driveLogin's
// .finally, not here: Login's internal Down makes the daemon flap
// through Idle, which would otherwise look like a terminal state.
useEffect(() => {
if (action === null) {
sawConnectingRef.current = false;
@@ -308,10 +218,6 @@ export const MainConnectionStatusSwitch = () => {
setAction(null);
return;
}
// Cancelled externally (e.g. tray Disconnect during our
// Connecting): the daemon went back to Idle after we'd
// observed Connecting. Clear the latch so the UI stops
// showing Connecting forever.
if (sawConnectingRef.current && daemonState === "Idle") {
setAction(null);
}
@@ -324,11 +230,6 @@ export const MainConnectionStatusSwitch = () => {
}
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
// The tray clicks Connect via its own gRPC call. When the daemon flips
// to NeedsLogin afterwards, the tray emits trigger-login so the React
// UI (which owns the SSO orchestration and the browser-login window)
// takes over. driveLogin's loginGuard handles concurrent tray +
// switch clicks.
useEffect(() => {
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
driveLogin();
@@ -359,9 +260,6 @@ export const MainConnectionStatusSwitch = () => {
const isOn =
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
// When the daemon hangs in Connecting/Disconnecting, give the user an
// escape hatch: after the delay, the switch becomes clickable again so a
// tap fires Connection.Down (plus cancels any in-flight SSO flow).
const [canForceCancel, setCanForceCancel] = useState(false);
useEffect(() => {
if (!isTransitioning) {
@@ -374,7 +272,9 @@ export const MainConnectionStatusSwitch = () => {
const forceCancel = async () => {
if (action === "logging-in") {
void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL);
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel failed", err),
);
}
WindowManager.CloseBrowserLogin().catch(() => {});
setAction("disconnect");
@@ -397,14 +297,8 @@ export const MainConnectionStatusSwitch = () => {
return (
<div
className={cn(
// Anchored from the top so the FQDN/IP lines below the toggle
// can grow into a popover-aware layout without shifting the
// toggle itself (justify-center would slide everything up
// when the IP line is hidden during Disconnected).
"flex flex-col h-full w-full items-center gap-4",
"relative top-[11.7rem]",
)}
className={cn("flex flex-col h-full w-full items-center gap-4", "relative")}
style={{ top: contentTop("11.7rem") }}
>
<img
src={netbirdFullLogo}
@@ -451,9 +345,6 @@ export const MainConnectionStatusSwitch = () => {
);
};
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
// a popover containing both v4 and v6, each independently click-to-copy.
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const [open, setOpen] = useState(false);
const hasV6 = !!ipv6;
@@ -489,10 +380,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
<button
type={"button"}
className={cn(
// relative so the chevron can be absolutely
// positioned alongside without widening the trigger
// — keeps the IP text centred in its parent and
// lets the popover centre cleanly on it.
"group relative inline-flex items-center outline-none cursor-default",
"transition-colors",
)}
@@ -540,9 +427,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
);
};
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
// icon on the right that flips to a checkmark briefly after a successful copy.
const IpRow = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false);
const handleClick = async () => {
@@ -551,9 +435,7 @@ const IpRow = ({ value }: { value: string }) => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch {
// ignore
}
} catch {}
};
return (
<button
@@ -8,15 +8,13 @@ import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const { exitNodes, toggleExitNode } = useNetworks();
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
@@ -27,19 +25,23 @@ export const MainExitNodeSwitcher = () => {
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active) void toggleExitNode(active.id, true);
if (active)
toggleExitNode(active.id, true).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
return;
}
if (active && active.id === next) return;
void toggleExitNode(next, false);
if (active?.id === next) return;
toggleExitNode(next, false).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
};
const title = active ? active.id : t("exitNodes.card.title");
const description = !hasAny
? t("exitNodes.empty.title")
: active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
const activeDescription = active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
const description = hasAny ? activeDescription : t("exitNodes.empty.title");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
@@ -36,22 +36,18 @@ export const MainHeader = () => {
const openSettings = useCallback(() => {
setMenuOpen(false);
void WindowManager.OpenSettings("").catch(() => {});
WindowManager.OpenSettings("").catch(() => {});
}, []);
// Mirror the tray's Settings accelerator so the keystroke works while
// the main window has focus too. The tray's SetAccelerator paints the
// glyph on macOS/Linux but only fires the menu item — it can't reach the
// webview's input loop, hence the parallel React-side listener.
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
const openAbout = () => {
setMenuOpen(false);
void WindowManager.OpenSettings("about").catch(() => {});
WindowManager.OpenSettings("about").catch(() => {});
};
const openManageProfiles = () => {
void WindowManager.OpenSettings("profiles").catch(() => {});
WindowManager.OpenSettings("profiles").catch(() => {});
};
const selectMode = (mode: ViewMode) => {
@@ -130,16 +126,6 @@ export const MainHeader = () => {
</div>
);
// The inner grid is locked to 356px (the default-mode content width:
// 380px window 12px px-3 each side). It stays left-anchored regardless
// of window size, so the profile keeps the exact same absolute X
// position when the user flips to advanced view. The settings button is
// pulled out as an absolute, right-anchored element so it tracks the
// window's right edge in both modes.
// Header height matches the Settings window's top traffic-light strip
// so the right panel ends up the same height in both windows. The h-10
// of the inner buttons (profile trigger, more-vertical) defines the
// natural height; the strip in SettingsLayout is sized to mirror it.
return (
<div
className={cn(
@@ -147,8 +133,7 @@ export const MainHeader = () => {
"flex items-center h-12 top-3",
)}
>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
className={cn(
@@ -13,7 +13,7 @@ import { Networks } from "@/modules/main/advanced/networks/Networks";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
import {isWindows} from "@/lib/platform.ts";
import { isWindows } from "@/lib/platform.ts";
export const MainPage = () => {
return (
@@ -34,10 +34,14 @@ const MainBody = () => {
return (
<div className={"wails-draggable flex flex-1 min-h-0"}>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div
className={cn(
"relative flex flex-col items-center shrink-0 ",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<MainConnectionStatusSwitch />
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
@@ -19,7 +19,7 @@ type Props = {
};
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
@@ -34,7 +34,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
};
return (
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
@@ -62,21 +62,10 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
<span className={"w-4 shrink-0 flex items-center justify-center"}>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
@@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
@@ -12,37 +13,26 @@ import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { mockNetworkRoutes, mockOr } from "@/lib/mock";
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
// The daemon stringifies route.Network via netip.Prefix.String(). For
// DNS-based routes the prefix is the zero value, which Go renders as
// "invalid Prefix". Those rows render their domain + resolved IPs instead.
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
const INVALID_PREFIX = "invalid Prefix";
const isDnsRoute = (n: Network): boolean =>
n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
// Mirror management's NetworkResourceType (resource.go GetResourceType):
// a CIDR is a host when its prefix length equals the address width
// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with
// domains attached are domain resources.
type ResourceType = "host" | "subnet" | "domain";
const isHostCidr = (cidr: string): boolean => {
const [addr, bitsStr] = cidr.split("/");
if (!addr || !bitsStr) return false;
const bits = Number(bitsStr);
// IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'.
const isV6 = addr.includes(":");
return isV6 ? bits === 128 : bits === 32;
};
const resourceTypeOf = (n: Network): ResourceType => {
if (isDnsRoute(n)) return "domain";
// n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs
// come comma-joined, but those are filtered out upstream — guard
// defensively by inspecting only the first segment.
const primary = n.range.split(",")[0].trim();
return isHostCidr(primary) ? "host" : "subnet";
};
@@ -53,9 +43,6 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
return NetworkIcon;
};
// Map every range string -> ids of CIDR routes that share it. Domain routes
// are skipped (they overlap on domain, not prefix). Single-entry buckets
// aren't overlaps.
const buildOverlapMap = (
routes: { id: string; range: string; domains: string[] }[],
): Map<string, string[]> => {
@@ -77,8 +64,7 @@ export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
@@ -106,26 +92,19 @@ export const Networks = () => {
[networkRoutes, overlapById],
);
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it, and newly discovered routes append at
// the end (sorted active-first / by-id among themselves). The ref carries
// the previous order across renders so the reconciliation is synchronous
// with networkRoutes updates (no useEffect lag → no visual hop).
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = networkRoutes
.filter((r) => !known.has(r.id))
.sort((a, b) => {
const { order, items } = reconcileOrder(
orderRef.current,
networkRoutes,
(r) => r.id,
(a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((r) => r.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
},
);
orderRef.current = order;
return items;
}, [networkRoutes]);
const filtered = useMemo(() => {
@@ -158,13 +137,15 @@ export const Networks = () => {
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
void setNetworksSelected(
setNetworksSelected(
filtered.map((r) => r.id),
false,
);
).catch((err: unknown) => console.error("disable all networks failed", err));
} else {
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
void setNetworksSelected(ids, true);
setNetworksSelected(ids, true).catch((err: unknown) =>
console.error("enable all networks failed", err),
);
}
};
@@ -247,17 +228,26 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
{data.map((n) => (
<li
key={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
"wails-no-draggable",
)}
>
<button
type={"button"}
aria-label={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={"absolute inset-0 cursor-pointer"}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={n.id}>
<CopyToClipboard message={n.id} className={"pointer-events-auto"}>
<TruncatedText
text={n.id}
className={
@@ -268,7 +258,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
</div>
<Subtitle network={n} />
</div>
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
<div className={"shrink-0 self-center relative"}>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
@@ -388,25 +378,28 @@ type ToggleProps = {
mixed?: boolean;
};
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<button
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
className={cn(
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
>
<span
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return (
<button
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
/>
</button>
);
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
)}
/>
</button>
);
};
@@ -30,7 +30,6 @@ import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { mockOr, mockPeers } from "@/lib/mock";
import { peerStatusLabelKey } from "./Peers";
const DEFAULT_TRANSITION: Transition = {
@@ -60,12 +59,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
// Keep `selected` in sync with the live peer list so the panel reflects
// status / latency / byte updates without re-opening. If the peer
// disappears, close the panel.
useEffect(() => {
if (!selected) return;
const peers = mockOr(status?.peers ?? [], mockPeers);
const peers = status?.peers ?? [];
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
@@ -74,11 +70,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// Re-render every second so the relative timestamps in PeerDetails
// ("Xs ago", "Xm ago") tick. The daemon updates latency/bytes/handshake
// silently without pushing a fresh status snapshot — see
// status.go UpdateLatency / UpdateWireGuardPeerState — so without this
// the displayed age would freeze for a stably-Connected peer.
// Daemon updates latency/bytes/handshake without pushing a fresh status
// snapshot, so tick locally to keep relative timestamps live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!selected) return;
@@ -90,9 +83,6 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
// Refresh over the unix socket usually completes in <50ms, faster
// than the spin animation can show. Hold the spinning state for at
// least one full rotation so the click feels responsive.
const MIN_SPIN_MS = 600;
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
try {
@@ -102,14 +92,13 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
}
}, [refresh, refreshing]);
// Esc closes the panel.
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
return (
@@ -371,10 +360,6 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
);
};
// Single "View {n}" badge with a chevron that opens a click popover listing
// each routed resource on its own line with a click-to-copy entry. Avoids
// the repetitive "first item + N more" pattern given the row already has a
// "Resources" label and Layers icon.
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
@@ -19,7 +19,7 @@ type Props = {
};
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
@@ -34,7 +34,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
};
return (
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
@@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
@@ -13,7 +14,6 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { mockOr, mockPeers } from "@/lib/mock";
import { PeerFilters, StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
@@ -29,8 +29,6 @@ const dotClass = (connStatus: string): string => {
}
};
// The daemon reports "Idle" for not-connected peers; surface it as
// "Disconnected" in the UI. Connected / Connecting pass through.
export const peerStatusLabelKey = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
@@ -49,15 +47,12 @@ export const Peers = () => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
// Peers is only mounted in advanced view (see pages/Main.tsx), so a
// mount-time focus is equivalent to "focus when the user toggles into
// advanced view".
useEffect(() => {
searchRef.current?.focus();
}, []);
const isConnected = status?.status === "Connected";
const peers = mockOr(status?.peers ?? [], mockPeers);
const peers = status?.peers ?? [];
const counts = useMemo<Record<StatusFilter, number>>(() => {
const online = peers.filter((p) => isOnline(p.connStatus)).length;
@@ -68,34 +63,21 @@ export const Peers = () => {
};
}, [peers]);
// Initial order: online-first, then alphabetically by fqdn / ip. Once
// peers have settled, positions become sticky — a peer flipping
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
// peers append at the end (sorted online-first / by-name among
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
// pattern.
//
// Stay in live-sort mode until every peer has reached a stable state
// (Connected or Idle). The daemon emits all peers as "Connecting" right
// after Up, which collapses the online-first sort into pure
// alphabetical — committing then would lock that incorrect order and
// the list would stay alphabetical even after every peer becomes
// Connected. Once nothing is Connecting we commit and go sticky.
// Stay in live-sort until every peer is stable. Right after Up the daemon
// emits all peers as "Connecting"; committing then would lock that
// alphabetical-only order forever.
const orderRef = useRef<string[]>([]);
const stickyRef = useRef(false);
const ordered = useMemo(() => {
const sortOnlineFirst = (list: PeerStatus[]) =>
[...list].sort((a, b) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
});
const compare = (a: PeerStatus, b: PeerStatus) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
};
// Reset on empty (Disconnect → reconnect) so the next session
// re-sorts from scratch instead of replaying the stale orderRef.
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
@@ -103,7 +85,7 @@ export const Peers = () => {
}
if (!stickyRef.current) {
const sorted = sortOnlineFirst(peers);
const sorted = [...peers].sort(compare);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
@@ -111,15 +93,9 @@ export const Peers = () => {
return sorted;
}
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
(p) => p.pubKey,
);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((k) => byKey.get(k)!);
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
orderRef.current = order;
return items;
}, [peers]);
const filtered = useMemo(() => {
@@ -190,24 +166,36 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
return (
<li
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-default",
"wails-no-draggable",
)}
>
<button
type={"button"}
aria-label={shortenDns(peer.fqdn)}
onClick={() => setSelected(peer)}
className={"absolute inset-0 cursor-default"}
/>
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={peer.fqdn}>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
@@ -217,7 +205,10 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</CopyToClipboard>
</div>
<div>
<CopyToClipboard message={peer.ip}>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
@@ -227,7 +218,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums",
"shrink-0 self-center text-xs tabular-nums relative pointer-events-none",
latencyColor(peer.latencyMs),
)}
>
@@ -237,7 +228,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
<ChevronRightIcon
size={16}
className={cn(
"shrink-0 self-center text-nb-gray-300",
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
@@ -19,10 +19,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/cn";
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
// just like "test" does. The list is scanned in order, so more-specific
// tokens (e.g. "staging" before "stage") should come first when they share
// roots.
// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage").
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
[/(default|personal)/i, UserCircle],
[/(work|business|office|company|corp|corporate)/i, Briefcase],
@@ -18,19 +18,11 @@ import {
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
// onCreate receives the sanitized profile name and the management URL the
// user picked (the cloud default for Cloud mode, the normalized self-
// hosted URL otherwise).
onCreate: (name: string, managementUrl: string) => void;
};
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
// `_` and `-` survive on the Go side. We additionally lowercase and convert
// spaces to `-` so what the user sees in the input is exactly what the
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
// ghost row and breaks subsequent delete.
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
const sanitizeProfileInput = (value: string): string =>
value
.toLowerCase()
@@ -46,9 +38,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
const [url, setUrl] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
// unreachable: soft warning. A second submit with the same URL proceeds
// anyway (matches the onboarding management step's behaviour for self-
// hosted servers behind internal DNS / VPN).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
@@ -65,8 +54,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
}
}, [open]);
// Reset the URL warnings whenever the user edits the URL or flips mode —
// otherwise a stale warning lingers next to a just-corrected value.
useEffect(() => {
setUrlError(null);
setUnreachable(false);
@@ -100,9 +87,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: soft warning + bail. A second submit with the
// same URL skips re-checking (unreachable still true) so the user can
// proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
@@ -117,16 +101,14 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
if (nameError) setNameError(null);
};
// Live syntactic feedback: flag a non-empty, malformed URL as the user
// types instead of waiting for submit. Empty is not an error yet (handled
// on submit); the unreachable soft-warning only applies once syntax is OK.
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
mode === ManagementMode.SelfHosted &&
trimmedUrl !== "" &&
!isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
@@ -178,7 +160,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
<Input
ref={urlRef}
autoFocus
placeholder={t("settings.general.management.urlPlaceholder")}
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
@@ -1,6 +1,5 @@
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog } from "@/lib/dialogs.ts";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
@@ -10,7 +9,7 @@ import type { Profile } from "@bindings/services/models.js";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -59,79 +58,77 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const displayName = activeProfile || t("profile.selector.loading");
return (
<>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ProfileTriggerButton name={displayName} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="center"
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ProfileTriggerButton name={displayName} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="center"
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-60 px-1">
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSelect={handleSelect}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="-mx-1 h-px bg-nb-gray-910" />
</>
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-60 px-1">
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSelect={handleSelect}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="-mx-1 h-px bg-nb-gray-910" />
</>
)}
<div className={"pt-1"}>
<Command.Item
value={MANAGE_VALUE}
onSelect={handleManage}
disabled={!onManageProfiles}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</>
<div className={"pt-1"}>
<Command.Item
value={MANAGE_VALUE}
onSelect={handleManage}
disabled={!onManageProfiles}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -186,7 +183,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
>
<div className="flex flex-col min-w-0 flex-1 leading-tight">
<span className="truncate">{profile.name}</span>
{showEmail && <TruncatedEmail email={profile.email!} />}
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
{isActive && (
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
@@ -1,6 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog } from "@/lib/dialogs.ts";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
@@ -17,7 +16,8 @@ import { SetConfigParams } from "@bindings/services/models.js";
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { reconcileOrder } from "@/lib/sorting";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE = "default";
@@ -38,38 +38,22 @@ export function ProfilesTab() {
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
// The display order is established once — the active profile first, then
// the rest alphabetically — and then held stable for the lifetime of the
// window. Switching profiles must only flip the "active" badge, never
// reorder the rows (otherwise the row the user just clicked jumps to the
// top under their cursor). New profiles append at the end; removed ones
// drop out. `orderRef` is the source of truth for row order; the active
// badge is derived live from `activeProfile`.
// Order is held stable so switching only flips the badge, never reorders rows
// (else the clicked row jumps to the top under the cursor).
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const present = new Set(profiles.map((p) => p.name));
if (orderRef.current.length === 0) {
// First population: active-first, then alphabetical.
orderRef.current = [...profiles]
.sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
})
.map((p) => p.name);
} else {
// Preserve the established order; drop removed, append added.
const kept = orderRef.current.filter((n) => present.has(n));
const added = profiles
.map((p) => p.name)
.filter((n) => !orderRef.current.includes(n))
.sort((a, b) => a.localeCompare(b));
orderRef.current = [...kept, ...added];
}
const byName = new Map(profiles.map((p) => [p.name, p]));
return orderRef.current
.map((n) => byName.get(n))
.filter((p): p is Profile => p !== undefined);
const { order, items } = reconcileOrder(
orderRef.current,
profiles,
(p) => p.name,
(a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
},
);
orderRef.current = order;
return items;
}, [profiles, activeProfile]);
const guarded = async (title: string, fn: () => Promise<void>) => {
@@ -120,27 +104,17 @@ export function ProfilesTab() {
};
const handleCreate = async (name: string, managementUrl: string) => {
try {
await guarded(i18next.t("profile.error.createTitle"), async () => {
await addProfile(name);
// Only persist a management URL for self-hosted; a fresh profile
// already defaults to NetBird Cloud, so writing the cloud URL
// would be a no-op. Do it before switching so any reconnect the
// switch triggers already targets the right deployment. SetConfig
// is keyed by profile name, so it writes the new profile even
// though it isn't active yet (adminUrl left empty — the daemon
// keeps its loaded value).
// SetConfig is keyed by profile name, so it writes the not-yet-active
// profile. Write before switching so any reconnect targets the right deployment.
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: name, username, managementUrl }),
);
}
await switchProfile(name);
} catch (e) {
await errorDialog({
Title: i18next.t("profile.error.createTitle"),
Message: formatErrorMessage(e),
});
}
});
};
return (
@@ -193,7 +167,11 @@ export function ProfilesTab() {
</SettingsBottomBar>
</SectionGroup>
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
<ProfileCreationModal
open={newOpen}
onOpenChange={setNewOpen}
onCreate={handleCreate}
/>
</div>
);
}
@@ -230,12 +208,16 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
/>
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
<div className={"flex items-center gap-2 min-w-0"}>
<span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}>
<span
className={
"truncate font-medium text-nb-gray-100 select-text cursor-text"
}
>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div>
{showEmail && <TruncatedEmail email={profile.email!} />}
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</div>
</td>
@@ -265,7 +247,10 @@ const TruncatedEmail = ({ email }: { email: string }) => {
}, [email]);
const span = (
<span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}>
<span
ref={ref}
className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}
>
{email}
</span>
);
@@ -294,11 +279,10 @@ const RowActions = ({
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
const deleteLabel = isDefault
? t("profile.delete.disabledDefault")
: isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
const nonDefaultDeleteLabel = isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
@@ -329,10 +313,8 @@ type ActionIconButtonProps = {
icon: typeof CircleMinus;
onClick: () => void;
variant?: "default" | "danger";
/** When true the button still occupies space (preserves row layout)
* but is invisible and non-interactive. */
/** Occupies space but invisible and non-interactive (preserves row layout). */
hidden?: boolean;
/** When true the button is visible but non-interactive (greyed out). */
disabled?: boolean;
};
@@ -359,7 +341,8 @@ const ActionIconButton = ({
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
hidden && "opacity-0 pointer-events-none",
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
disabled &&
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)}
>
<Icon size={16} />
@@ -368,9 +351,7 @@ const ActionIconButton = ({
if (hidden) return button;
return (
<Tooltip
content={
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
}
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
side={"top"}
>
{button}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { AlertCircleIcon, ClockIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
@@ -12,30 +11,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
// Below this, the situation is genuinely "soon" and the title/description
// uses the urgent wording. Above it (e.g. opened with hours remaining), the
// "later" variant drops the urgency cue so it doesn't read absurdly.
const SOON_THRESHOLD_SECONDS = 60 * 60;
// Renders the countdown with only the units that matter: mm:ss under an
// hour, hh:mm:ss under a day, dd:hh:mm:ss otherwise. Two-digit zero pad
// throughout so columns don't jump as digits roll over.
function formatRemaining(seconds: number): string {
const s = Math.max(0, seconds | 0);
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const secs = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
return `${pad(minutes)}:${pad(secs)}`;
}
export default function SessionExpirationDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
@@ -49,27 +31,31 @@ export default function SessionExpirationDialog() {
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const busyRef = useRef(busy);
busyRef.current = busy;
const expired = remaining <= 0;
const soon = remaining <= SOON_THRESHOLD_SECONDS;
const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater");
const activeDescription = soon
? t("sessionExpiration.description")
: t("sessionExpiration.descriptionLater");
useEffect(() => {
setRemaining(initialSeconds);
}, [initialSeconds]);
useEffect(() => {
if (remaining <= 0) return;
const id = window.setInterval(() => {
const id = globalThis.setInterval(() => {
setRemaining((s) => (s <= 1 ? 0 : s - 1));
}, 1000);
return () => window.clearInterval(id);
}, [remaining]);
return () => globalThis.clearInterval(id);
}, [initialSeconds]);
// Auto-close when the daemon flips back to Connected — covers extend
// flows started from outside this window (tray notification action,
// another UI surface) so the user isn't left staring at a stale dialog.
// Suppressed while `busy`: the tunnel stays up so Connected re-fires for
// unrelated reasons (peer/route changes), and closing would abort our own WaitExtend.
useEffect(() => {
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
if (ev?.data?.status === "Connected") {
if (!busyRef.current && ev?.data?.status === "Connected") {
WindowManager.CloseSessionExpiration().catch(console.error);
}
});
@@ -78,11 +64,6 @@ export default function SessionExpirationDialog() {
};
}, []);
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
// opens the browser for the user to sign in, blocks on the daemon until
// the new deadline arrives. Tunnel stays up; success simply closes the
// dialog, failure surfaces a native error dialog and leaves this one
// open so the user can retry or logout.
const stay = useCallback(async () => {
if (busy) return;
setBusy(true);
@@ -101,13 +82,8 @@ export default function SessionExpirationDialog() {
userCode: start.userCode,
});
if (result.preempted) {
// Another UI surface (e.g. the tray "Extend now"
// notification action) started a flow for the same
// deadline and took over. Keep the dialog open so the
// user can re-trigger if the other flow also fails;
// a successful extend elsewhere refreshes the deadline
// and this window auto-closes when it's no longer
// relevant.
// Another surface took over this deadline's flow; keep the dialog
// open to retry. A successful extend elsewhere auto-closes this window.
return;
}
WindowManager.CloseSessionExpiration().catch(console.error);
@@ -152,18 +128,10 @@ export default function SessionExpirationDialog() {
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>
{expired
? t("sessionExpiration.expired")
: soon
? t("sessionExpiration.title")
: t("sessionExpiration.titleLater")}
{expired ? t("sessionExpiration.expired") : activeTitle}
</DialogHeading>
<DialogDescription>
{expired
? t("sessionExpiration.expiredDescription")
: soon
? t("sessionExpiration.description")
: t("sessionExpiration.descriptionLater")}
{expired ? t("sessionExpiration.expiredDescription") : activeDescription}
</DialogDescription>
</div>
@@ -187,9 +155,7 @@ export default function SessionExpirationDialog() {
onClick={stay}
disabled={busy}
>
{expired
? t("sessionExpiration.authenticate")
: t("sessionExpiration.stay")}
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
</Button>
<Button
variant={"secondary"}
@@ -1,15 +1,29 @@
import type { ComponentType, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
import netbirdFull from "@/assets/logos/netbird-full.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/>
</svg>
);
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/>
</svg>
);
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function SettingsAbout() {
@@ -20,16 +34,23 @@ export function SettingsAbout() {
const handleVersionClick = useAccentTrigger();
const COMMUNITY_LINKS: { label: string; url: string; Icon: LucideIcon }[] = [
const COMMUNITY_LINKS: {
label: string;
url: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
iconClassName?: string;
}[] = [
{
label: t("settings.about.community.github"),
url: "https://github.com/netbirdio/netbird",
Icon: Github,
Icon: GithubIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.slack"),
url: "https://docs.netbird.io/slack-url",
Icon: Slack,
Icon: SlackIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.forum"),
@@ -63,7 +84,8 @@ export function SettingsAbout() {
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p
<button
type={"button"}
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
onClick={handleVersionClick}
>
@@ -77,7 +99,7 @@ export function SettingsAbout() {
) : (
t("settings.about.client", { version: daemonVersion })
)}
</p>
</button>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{guiVersion === "development" ? (
<span>
@@ -100,7 +122,7 @@ export function SettingsAbout() {
<div
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
>
{COMMUNITY_LINKS.map(({ label, url, Icon }) => (
{COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => (
<button
key={url}
type={"button"}
@@ -109,7 +131,7 @@ export function SettingsAbout() {
"inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
}
>
<Icon className={"h-3.5 w-3.5"} />
<Icon className={iconClassName ?? "h-3.5 w-3.5"} />
<span>{label}</span>
</button>
))}
@@ -35,7 +35,7 @@ function triggerAccent() {
root.render(<Accent onDone={cleanup} />);
}
function Accent({ onDone }: { onDone: () => void }) {
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(false);
@@ -94,14 +94,14 @@ function Accent({ onDone }: { onDone: () => void }) {
};
raf = requestAnimationFrame(draw);
const timeout = window.setTimeout(() => {
const timeout = globalThis.setTimeout(() => {
setVisible(false);
window.setTimeout(onDone, 500);
globalThis.setTimeout(onDone, 500);
}, 9000);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timeout);
globalThis.clearTimeout(timeout);
window.removeEventListener("resize", resize);
};
}, [onDone]);
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { System } from "@wailsio/runtime";
import Button from "@/components/buttons/Button";
@@ -8,23 +8,16 @@ import { Label } from "@/components/typography/Label";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
// macOS: the Darwin utun control socket parses the digits after "utun" as the
// unit number, so the daemon (and the CLI's parseInterfaceName in
// client/cmd/up.go) only accepts utun<N>.
// Linux/Windows: no daemon-side validation; the Linux kernel caps names at
// IFNAMSIZ-1 = 15 chars and the safe charset across both is [A-Za-z0-9._-].
// macOS daemon/CLI only accept utun<N> (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars.
const IS_MAC = System.IsMac();
const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
const INTERFACE_NAME_ERROR_KEY = IS_MAC
? "settings.advanced.interfaceName.errorMac"
: "settings.advanced.interfaceName.error";
// Port 0 means "let the daemon pick a random free port" (see the hint text).
// Port 0 lets the daemon pick a random free port.
const PORT_MIN = 0;
const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host
// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for
// IPv6 the daemon still needs 1280 on the path (RFC 8200), but that is not
// the validator's job to enforce.
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
const MTU_MIN = 576;
const MTU_MAX = 8192;
@@ -40,6 +33,15 @@ export function SettingsAdvanced() {
});
const [saving, setSaving] = useState(false);
useEffect(() => {
setValues({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
const errors = useMemo(() => {
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
@@ -55,11 +57,7 @@ export function SettingsAdvanced() {
max: PORT_MAX,
});
}
if (
!Number.isInteger(values.mtu) ||
values.mtu < MTU_MIN ||
values.mtu > MTU_MAX
) {
if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) {
out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX });
}
return out;
@@ -89,9 +87,7 @@ export function SettingsAdvanced() {
label={t("settings.advanced.interfaceName.label")}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) =>
setValues((v) => ({ ...v, interfaceName: e.target.value }))
}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
/>
<div className={"grid grid-cols-2 gap-4"}>
<div>
@@ -118,9 +114,7 @@ export function SettingsAdvanced() {
max={MTU_MAX}
value={values.mtu}
error={errors.mtu}
onChange={(e) =>
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
}
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
/>
</div>
</SectionGroup>
@@ -128,17 +122,13 @@ export function SettingsAdvanced() {
<SectionGroup title={t("settings.advanced.section.security")}>
<div>
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
<HelpText>
{t("settings.advanced.psk.help")}
</HelpText>
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
showPasswordToggle
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={values.preSharedKey}
onChange={(e) =>
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
}
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
/>
</div>
</SectionGroup>
@@ -15,25 +15,13 @@ export function SettingsGeneral() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { autostart, setAutostartEnabled } = useAutostartSetting();
const {
mode,
setMode,
setUrl,
displayUrl,
showError,
canSave,
save,
checking,
unreachable,
} = useManagementUrl();
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl();
const inputRef = useRef<HTMLInputElement>(null);
const prevMode = useRef(mode);
useEffect(() => {
if (
prevMode.current === ManagementMode.Cloud &&
mode === ManagementMode.SelfHosted
) {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
inputRef.current?.focus();
}
prevMode.current = mode;
@@ -71,9 +59,7 @@ export function SettingsGeneral() {
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>{t("settings.general.management.label")}</Label>
<HelpText>
{t("settings.general.management.help")}
</HelpText>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
@@ -26,49 +26,49 @@ export const SettingsNavigation = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={t("settings.tabs.troubleshooting")}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={t("settings.tabs.about")}
adornment={aboutAdornment}
/>
</VerticalTabs.List>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={t("settings.tabs.troubleshooting")}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={t("settings.tabs.about")}
adornment={aboutAdornment}
/>
</VerticalTabs.List>
</div>
);
};
@@ -7,10 +7,7 @@ import { isMacOS } from "@/lib/platform";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx";
import {
AutostartSettingsProvider,
SettingsProvider,
} from "@/contexts/SettingsContext.tsx";
import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
@@ -22,21 +19,6 @@ import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
// The settings window mounts once at app startup (hidden) and stays at the
// single URL `/#/settings` forever — no SetURL between opens, so the
// `AppLayout` provider stack never re-mounts and we never see the
// `SettingsSkeleton` flash mid-reload. Tab is local state, driven by:
// - the `netbird:settings:open` Wails event from `WindowManager.OpenSettings`
// (sets the target tab, then Go calls `Show`/`Focus`); and
// - the same event with payload `"general"` from the close hook, so the
// window is already on General the next time Show fires (common case).
// In-window navigation state (e.g. the update-available header jump to About)
// still wins for that one render.
//
// The `h-12` draggable strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
// buttons float over invisible title bar) and mirrors the main window's
// Header height so AppRightPanel ends up the same height in both windows.
export const SettingsPage = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
@@ -55,72 +37,63 @@ export const SettingsPage = () => {
return (
<>
{isMacOS() ? (
<div
className={
"wails-draggable cursor-default select-none h-12 shrink-0"
}
/>
<div className={"wails-draggable cursor-default select-none h-12 shrink-0"} />
) : (
<div className={"h-px shrink-0 bg-nb-gray-920/0"} />
)}
<VerticalTabs
value={active}
onValueChange={setActive}
>
<VerticalTabs value={active} onValueChange={setActive}>
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<ScrollArea.Root
key={active}
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<ProfilesTab />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content
value={"troubleshooting"}
>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
<ScrollArea.Root
key={active}
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<ProfilesTab />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
@@ -50,7 +50,10 @@ export function SettingsSSH() {
/>
</SectionGroup>
<SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}>
<SectionGroup
title={t("settings.ssh.section.capabilities")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
@@ -77,7 +80,10 @@ export function SettingsSSH() {
/>
</SectionGroup>
<SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}>
<SectionGroup
title={t("settings.ssh.section.authentication")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
@@ -92,9 +98,7 @@ export function SettingsSSH() {
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
<HelpText margin={false}>
{t("settings.ssh.jwtTtl.help")}
</HelpText>
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
@@ -18,10 +18,6 @@ export const SectionGroup = ({
</section>
);
// SettingsBottomBar renders the floating action bar at the bottom of a
// settings tab (Save Changes / Add Profile / Create Bundle). It pairs the
// absolutely positioned bar with an in-flow spacer of the same height so
// scrollable content above doesn't end up hidden behind the bar.
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[4rem] shrink-0"} aria-hidden />
@@ -38,11 +38,7 @@ export function SettingsTroubleshooting() {
if (stage.kind === "done") {
return (
<DoneResult
result={stage.result}
uploaded={stage.uploadAttempted}
onClose={reset}
/>
<DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} />
);
}
if (stage.kind !== "idle") {
@@ -115,7 +111,7 @@ export function SettingsTroubleshooting() {
);
}
function CenteredPanel({ children }: { children: ReactNode }) {
function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
return (
<div
className={
@@ -127,7 +123,10 @@ function CenteredPanel({ children }: { children: ReactNode }) {
);
}
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
function ProgressSection({
stage,
onCancel,
}: Readonly<{ stage: DebugStage; onCancel: () => void }>) {
const { t } = useTranslation();
const cancelling = stage.kind === "cancelling";
return (
@@ -135,9 +134,7 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: ()
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2 max-w-xs"}>
<DialogHeading className={"text-balance"}>
{stageLabel(stage, t)}
</DialogHeading>
<DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading>
<DialogDescription>
{t("settings.troubleshooting.progress.description")}
</DialogDescription>
@@ -163,17 +160,19 @@ function DoneResult({
result,
uploaded,
onClose,
}: {
}: Readonly<{
result: DebugBundleResult;
uploaded: boolean;
onClose: () => void;
}) {
}>) {
const { t } = useTranslation();
const showKey = uploaded && Boolean(result.uploadedKey);
const uploadFailed = uploaded && !result.uploadedKey;
const onRevealPath = () => {
if (!result.path) return;
void DebugSvc.RevealFile(result.path).catch(() => {});
DebugSvc.RevealFile(result.path).catch((err: unknown) =>
console.error("reveal debug bundle file", err),
);
};
return (
<CenteredPanel>
@@ -252,12 +251,7 @@ function DoneResult({
</Button>
)
)}
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={onClose}
>
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}>
{t("common.close")}
</Button>
</DialogActions>
@@ -265,7 +259,10 @@ function DoneResult({
);
}
const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => {
const stageLabel = (
stage: DebugStage,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
switch (stage.kind) {
case "preparing-trace":
return t("settings.troubleshooting.stage.preparingTrace");
@@ -8,8 +8,7 @@ import {
import { SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog } from "@/lib/dialogs";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
@@ -17,18 +16,8 @@ import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
// WelcomeStep is the orchestrator's state machine. The transitions:
// tray → management (if eligible) → finish
// tray → finish (otherwise)
// Login itself is no longer part of onboarding — once the welcome window
// closes the user lands in the main window and clicks Connect there.
type WelcomeStep = "tray" | "management";
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
// on a pristine setup — default profile, no email recorded (no successful
// login yet), and the management URL is either unset or already the cloud
// default. Any other state means the user (or a previous run) already
// made a deliberate choice and we shouldn't second-guess it.
function shouldShowManagementStep(
activeProfile: string,
email: string,
@@ -39,10 +28,6 @@ function shouldShowManagementStep(
return isCloudManagementUrl(managementUrl);
}
// initial flow snapshot resolved at mount. Held in component state so the
// step-2 management input can hydrate from initialUrl, and so the
// "should we even show step 2" check is computed once (the user can't
// change profile / URL from inside the welcome window).
type InitialState = {
profileName: string;
username: string;
@@ -54,24 +39,12 @@ export default function WelcomeDialog() {
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
// ready=false until the daemon probe resolves — keeps the window
// Hidden so neither the empty padding-only frame (Linux/GNOME paints
// through) nor a placeholder div leaks onto screen.
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
// Probe daemon state on mount: who's the active profile, do they
// have an email recorded, and what management URL is configured?
// Errors fall through to "skip the management step" so a daemon
// hiccup never blocks onboarding entirely.
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Resolve username + active profile first so GetConfig + List
// can target the actual profile (passing empty strings would
// work today since the daemon falls back to the default
// profile, but being explicit shields us from future
// changes to that fallback).
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
@@ -97,8 +70,6 @@ export default function WelcomeDialog() {
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
// Conservative fallback: skip the management step rather
// than block onboarding behind a daemon hiccup.
setInitial({
profileName: "default",
username: "",
@@ -112,10 +83,6 @@ export default function WelcomeDialog() {
};
}, []);
// finish persists the onboarding flag, opens the main window so the
// user has somewhere to land, and closes the welcome window. Called
// at the end of every successful flow (tray-only and tray→management
// alike). The Connect button in the main window picks up from here.
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
@@ -148,10 +115,7 @@ export default function WelcomeDialog() {
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — pointer fields left
// undefined are preserved (services/settings.go). We only
// touch managementUrl; adminUrl stays empty here because
// the daemon already has its own value loaded.
// SetConfig is a partial update — undefined fields are preserved Go-side.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
@@ -18,16 +18,14 @@ import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
// initialUrl is the management URL the daemon is already configured
// with (empty / cloud-default both render as Cloud selected).
initialUrl: string;
// onContinue is invoked with the URL the user wants to persist. The
// parent owns the actual Settings.SetConfig call so the dialog stays
// free of context dependencies.
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
export function WelcomeStepManagement({
initialUrl,
onContinue,
}: Readonly<WelcomeStepManagementProps>) {
const { t } = useTranslation();
const startsCloud = isCloudManagementUrl(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
@@ -35,21 +33,13 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
// unreachable: soft warning. Continue stays enabled — user can confirm
// they typed it right and proceed (matches self-hosted-behind-internal-
// DNS / VPN scenarios where the in-app fetch would false-negative).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
// Continue is no longer disabled for an empty / invalid self-hosted
// URL; a Continue click in that state focuses the input and renders
// an inline error so the user actively notices what's missing.
const inputRef = useRef<HTMLInputElement | null>(null);
// Reset inline error/warning whenever the user edits the URL or flips
// mode — otherwise the warning lingers next to a just-corrected value.
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
@@ -58,9 +48,6 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
// Empty or syntactically invalid URL — Continue stays enabled
// so the click registers; surface the error inline and focus
// the input so the user has somewhere to fix it.
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
@@ -69,14 +56,11 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted) {
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: show soft warning + bail. A second click
// with the same URL skips the check (unreachable still true)
// so the user can proceed if they're sure.
if (!reachable && !unreachable) {
if (!reachable) {
setUnreachable(true);
return;
}
@@ -84,14 +68,10 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
try {
await onContinue(target);
} catch (e) {
// Parent surfaces save errors via errorDialog; keep a console
// breadcrumb but don't double-render.
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
// Syntax problems are hard errors (red); an unreachable-but-valid URL is
// a soft, non-blocking caveat (orange).
const inputError = syntaxError ?? undefined;
const inputWarning = useMemo(
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
@@ -8,12 +8,7 @@ import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// trayScreenshotForOS picks the marketing screenshot that shows the
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
// matches the chrome the user will actually be hunting for. Evaluated
// inside the component so initPlatform() has finished by the time
// isMacOS/isWindows run (the static imports above only load the bytes,
// no platform check).
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
@@ -24,7 +19,7 @@ type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();