add exit node switcher

This commit is contained in:
Eduard Gert
2026-06-02 17:23:56 +02:00
parent 7798b7cf14
commit 1710868a09
15 changed files with 269 additions and 217 deletions

View File

@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
{...props}
/>
);

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks" | "exitNode";
export type NavSection = "peers" | "networks";
type NavSectionContextValue = {
section: NavSection;

View File

@@ -6,6 +6,7 @@ type Props = {
children: ReactNode;
overlay?: ReactNode;
overlayOpen?: boolean;
className?: string;
};
// iOS-style push transition: incoming pane slides in from the right while
@@ -16,13 +17,14 @@ const PANEL_TRANSITION = {
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
return (
<div
className={cn(
"wails-no-draggable relative m-4",
"wails-no-draggable relative m-5",
"bg-nb-gray-940 border border-nb-gray-920",
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
className,
)}
>
<motion.div

View File

@@ -343,7 +343,11 @@ export const MainConnectionStatusSwitch = () => {
const ip = status?.local.ip || "";
return (
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
<div
className={cn(
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-5",
)}
>
<img
src={netbirdFullLogo}
alt={"NetBird"}

View File

@@ -0,0 +1,215 @@
import { forwardRef, 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, 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 { 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 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 handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active) void toggleExitNode(active.id, true);
return;
}
if (active && active.id === next) return;
void toggleExitNode(next, false);
};
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");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ExitNodeTriggerCard
title={title}
description={description}
disabled={disabled}
active={!!active}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
side={"top"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: "var(--radix-popover-trigger-width)" }}
className={cn(
"z-50 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()}>
<Command.List>
<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={"overflow-hidden -mx-1"}>
<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 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>
)}
</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,
) {
return (
<button
ref={ref}
type={"button"}
disabled={disabled}
className={cn(
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
disabled
? "opacity-60 cursor-not-allowed"
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
className,
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
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"}>
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
<TruncatedText
text={description}
className={
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
}
/>
</div>
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
</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 gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && <Check size={16} 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 gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} 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)}
/>
);

View File

@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import {isWindows} from "@/lib/platform.ts";
import { isWindows } from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -143,21 +143,24 @@ export const MainHeader = () => {
return (
<div
className={cn(
"shrink-0 cursor-default wails-draggable relative",
"flex items-center h-12 top-2.5",
"shrink-0 cursor-default wails-draggable relative z-10",
"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.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div
className={cn(
"grid grid-cols-3 items-center shrink-0",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<div />
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
{settingsSlot}
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
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";
@@ -9,7 +10,6 @@ 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 { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
@@ -37,8 +37,11 @@ const MainBody = () => {
{/* 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.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("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 />
</div>
</div>
{isAdvanced && (
<NavSectionProvider>
@@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => {
const isConnected = status?.status === "Connected";
return (
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
>
<div
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
@@ -68,7 +75,6 @@ const AdvancedAppRightPanel = () => {
<div className={"flex-1 min-h-0 flex flex-col"}>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</div>
</div>
{!isConnected && (

View File

@@ -1,6 +1,6 @@
import { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
@@ -28,11 +28,6 @@ export const Navigation = () => {
label: t("nav.resources.title"),
icon: Layers3Icon,
},
{
value: "exitNode",
label: t("nav.exitNode.title"),
icon: ExitNodeIcon,
},
];
return (
@@ -72,12 +67,4 @@ export const Navigation = () => {
);
};
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);
export type { NavSection } from "@/contexts/NavSectionContext";

View File

@@ -1,184 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as RadioGroup from "@radix-ui/react-radio-group";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { WaypointsIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/inputs/SearchInput";
import { TruncatedText } from "@/components/TruncatedText";
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 { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const ExitNodes = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
// the optimistic radio flip paints in place instead of the row jumping to
// the top.
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(exitNodes.map((n) => [n.id, n]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = exitNodes
.filter((n) => !known.has(n.id))
.sort((a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((n) => n.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [exitNodes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return ordered;
return ordered.filter((r) => r.id.toLowerCase().includes(q));
}, [ordered, search]);
if (isConnected && exitNodes.length === 0) {
return (
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={WaypointsIcon}
title={t("exitNodes.empty.title")}
description={t("exitNodes.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
learnMoreTopic={t("nav.exitNode.title")}
/>
</div>
);
}
return (
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
)}
</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>
</div>
);
};
type ExitNodesListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
const { t } = useTranslation();
const active = data.find((n) => n.selected) ?? null;
const value = active?.id ?? NONE_VALUE;
const handleChange = (next: string) => {
if (next === value) return;
if (next === NONE_VALUE) {
if (active) onToggle(active.id, true);
return;
}
onToggle(next, false);
};
return (
<RadioGroup.Root
value={value}
onValueChange={handleChange}
className={"flex flex-col"}
>
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
{data.map((n) => (
<Row key={n.id} value={n.id} label={n.id} />
))}
</RadioGroup.Root>
);
};
type RowProps = {
value: string;
label: string;
first?: boolean;
};
const Row = ({ value, label, first }: RowProps) => (
<RadioGroup.Item
value={value}
className={cn(
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
first && "mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer outline-none text-left",
)}
>
<div className={"min-w-0 flex-1"}>
<TruncatedText
text={label}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-full"
}
/>
</div>
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border",
"border-nb-gray-700 bg-nb-gray-900",
"flex items-center justify-center",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator
className={"h-2 w-2 rounded-full bg-white"}
/>
</span>
</RadioGroup.Item>
);

View File

@@ -58,7 +58,7 @@ export function SettingsAbout() {
return (
<div
className={
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
}
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />

View File

@@ -381,6 +381,11 @@
"exitNodes.none": "Keiner",
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
"exitNodes.card.title": "Exit Node",
"exitNodes.card.statusActive": "Aktiv",
"exitNodes.card.statusInactive": "Inaktiv",
"exitNodes.dropdown.noneTitle": "Keiner",
"exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node",
"quickActions.connect": "Verbinden",
"quickActions.disconnect": "Trennen",

View File

@@ -398,6 +398,11 @@
"exitNodes.none": "None",
"exitNodes.empty.title": "No exit nodes available",
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
"exitNodes.card.title": "Exit Node",
"exitNodes.card.statusActive": "Active",
"exitNodes.card.statusInactive": "Inactive",
"exitNodes.dropdown.noneTitle": "None",
"exitNodes.dropdown.noneDescription": "Direct connection without an exit node",
"quickActions.connect": "Connect",
"quickActions.disconnect": "Disconnect",

View File

@@ -381,6 +381,11 @@
"exitNodes.none": "Egyik sem",
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
"exitNodes.card.title": "Kilépő csomópont",
"exitNodes.card.statusActive": "Aktív",
"exitNodes.card.statusInactive": "Inaktív",
"exitNodes.dropdown.noneTitle": "Egyik sem",
"exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül",
"quickActions.connect": "Csatlakozás",
"quickActions.disconnect": "Bontás",

View File

@@ -316,7 +316,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
Name: "main",
Title: "NetBird",
Width: initialWidth,
Height: 640,
Height: services.WindowHeight,
Hidden: true,
BackgroundColour: services.WindowBackgroundColour,
URL: "/",

View File

@@ -45,6 +45,10 @@ const EventSettingsOpen = "netbird:settings:open"
// at #181A1D / nb-gray-950, used by AppLayout's <html> background).
var WindowBackgroundColour = application.NewRGB(24, 26, 29)
// WindowHeight is the shared frame height for the main window and the
// Settings window so the right panel inside both ends up the same size.
const WindowHeight = 660
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21);
// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only
@@ -192,7 +196,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
Height: 640,
Height: WindowHeight,
Hidden: true,
DisableResize: true,
MinimiseButtonState: application.ButtonHidden,