mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 23:59:08 +02:00
[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:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user