mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-19 21:29:09 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user