[management,client] 0.75.0 release with new desktop UI (#6473)

- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Maycon Santos
2026-07-06 13:47:16 +02:00
committed by GitHub
co-authored by Zoltan Papp Eduard Gert braginini Pascal Fischer riccardom
parent c9d387bd0d
commit 91acb8147c
389 changed files with 46269 additions and 6684 deletions
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
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 { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import {
startConnection,
EVENT_BROWSER_LOGIN_CANCEL,
EVENT_TRIGGER_LOGIN,
} from "@/lib/connection.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { contentTop } from "@/components/empty-state/EmptyState";
import { useFocusVisible } from "@/hooks/useFocusVisible";
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";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
const { activeProfileId, username } = useProfile();
const daemonState = status?.status ?? "Idle";
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
const unreachable = daemonState === "DaemonUnavailable";
type Action = "connect" | "logging-in" | "disconnect" | null;
const [action, setAction] = useState<Action>(null);
const loginGuard = useRef(false);
const driveLogin = useCallback(() => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
void startConnection(() => {
loginGuard.current = false;
setAction(null);
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
});
}, [refresh]);
const connState: ConnectionState = useMemo(() => {
if (action === "disconnect" && daemonState === "Connected") {
return ConnectionState.Disconnecting;
}
if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") {
return ConnectionState.Connecting;
}
switch (daemonState) {
case "Connected":
return ConnectionState.Connected;
case "Connecting":
return ConnectionState.Connecting;
case "Idle":
case "NeedsLogin":
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;
}
}, [daemonState, action]);
const connect = async () => {
setAction("connect");
try {
await Connection.Up({
profileName: activeProfileId,
username,
});
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.connectTitle"),
Message: errorMessage(e),
});
}
};
const disconnect = async () => {
setAction("disconnect");
try {
await Connection.Down();
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
}
};
const sawConnectingRef = useRef(false);
useEffect(() => {
if (action === null) {
sawConnectingRef.current = false;
return;
}
if (daemonState === "Connecting") {
sawConnectingRef.current = true;
}
if (action === "connect") {
if (needsLogin) {
driveLogin();
return;
}
if (daemonState === "Connected" || unreachable) {
setAction(null);
return;
}
if (sawConnectingRef.current && daemonState === "Idle") {
setAction(null);
}
return;
}
if (action === "disconnect") {
if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) {
setAction(null);
}
}
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
useEffect(() => {
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
driveLogin();
});
return () => off();
}, [driveLogin]);
const handleSwitch = (next: boolean) => {
if (unreachable) return;
if (isTransitioning) {
if (canForceCancel) void forceCancel();
return;
}
if (action !== null) return;
if (needsLogin) {
driveLogin();
return;
}
if (next && connState === ConnectionState.Disconnected) {
void connect();
} else if (!next && connState === ConnectionState.Connected) {
void disconnect();
}
};
const isTransitioning =
connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting;
const isOn =
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
const [canForceCancel, setCanForceCancel] = useState(false);
useEffect(() => {
if (!isTransitioning) {
setCanForceCancel(false);
return;
}
const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS);
return () => clearTimeout(id);
}, [isTransitioning]);
const forceCancel = async () => {
if (action === "logging-in") {
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel failed", err),
);
}
WindowManager.CloseBrowserLogin().catch((err: unknown) =>
console.warn("close browser-login window failed", err),
);
setAction("disconnect");
try {
await Connection.Down();
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
}
};
const show = connState === ConnectionState.Connected;
const fqdn = status?.local.fqdn || "";
const ip = status?.local.ip || "";
const ipv6 = status?.local.ipv6 || "";
return (
<div
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
style={{ top: contentTop("11.7rem") }}
>
<img
src={netbirdFullLogo}
alt={"NetBird"}
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
draggable={false}
/>
<ToggleSwitch
size={"large"}
checked={isOn}
onCheckedChange={handleSwitch}
disabled={(isTransitioning && !canForceCancel) || unreachable}
aria-label={t("connect.toggle.label")}
aria-describedby={"nb-connection-status"}
aria-busy={isTransitioning}
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
/>
<div className={"flex flex-col items-center"}>
<p
id={"nb-connection-status"}
role={"status"}
aria-live={"polite"}
className={
"wails-no-draggable mb-1 select-none text-sm font-medium tracking-wide text-nb-gray-200 transition-colors duration-300"
}
>
{t(STATUS_KEY[connState])}
</p>
<CopyToClipboard
message={fqdn}
variant={"bright"}
iconClassName={"-top-px"}
tabIndex={show && fqdn ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
"relative left-[0.55rem]",
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
className={
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
}
/>
</CopyToClipboard>
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
</div>
</div>
);
};
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const isFocusVisible = useFocusVisible();
const hasV6 = !!ipv6;
if (!hasV6) {
return (
<CopyToClipboard
message={ip}
variant={"bright"}
tabIndex={show && ip ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
);
}
return (
<div
className={cn(
"min-h-[1em] max-w-full transition-opacity duration-300",
"wails-no-draggable relative",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
tabIndex={show && ip ? 0 : -1}
aria-label={t("connect.localIp.label")}
aria-haspopup={"dialog"}
aria-expanded={open}
className={cn(
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"transition-colors",
)}
>
<span
className={cn(
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
>
{ip || " "}
</span>
<ChevronDownIcon
size={14}
aria-hidden={"true"}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"center"}
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 max-w-[280px] overflow-hidden",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-1 text-nb-gray-200 shadow-lg outline-none",
"flex flex-col",
)}
>
<IpRow value={ip} />
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
<IpRow value={ipv6} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
};
const IpRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isFocusVisible = useFocusVisible();
const handleClick = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (e) {
console.warn("copy IP to clipboard failed", e);
}
};
return (
<button
type={"button"}
onClick={handleClick}
tabIndex={0}
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
<span
aria-hidden={"true"}
className={"inline-flex shrink-0 items-center text-nb-gray-200"}
>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};
@@ -0,0 +1,256 @@
import { forwardRef, useRef, useState } from "react";
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 { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes, toggleExitNode } = useNetworks();
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
const disabled = !isConnected || !hasAny;
const [open, setOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (open || disabled) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
}
};
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active)
toggleExitNode(active.id, true).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
return;
}
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 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}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ExitNodeTriggerCard
title={title}
description={description}
disabled={disabled}
active={!!active}
aria-label={t("exitNodes.dropdown.trigger")}
aria-haspopup={"listbox"}
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
side={"top"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => {
e.preventDefault();
listRef.current?.focus();
}}
style={{ width: "var(--radix-popover-trigger-width)" }}
className={cn(
"wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
"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()}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<Command.List
ref={listRef}
aria-label={t("exitNodes.dropdown.trigger")}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
key={n.id}
id={n.id}
label={n.id}
isActive={active?.id === n.id}
onSelect={() => handleSelect(n.id)}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
title: string;
description: string;
active?: boolean;
};
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
function ExitNodeTriggerCard(
{ title, description, disabled, active = false, className, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<button
ref={ref}
type={"button"}
tabIndex={0}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled
? "cursor-not-allowed opacity-60"
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
className,
)}
{...props}
>
<div
aria-hidden={"true"}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
active
? "bg-green-500/25 text-green-400"
: "bg-nb-gray-900 text-nb-gray-300",
)}
>
<ExitNodeIcon size={14} />
</div>
<div className={"min-w-0 flex-1"}>
<span className={"block truncate text-sm font-medium text-nb-gray-100"}>
{title}
</span>
<TruncatedText
text={description}
className={
"block max-w-full truncate text-[0.85rem] font-medium text-nb-gray-400"
}
/>
</div>
<ChevronsUpDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
);
},
);
type NoneRowProps = {
isActive: boolean;
onSelect: () => void;
};
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
const { t } = useTranslation();
return (
<Command.Item
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && (
<Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />
)}
</Command.Item>
);
};
type ExitNodeRowProps = {
id: string;
label: string;
isActive: boolean;
onSelect: () => void;
};
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
<Command.Item
value={id}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);
@@ -0,0 +1,192 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUpCircleIcon,
Check,
MoreVertical,
PanelsRightBottom,
RectangleVertical,
Settings,
type LucideIcon,
} from "lucide-react";
import { WindowManager } from "@bindings/services";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { IconButton } from "@/components/buttons/IconButton";
import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { isWindows } from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
export const MainHeader = () => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const openSettings = useCallback(() => {
setMenuOpen(false);
WindowManager.OpenSettings("").catch((err: unknown) =>
console.error("open settings window failed", err),
);
}, []);
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
const openAbout = () => {
setMenuOpen(false);
WindowManager.OpenSettings("about").catch((err: unknown) =>
console.error("open settings (about) window failed", err),
);
};
const openManageProfiles = () => {
WindowManager.OpenSettings("profiles").catch((err: unknown) =>
console.error("open settings (profiles) window failed", err),
);
};
const selectMode = (mode: ViewMode) => {
setMenuOpen(false);
setViewMode(mode);
};
const profileSlot = features.disableProfiles ? null : (
<ProfileDropdown onManageProfiles={openManageProfiles} />
);
const settingsSlot = (
<div className={"relative"}>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
<IconButton
icon={MoreVertical}
iconClassName={"text-nb-gray-200 wails-no-draggable"}
className={"select-none"}
aria-label={t("header.menu.open")}
aria-haspopup={"menu"}
aria-expanded={menuOpen}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
sideOffset={8}
className={
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
}
>
{updateAvailable && (
<>
<DropdownMenuItem onClick={openAbout}>
<div className={"flex items-center gap-2"}>
<ArrowUpCircleIcon
size={14}
className={"text-netbird"}
aria-hidden={"true"}
/>
<span className={"text-netbird"}>
{t("header.menu.updateAvailable")}
</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={openSettings}>
<div className={"flex w-full items-center gap-2"}>
<Settings size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("header.menu.settings")}</span>
<DropdownMenuShortcut>
{formatShortcut(SETTINGS_SHORTCUT)}
</DropdownMenuShortcut>
</div>
</DropdownMenuItem>
{!mdm.disableAdvancedView && (
<>
<DropdownMenuSeparator />
<ViewModeItem
icon={RectangleVertical}
label={t("header.menu.defaultView")}
selected={viewMode === "default"}
onSelect={() => selectMode("default")}
/>
<ViewModeItem
icon={PanelsRightBottom}
label={t("header.menu.advancedView")}
selected={viewMode === "advanced"}
onSelect={() => selectMode("advanced")}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{updateAvailable && (
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
>
<span
className={
"absolute inset-0 animate-ping rounded-full bg-netbird opacity-60"
}
/>
<span className={"relative h-1.5 w-1.5 rounded-full bg-netbird"} />
</span>
)}
</div>
);
return (
<header
className={cn(
"wails-draggable relative z-10 shrink-0 cursor-default",
"top-3 flex h-12 items-center",
)}
>
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
className={cn(
"grid shrink-0 grid-cols-3 items-center",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<div />
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
</header>
);
};
type ViewModeItemProps = {
icon: LucideIcon;
label: string;
selected: boolean;
onSelect: () => void;
};
const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
<DropdownMenuItem onClick={onSelect} role={"menuitemradio"} aria-checked={selected}>
<div className={"flex w-full items-center gap-2"}>
<Icon size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{label}</span>
{selected && <Check size={14} className={"text-netbird"} aria-hidden={"true"} />}
</div>
</DropdownMenuItem>
);
@@ -0,0 +1,114 @@
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
import { MainHeader } from "@/modules/main/MainHeader.tsx";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
import { cn } from "@/lib/cn";
import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
import { useEffect } from "react";
import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
import { isWindows } from "@/lib/platform.ts";
export const MainPage = () => {
return (
<ViewModeProvider>
<MainHeader />
<NetworksProvider>
<PeerDetailProvider>
<MainBody />
</PeerDetailProvider>
</NetworksProvider>
</ViewModeProvider>
);
};
const MainBody = () => {
const { viewMode, setViewMode } = useViewMode();
const { mdm, features } = useRestrictions();
// Force flip the view if MDM disabled advanced
useEffect(() => {
if (mdm.disableAdvancedView && viewMode === "advanced") {
setViewMode("default");
}
}, [mdm.disableAdvancedView, viewMode, setViewMode]);
const isAdvanced = viewMode === "advanced";
return (
<main className={"wails-draggable flex min-h-0 flex-1"}>
{/* 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 shrink-0 flex-col items-center",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<MainConnectionStatusSwitch />
{!features.disableNetworks && (
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
<MainExitNodeSwitcher />
</div>
)}
</div>
{isAdvanced && (
<NavSectionProvider>
<AdvancedAppRightPanel />
</NavSectionProvider>
)}
</main>
);
};
const AdvancedAppRightPanel = () => {
const { section } = useNavSection();
const { selected } = usePeerDetail();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
return (
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
>
<div
ref={(el) => {
if (!el) return;
if (isConnected) el.removeAttribute("inert");
else el.setAttribute("inert", "");
}}
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
!isConnected && "pointer-events-none select-none",
)}
aria-hidden={!isConnected}
>
<Navigation />
<div
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
className={"flex min-h-0 flex-1 flex-col"}
>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
</div>
</div>
{!isConnected && (
<div className={"pointer-events-auto absolute inset-0 z-20 flex bg-nb-gray-940"}>
<NotConnectedState />
</div>
)}
</AppRightPanel>
);
};
@@ -0,0 +1,134 @@
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
type TabEntry = {
value: NavSection;
label: string;
icon: ComponentType<LucideProps>;
};
export const Navigation = () => {
const { t } = useTranslation();
const { section, setSection } = useNavSection();
const { status } = useStatus();
const { features } = useRestrictions();
const isConnected = status?.status === "Connected";
// Reset back to peers tab if mdm or feature flag flipped it
useEffect(() => {
if (features.disableNetworks && section === "networks") {
setSection("peers");
}
}, [features.disableNetworks, section, setSection]);
const tabs: TabEntry[] = [
{
value: "peers",
label: t("nav.peers.title"),
icon: MonitorSmartphoneIcon,
},
];
if (!features.disableNetworks) {
tabs.push({
value: "networks",
label: t("nav.resources.title"),
icon: Layers3Icon,
});
}
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const focusTab = (value: NavSection) => {
setSection(value);
requestAnimationFrame(() => tabRefs.current[value]?.focus());
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const enabled = tabs.filter((t) => isConnected || t.value === section);
if (enabled.length < 2) return;
const currentIndex = enabled.findIndex((t) => t.value === section);
if (currentIndex === -1) return;
let nextIndex: number;
switch (e.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % enabled.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = enabled.length - 1;
break;
default:
return;
}
e.preventDefault();
focusTab(enabled[nextIndex].value);
};
return (
<div
role={"tablist"}
aria-orientation={"horizontal"}
aria-label={t("nav.peers.title")}
className={"wails-no-draggable flex shrink-0 items-stretch"}
>
{tabs.map((tab, index) => {
const isActive = tab.value === section;
const isDisabled = !isConnected && !isActive;
const isFirst = index === 0;
const isLast = index === tabs.length - 1;
const Icon = tab.icon;
return (
<button
key={tab.value}
ref={(el) => {
tabRefs.current[tab.value] = el;
}}
type={"button"}
role={"tab"}
aria-selected={isActive}
aria-controls={`nb-tabpanel-${tab.value}`}
id={`nb-tab-${tab.value}`}
tabIndex={isActive ? 0 : -1}
onClick={() => setSection(tab.value)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
className={cn(
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
>
<Icon size={14} aria-hidden={"true"} />
<span className={"text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden={"true"}
className={cn(
"absolute inset-x-0 bottom-0 h-px transition-all",
isActive
? "bg-netbird"
: "bg-nb-gray-910 group-hover:bg-nb-gray-700",
)}
/>
</button>
);
})}
</div>
);
};
export type { NavSection } from "@/contexts/NavSectionContext";
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type NetworkFilter = "all" | "active" | "overlapping";
type Props = {
value: NetworkFilter;
onChange: (value: NetworkFilter) => void;
counts: Record<NetworkFilter, number>;
disabled?: boolean;
};
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
{ value: "active", label: t("networks.filter.active") },
{ value: "overlapping", label: t("networks.filter.overlapping") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: NetworkFilter) => {
onChange(v);
setOpen(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
tabIndex={0}
aria-label={t("common.filter")}
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role={"menuitemradio"}
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
aria-hidden={"true"}
className={"flex w-4 shrink-0 items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,528 @@
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
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";
import { SearchInput } from "@/components/inputs/SearchInput";
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 { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
// 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);
type ResourceType = "host" | "subnet" | "domain";
const isHostCidr = (cidr: string): boolean => {
const [addr, bitsStr] = cidr.split("/");
if (!addr || !bitsStr) return false;
const bits = Number(bitsStr);
const isV6 = addr.includes(":");
return isV6 ? bits === 128 : bits === 32;
};
const resourceTypeOf = (n: Network): ResourceType => {
if (isDnsRoute(n)) return "domain";
const primary = n.range.split(",")[0].trim();
return isHostCidr(primary) ? "host" : "subnet";
};
const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
if (type === "host") return WorkflowIcon;
if (type === "domain") return GlobeIcon;
return NetworkIcon;
};
const buildOverlapMap = (
routes: { id: string; range: string; domains: string[] }[],
): Map<string, string[]> => {
const byRange = new Map<string, string[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r.id);
byRange.set(r.range, arr);
}
const out = new Map<string, string[]>();
for (const [range, ids] of byRange) {
if (ids.length > 1) out.set(range, ids);
}
return out;
};
export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
const overlapById = useMemo(() => {
const map = new Map<string, string[]>();
for (const ids of overlapGroups.values()) {
for (const id of ids) map.set(id, ids);
}
return map;
}, [overlapGroups]);
const counts = useMemo<Record<NetworkFilter, number>>(
() => ({
all: networkRoutes.length,
active: networkRoutes.filter((r) => r.selected).length,
overlapping: overlapById.size,
}),
[networkRoutes, overlapById],
);
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
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);
},
);
orderRef.current = order;
return items;
}, [networkRoutes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [ordered, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) {
return (
<EmptyState
icon={Layers3Icon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
/>
);
}
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
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);
setNetworksSelected(ids, true).catch((err: unknown) =>
console.error("enable all networks failed", err),
);
}
};
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
data={filtered}
onToggle={toggleNetwork}
scrollParent={scrollParent}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
{filtered.length > 0 && (
<div
className={cn(
"flex items-center gap-3 px-6 py-3.5",
"border-t border-nb-gray-910",
)}
>
<span className={"flex-1 text-xs font-medium tabular-nums text-nb-gray-300"}>
{t("networks.bulk.selectionCount", {
selected: selectedInView,
total: filtered.length,
})}
</span>
<button
type={"button"}
tabIndex={0}
onClick={onBulkClick}
aria-label={t("networks.bulk.label")}
className={cn(
"inline-flex h-8 items-center rounded-md px-3",
"text-xs font-medium text-nb-gray-100",
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
"wails-no-draggable cursor-pointer outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{bulkLabel}
</button>
</div>
)}
</div>
);
};
type NetworksListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
scrollParent: HTMLElement;
};
const NetworksHeader = () => <div className={"h-2"} />;
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= data.length) return;
const row = data[index];
const tryFocus = () => {
const el = rowRefs.current.get(row.id);
if (el) {
el.focus();
return true;
}
return false;
};
if (!tryFocus()) {
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
requestAnimationFrame(() => {
if (!tryFocus()) requestAnimationFrame(tryFocus);
});
}
};
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, data.length - 1));
break;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "Home":
e.preventDefault();
focusRow(0);
break;
case "End":
e.preventDefault();
focusRow(data.length - 1);
break;
}
};
const setRowRef = (id: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(id, el);
else rowRefs.current.delete(id);
};
const ctx = useMemo<NetworkRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, onToggle],
);
return (
<Virtuoso<Network, NetworkRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, n) => n.id}
components={{ Header: NetworksHeader }}
context={ctx}
itemContent={renderNetworkRow}
/>
);
};
type NetworkRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => (
<NetworkRow
network={n}
index={index}
onKeyDown={ctx.onKeyDown}
onToggle={ctx.onToggle}
setRowRef={ctx.setRowRef}
/>
);
type NetworkRowProps = {
network: Network;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => {
const { t } = useTranslation();
// Same handler is attached to the overlay button and to the network-id copy
// button so arrow nav works wherever focus sits inside the row.
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(n.id, el)}
aria-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div
className={
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
}
>
<div>
<CopyToClipboard message={n.id} onKeyDown={handleKey}>
<TruncatedText
text={n.id}
className={
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
}
/>
</CopyToClipboard>
</div>
<Subtitle network={n} onKeyDown={handleKey} />
</div>
<div
aria-hidden={"true"}
className={"pointer-events-none relative shrink-0 self-center"}
>
<NetworkToggle checked={n.selected} />
</div>
</div>
);
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
<div
aria-hidden={"true"}
className={cn(
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
)}
>
<Icon size={14} />
</div>
);
};
type SubtitleProps = {
network: Network;
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
if (isDnsRoute(network)) {
const domain = network.domains[0];
const ips = network.resolvedIps[domain] ?? [];
return <DomainSubtitle domain={domain} ips={ips} onKeyDown={onKeyDown} />;
}
if (network.range && network.range !== INVALID_PREFIX) {
return (
<div>
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
className={
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
}
/>
</CopyToClipboard>
</div>
);
}
return null;
};
type DomainSubtitleProps = {
domain: string;
ips: string[];
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
const span = (
<span className={"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"}>
{domain}
</span>
);
return (
<div>
<CopyToClipboard message={domain} onKeyDown={onKeyDown}>
{ips.length > 0 ? (
<Tooltip
content={<ResolvedIpsTooltip ips={ips} />}
delayDuration={300}
closeDelay={300}
side={"right"}
align={"start"}
alignOffset={-8}
interactive
keepOpenOnClick
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 pr-4",
)}
>
{span}
</Tooltip>
) : (
span
)}
</CopyToClipboard>
</div>
);
};
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
const { t } = useTranslation();
return (
<>
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
<span
className={
"whitespace-nowrap font-mono text-[0.72rem] text-nb-gray-100"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</>
);
};
type ToggleProps = {
checked: boolean;
mixed?: boolean;
};
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return (
<span
className={cn(
"inline-flex h-5 w-9 shrink-0 items-center rounded-full",
"wails-no-draggable transition-colors",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
)}
/>
</span>
);
};
@@ -0,0 +1,575 @@
import {
type ComponentType,
Fragment,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowUpDownIcon,
ArrowUpIcon,
Check as CheckIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
ClockIcon,
Copy as CopyIcon,
GaugeIcon,
HandshakeIcon,
KeyRoundIcon,
Layers3Icon,
type LucideProps,
MapPinIcon,
MonitorIcon,
Radio,
RefreshCwIcon,
WaypointsIcon,
} from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { peerStatusLabelKey } from "./Peers";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
ease: [0.32, 0.72, 0, 1],
};
const DASH = "-";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
type Props = {
transition?: Transition;
};
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
useEffect(() => {
if (!selected) return;
const peers = status?.peers ?? [];
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
return;
}
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// 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;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [selected]);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
const MIN_SPIN_MS = 600;
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
try {
await Promise.all([refresh(), minDelay]);
} finally {
setRefreshing(false);
}
}, [refresh, refreshing]);
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSelected(null);
return;
}
if (e.key === "ArrowLeft") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
setSelected(null);
}
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
const dialogRef = useRef<HTMLDivElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!selected) return;
// Defer focus until the slide-in animation has started rendering.
// preventScroll avoids the browser scrolling the parent to chase the
// still-offscreen button, which lands as a stutter at the end of the slide.
requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.pubKey]);
const getFocusable = (): HTMLElement[] => {
const root = dialogRef.current;
if (!root) return [];
const sel =
"button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," +
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
};
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Tab") return;
const focusables = getFocusable();
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !active || !dialogRef.current?.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
return (
<AnimatePresence>
{selected && (
<motion.div
ref={dialogRef}
role={"dialog"}
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
onKeyDown={onDialogKeyDown}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={transition}
style={{ willChange: "transform" }}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
>
<div
className={cn(
"flex shrink-0 items-center gap-3",
"h-12 border-b border-nb-gray-910 px-3",
)}
>
<button
ref={backButtonRef}
type={"button"}
tabIndex={0}
onClick={() => setSelected(null)}
aria-label={t("common.close")}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} aria-hidden={"true"} />
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
aria-hidden={"true"}
className={cn(
"h-2 w-2 shrink-0 rounded-full",
dotClass(selected.connStatus),
)}
/>
</Tooltip>
<CopyToClipboard
message={selected.fqdn || selected.ip}
size={11}
className={"min-w-0 flex-1"}
iconClassName={"top-[2px]"}
>
<span
id={"nb-peer-detail-title"}
className={"truncate text-sm font-medium text-nb-gray-100"}
>
{shortenDns(selected.fqdn) || selected.ip}
</span>
</CopyToClipboard>
<Tooltip content={t("peers.details.refresh")}>
<button
type={"button"}
tabIndex={0}
onClick={onRefresh}
disabled={refreshing}
aria-label={t("peers.details.refresh")}
aria-busy={refreshing}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
"disabled:opacity-50 disabled:hover:bg-transparent",
)}
>
<RefreshCwIcon
size={14}
aria-hidden={"true"}
className={refreshing ? "animate-spin" : undefined}
/>
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</motion.div>
)}
</AnimatePresence>
);
};
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const { t } = useTranslation();
const formatAge = (unix: number, fallback: string): string => {
if (!Number.isFinite(unix) || unix <= 0) return fallback;
const diff = Math.floor(now / 1000 - unix);
if (diff < 1) return t("peers.details.justNow");
return formatRelative(unix, now) ?? fallback;
};
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
const isConnected = peer.connStatus === "Connected";
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
return (
<ul className={"flex flex-col divide-y divide-nb-gray-920"}>
<Row icon={MapPinIcon} label={t("peers.details.netbirdIp")}>
{peer.ip ? (
<CopyToClipboard
message={peer.ip}
alwaysShowIcon
className={"max-w-full"}
iconClassName={"top-0"}
>
<span className={"font-mono"}>{peer.ip}</span>
</CopyToClipboard>
) : (
DASH
)}
</Row>
{peer.ipv6 && (
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
<CopyToClipboard
message={peer.ipv6}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.ipv6} mono />
</CopyToClipboard>
</Row>
)}
{isConnected && (
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
</Row>
)}
{peer.relayed && (
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
)}
{peer.latencyMs > 0 && (
<Row icon={GaugeIcon} label={t("peers.details.latency")}>
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}>
{peer.latencyMs} ms
</span>
</Row>
)}
{(peer.bytesRx > 0 || peer.bytesTx > 0) && (
<Row icon={ArrowUpDownIcon} label={t("peers.details.bytes")}>
<div
className={
"flex items-center justify-end gap-3 font-medium text-nb-gray-300"
}
>
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
<ArrowDownIcon
size={13}
aria-hidden={"true"}
className={"text-sky-400"}
/>
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
</div>
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
<ArrowUpIcon
size={13}
aria-hidden={"true"}
className={"text-netbird"}
/>
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
</div>
</div>
</Row>
)}
<Row icon={HandshakeIcon} label={t("peers.details.lastHandshake")}>
{lastHandshake}
</Row>
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
{statusSince}
</Row>
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<IceRow
icon={MonitorIcon}
baseLabel={t("peers.details.localIce")}
type={peer.localIceCandidateType}
endpoint={peer.localIceCandidateEndpoint}
/>
<IceRow
icon={Radio}
baseLabel={t("peers.details.remoteIce")}
type={peer.remoteIceCandidateType}
endpoint={peer.remoteIceCandidateEndpoint}
/>
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
{peer.pubKey ? (
<CopyToClipboard
message={peer.pubKey}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.pubKey} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
</ul>
);
};
type RowProps = {
icon: ComponentType<LucideProps>;
iconClassName?: string;
label: string;
children: ReactNode;
};
type IceRowProps = {
icon: ComponentType<LucideProps>;
baseLabel: string;
type: string;
endpoint: string;
};
const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s);
const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
if (!type && !endpoint) return null;
const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel;
return (
<Row icon={icon} label={label}>
{endpoint ? (
<CopyToClipboard
message={endpoint}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={endpoint} mono />
</CopyToClipboard>
) : (
<span className={"truncate"}>{capitalize(type)}</span>
)}
</Row>
);
};
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
const [open, setOpen] = useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
tabIndex={0}
aria-haspopup={"dialog"}
aria-expanded={open}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{networks.length}
<ChevronDownIcon
size={12}
aria-hidden={"true"}
className={cn("transition-transform duration-150", open && "rotate-180")}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"end"}
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-1 text-nb-gray-200 shadow-lg outline-none",
"flex flex-col",
)}
>
{networks.map((n, i) => (
<Fragment key={n}>
{i > 0 && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
<ResourceRow value={n} />
</Fragment>
))}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ResourceRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isFocusVisible = useFocusVisible();
const handleClick = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (e) {
console.warn("copy resource to clipboard failed", e);
}
};
return (
<button
type={"button"}
onClick={handleClick}
tabIndex={0}
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/resourcerow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
<span
aria-hidden={"true"}
className={"inline-flex shrink-0 items-center text-nb-gray-200"}
>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
className={cn(
"inline-block min-w-0 max-w-[260px] truncate align-middle",
mono && "font-mono",
)}
/>
);
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<li className={"flex min-w-0 items-center gap-2 px-5 py-4 text-xs text-nb-gray-100"}>
<Icon
size={14}
aria-hidden={"true"}
className={cn("shrink-0 text-nb-gray-100", iconClassName)}
/>
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
<span
className={cn(
"min-w-0 flex-1 pl-8 text-right",
"font-medium text-nb-gray-350",
"flex items-center justify-end",
)}
>
{children}
</span>
</li>
);
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type StatusFilter = "all" | "online" | "offline";
type Props = {
value: StatusFilter;
onChange: (value: StatusFilter) => void;
counts: Record<StatusFilter, number>;
disabled?: boolean;
};
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
{ value: "online", label: t("peers.filter.online") },
{ value: "offline", label: t("peers.filter.offline") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: StatusFilter) => {
onChange(v);
setOpen(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
tabIndex={0}
aria-label={t("common.filter")}
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role={"menuitemradio"}
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
aria-hidden={"true"}
className={"flex w-4 shrink-0 items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,353 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
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";
import { NoResults } from "@/components/empty-state/NoResults";
import { latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
export const peerStatusLabelKey = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "peers.status.connected";
case "Connecting":
return "peers.status.connecting";
default:
return "peers.status.disconnected";
}
};
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const isConnected = status?.status === "Connected";
const peers = useMemo(() => status?.peers ?? [], [status?.peers]);
const counts = useMemo<Record<StatusFilter, number>>(() => {
const online = peers.filter((p) => isOnline(p.connStatus)).length;
return {
all: peers.length,
online,
offline: peers.length - online,
};
}, [peers]);
// 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 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);
};
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
return [];
}
if (!stickyRef.current) {
const sorted = [...peers].sort(compare);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
}
return sorted;
}
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
orderRef.current = order;
return items;
}, [peers]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((p) => {
if (statusFilter === "online" && !isOnline(p.connStatus)) return false;
if (statusFilter === "offline" && isOnline(p.connStatus)) return false;
return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q);
});
}, [ordered, search, statusFilter]);
if (isConnected && peers.length === 0) {
return (
<EmptyState
icon={MonitorSmartphoneIcon}
title={t("peers.empty.title")}
description={t("peers.empty.description")}
/>
);
}
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("peers.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<PeerFilters value={statusFilter} onChange={setStatusFilter} counts={counts} />
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</div>
);
};
const ListTopSpacer = () => <div className={"h-2"} />;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
};
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= data.length) return;
const peer = data[index];
const tryFocus = () => {
const el = rowRefs.current.get(peer.pubKey);
if (el) {
el.focus();
return true;
}
return false;
};
if (!tryFocus()) {
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
// Row may not be mounted yet — retry after Virtuoso renders it.
requestAnimationFrame(() => {
if (!tryFocus()) requestAnimationFrame(tryFocus);
});
}
};
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, data.length - 1));
break;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "ArrowRight":
e.preventDefault();
setSelected(data[index]);
break;
case "Home":
e.preventDefault();
focusRow(0);
break;
case "End":
e.preventDefault();
focusRow(data.length - 1);
break;
}
};
const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(pubKey, el);
else rowRefs.current.delete(pubKey);
};
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
);
return (
<Virtuoso<PeerStatus, PeerRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, peer) => peer.pubKey}
components={{ Header: ListTopSpacer }}
context={ctx}
itemContent={renderPeerRow}
/>
);
};
type PeerRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
<PeerRow
peer={peer}
index={index}
onKeyDown={ctx.onKeyDown}
onSelect={ctx.onSelect}
setRowRef={ctx.setRowRef}
/>
);
type PeerRowProps = {
peer: PeerStatus;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(peer.pubKey, el)}
aria-label={t("peers.row.label", { name: peerName, status: statusLabel })}
onClick={() => onSelect(peer)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<Tooltip content={statusLabel} side={"left"}>
<span
aria-hidden={"true"}
className={cn(
"relative mt-2 h-2 w-2 shrink-0 rounded-full",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div
className={
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
}
>
<div>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
}
/>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<span className={"truncate font-mono text-xs text-nb-gray-400"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"pointer-events-none relative shrink-0 self-center text-xs tabular-nums",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
aria-hidden={"true"}
className={cn(
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
"opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
</div>
);
};