From 0e83d2ad94701be07c68317e6b2fbcd54bb1a94e Mon Sep 17 00:00:00 2001
From: Eduard Gert
Date: Wed, 27 May 2026 14:52:19 +0200
Subject: [PATCH] add peer details
---
client/ui/frontend/package.json | 1 +
client/ui/frontend/pnpm-lock.yaml | 31 +++
.../frontend/src/components/CardNavItem.tsx | 15 +-
.../src/components/CopyToClipboard.tsx | 3 +
.../ui/frontend/src/components/EmptyState.tsx | 70 ++++++
.../ui/frontend/src/components/HoverCard.tsx | 34 +++
.../ui/frontend/src/components/NoResults.tsx | 25 ++
.../src/components/NotConnectedState.tsx | 20 ++
.../frontend/src/components/SearchInput.tsx | 31 ++-
.../ui/frontend/src/components/SquareIcon.tsx | 2 +-
.../src/components/SwitchItemGroup.tsx | 14 +-
client/ui/frontend/src/layouts/AppLayout.tsx | 7 +-
.../src/layouts/ConnectionStatusSwitch.tsx | 4 +-
client/ui/frontend/src/layouts/Header.tsx | 164 ++++++------
client/ui/frontend/src/layouts/Main.tsx | 75 +++---
.../ui/frontend/src/layouts/MainRightSide.tsx | 29 ++-
client/ui/frontend/src/layouts/Navigation.tsx | 124 ++++-----
.../frontend/src/layouts/SettingsLayout.tsx | 9 +-
client/ui/frontend/src/lib/navSection.tsx | 29 +++
.../frontend/src/lib/useKeyboardShortcut.ts | 56 +++++
.../src/modules/exit-nodes/ExitNodes.tsx | 56 ++++-
.../src/modules/exit-nodes/ExitNodesList.tsx | 100 ++++----
.../src/modules/networks/NetworkFilters.tsx | 8 +-
.../src/modules/networks/Networks.tsx | 93 +++++--
.../src/modules/networks/NetworksList.tsx | 237 +++++++++++++++---
.../src/modules/peers/PeerDetailContext.tsx | 28 +++
.../src/modules/peers/PeerDetailPanel.tsx | 122 +++++++++
.../src/modules/peers/PeerDetails.tsx | 198 +++++++++++++++
.../src/modules/peers/PeerFilters.tsx | 4 +-
.../ui/frontend/src/modules/peers/Peers.tsx | 49 +++-
.../frontend/src/modules/peers/PeersList.tsx | 87 +++++--
.../ui/frontend/src/modules/peers/format.ts | 35 +++
client/ui/i18n/locales/de/common.json | 44 +++-
client/ui/i18n/locales/en/common.json | 46 +++-
client/ui/i18n/locales/hu/common.json | 44 +++-
35 files changed, 1549 insertions(+), 345 deletions(-)
create mode 100644 client/ui/frontend/src/components/EmptyState.tsx
create mode 100644 client/ui/frontend/src/components/HoverCard.tsx
create mode 100644 client/ui/frontend/src/components/NoResults.tsx
create mode 100644 client/ui/frontend/src/components/NotConnectedState.tsx
create mode 100644 client/ui/frontend/src/lib/navSection.tsx
create mode 100644 client/ui/frontend/src/lib/useKeyboardShortcut.ts
create mode 100644 client/ui/frontend/src/modules/peers/PeerDetailContext.tsx
create mode 100644 client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx
create mode 100644 client/ui/frontend/src/modules/peers/PeerDetails.tsx
create mode 100644 client/ui/frontend/src/modules/peers/format.ts
diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json
index a66854e50..beb10cd6b 100644
--- a/client/ui/frontend/package.json
+++ b/client/ui/frontend/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml
index ac20a6457..cdca9b0fa 100644
--- a/client/ui/frontend/pnpm-lock.yaml
+++ b/client/ui/frontend/pnpm-lock.yaml
@@ -11,6 +11,9 @@ dependencies:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-hover-card':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
@@ -828,6 +831,34 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
+ /@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: false
+
/@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1):
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
diff --git a/client/ui/frontend/src/components/CardNavItem.tsx b/client/ui/frontend/src/components/CardNavItem.tsx
index 07eba99dd..a1d1c22d3 100644
--- a/client/ui/frontend/src/components/CardNavItem.tsx
+++ b/client/ui/frontend/src/components/CardNavItem.tsx
@@ -1,9 +1,13 @@
-import { ComponentType, forwardRef, ReactNode } from "react";
-import { motion, HTMLMotionProps } from "framer-motion";
+import {
+ ButtonHTMLAttributes,
+ ComponentType,
+ forwardRef,
+ ReactNode,
+} from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
-type Props = HTMLMotionProps<"button"> & {
+type Props = ButtonHTMLAttributes & {
icon?: ComponentType;
iconNode?: ReactNode;
title: string;
@@ -28,10 +32,9 @@ export const CardNavItem = forwardRef(
ref,
) {
return (
- (
)}
-
+
);
},
);
diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx
index 2b8657d8f..6affc3008 100644
--- a/client/ui/frontend/src/components/CopyToClipboard.tsx
+++ b/client/ui/frontend/src/components/CopyToClipboard.tsx
@@ -8,6 +8,7 @@ type CopyToClipboardProps = {
size?: number;
iconAlignment?: "left" | "right";
className?: string;
+ iconClassName?: string;
alwaysShowIcon?: boolean;
};
@@ -17,6 +18,7 @@ export const CopyToClipboard = ({
size = 10,
iconAlignment = "right",
className,
+ iconClassName,
alwaysShowIcon = false,
}: CopyToClipboardProps) => {
const wrapperRef = useRef(null);
@@ -57,6 +59,7 @@ export const CopyToClipboard = ({
className={cn(
"shrink-0 inline-flex relative top-[2px] right-[1px]",
iconAlignment === "left" ? "order-first" : "order-last",
+ iconClassName,
)}
>
;
+ title: string;
+ description?: string;
+ learnMoreUrl?: string;
+ learnMoreTopic?: string;
+ className?: string;
+};
+
+const openUrl = (url: string) => {
+ void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
+};
+
+export const EmptyState = ({
+ icon,
+ title,
+ description,
+ learnMoreUrl,
+ learnMoreTopic,
+ className,
+}: Props) => {
+ const { t } = useTranslation();
+ return (
+
+ );
+};
diff --git a/client/ui/frontend/src/components/HoverCard.tsx b/client/ui/frontend/src/components/HoverCard.tsx
new file mode 100644
index 000000000..f87f3f09c
--- /dev/null
+++ b/client/ui/frontend/src/components/HoverCard.tsx
@@ -0,0 +1,34 @@
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
+import * as React from "react";
+import { cn } from "@/lib/cn";
+
+const HoverCard = HoverCardPrimitive.Root;
+const HoverCardTrigger = HoverCardPrimitive.Trigger;
+const HoverCardPortal = HoverCardPrimitive.Portal;
+
+const HoverCardContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "start", sideOffset = 6, ...props }, ref) => (
+
+
+
+));
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
+
+export { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger };
diff --git a/client/ui/frontend/src/components/NoResults.tsx b/client/ui/frontend/src/components/NoResults.tsx
new file mode 100644
index 000000000..4af76a539
--- /dev/null
+++ b/client/ui/frontend/src/components/NoResults.tsx
@@ -0,0 +1,25 @@
+import { ComponentType } from "react";
+import { FunnelXIcon, LucideProps } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { EmptyState } from "./EmptyState";
+
+type Props = {
+ icon?: ComponentType;
+ title?: string;
+ description?: string;
+};
+
+export const NoResults = ({
+ icon = FunnelXIcon,
+ title,
+ description,
+}: Props) => {
+ const { t } = useTranslation();
+ return (
+
+ );
+};
diff --git a/client/ui/frontend/src/components/NotConnectedState.tsx b/client/ui/frontend/src/components/NotConnectedState.tsx
new file mode 100644
index 000000000..168d3fe7c
--- /dev/null
+++ b/client/ui/frontend/src/components/NotConnectedState.tsx
@@ -0,0 +1,20 @@
+import { UnplugIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { EmptyState } from "./EmptyState";
+
+export const NotConnectedState = () => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/SearchInput.tsx b/client/ui/frontend/src/components/SearchInput.tsx
index b3efa4223..97e07dcc5 100644
--- a/client/ui/frontend/src/components/SearchInput.tsx
+++ b/client/ui/frontend/src/components/SearchInput.tsx
@@ -1,15 +1,24 @@
-import { forwardRef, InputHTMLAttributes } from "react";
+import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = InputHTMLAttributes & {
iconSize?: number;
+ shortcut?: ReactNode;
};
export const SearchInput = forwardRef(
- function SearchInput({ iconSize = 16, className, ...props }, ref) {
+ function SearchInput(
+ { iconSize = 16, className, disabled, shortcut, ...props },
+ ref,
+ ) {
return (
-
+
(
+ {shortcut && (
+
+ {shortcut}
+
+ )}
);
},
diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx
index ae1dfd69d..469655eec 100644
--- a/client/ui/frontend/src/components/SquareIcon.tsx
+++ b/client/ui/frontend/src/components/SquareIcon.tsx
@@ -14,7 +14,7 @@ type SquareIconProps = {
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
diff --git a/client/ui/frontend/src/components/SwitchItemGroup.tsx b/client/ui/frontend/src/components/SwitchItemGroup.tsx
index ecf4ef079..7c13837ad 100644
--- a/client/ui/frontend/src/components/SwitchItemGroup.tsx
+++ b/client/ui/frontend/src/components/SwitchItemGroup.tsx
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
type SwitchItemGroupContextValue = {
value: string;
layoutId: string;
+ disabled: boolean;
};
const SwitchItemGroupContext = createContext
(null);
@@ -22,18 +23,27 @@ type Props = {
onChange: (value: string) => void;
children: ReactNode;
className?: string;
+ disabled?: boolean;
};
-export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => {
+export const SwitchItemGroup = ({
+ value,
+ onChange,
+ children,
+ className,
+ disabled = false,
+}: Props) => {
const layoutId = useId();
return (
-
+
diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx
index 5668b1d4a..4435a085e 100644
--- a/client/ui/frontend/src/layouts/AppLayout.tsx
+++ b/client/ui/frontend/src/layouts/AppLayout.tsx
@@ -1,5 +1,6 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
+import { NavSectionProvider } from "@/lib/navSection";
import { ViewModeProvider } from "@/lib/viewMode";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
@@ -14,8 +15,10 @@ export const AppLayout = () => {
-
-
+
+
+
+
diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx
index ce3b17a68..87e134421 100644
--- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx
+++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx
@@ -319,7 +319,7 @@ export const ConnectionStatusSwitch = () => {
{t(STATUS_KEY[connState])}
@@ -339,7 +339,7 @@ export const ConnectionStatusSwitch = () => {
{
setViewMode(mode);
};
- return (
-
-
-
-
-
-
-
-
-
-
- {updateAvailable && (
- <>
-
-
-
-
- {t("header.menu.updateAvailable")}
-
-
-
-
- >
- )}
-
+ const profileSlot = ;
+
+ const settingsSlot = (
+
+
+
+
+
+
+ {updateAvailable && (
+ <>
+
-
- {t("header.menu.settings")}
+
+
+ {t("header.menu.updateAvailable")}
+
- selectMode("default")}
- />
- selectMode("advanced")}
- />
-
-
- {updateAvailable && (
-
-
-
-
+ >
)}
-
+
+
+
+ {t("header.menu.settings")}
+
+
+
+ selectMode("default")}
+ />
+ selectMode("advanced")}
+ />
+
+
+ {updateAvailable && (
+
+
+
+
+ )}
+
+ );
+
+ // The inner grid is locked to 356px (the default-mode content width:
+ // 380px window − 12px px-3 each side). It stays left-anchored regardless
+ // of window size, so the profile keeps the exact same absolute X
+ // position when the user flips to advanced view. The settings button is
+ // pulled out as an absolute, right-anchored element so it tracks the
+ // window's right edge in both modes.
+ // Header height matches the Settings window's top traffic-light strip
+ // so the right panel ends up the same height in both windows. The h-10
+ // of the inner buttons (profile trigger, more-vertical) defines the
+ // natural height; the strip in SettingsLayout is sized to mirror it.
+ return (
+
);
};
diff --git a/client/ui/frontend/src/layouts/Main.tsx b/client/ui/frontend/src/layouts/Main.tsx
index 1455abf30..4dda83fc5 100644
--- a/client/ui/frontend/src/layouts/Main.tsx
+++ b/client/ui/frontend/src/layouts/Main.tsx
@@ -1,47 +1,56 @@
-import { useState } from "react";
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
-import { Navigation, NavSection } from "@/layouts/Navigation.tsx";
+import { Navigation } from "@/layouts/Navigation.tsx";
+import { useNavSection } from "@/lib/navSection";
import { useViewMode } from "@/lib/viewMode";
import { Peers } from "@/modules/peers/Peers";
import { Networks } from "@/modules/networks/Networks";
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/modules/networks/NetworksContext";
+import {
+ PeerDetailProvider,
+ usePeerDetail,
+} from "@/modules/peers/PeerDetailContext";
+import { PeerDetailPanel } from "@/modules/peers/PeerDetailPanel";
export const Main = () => {
- const { viewMode } = useViewMode();
- const isAdvanced = viewMode === "advanced";
- const [section, setSection] = useState
("peers");
-
return (
-
- {/* Fixed-width column for the connection switch. Navigation
- is rendered absolutely at the bottom in advanced view so
- it doesn't reshape the column and shift the switch up. */}
-
-
- {isAdvanced && (
-
-
-
- )}
-
- {isAdvanced && (
-
- {section === "peers" && }
- {section === "networks" && }
- {section === "exitNode" && }
-
- )}
-
+
+
+
);
};
+
+const MainBody = () => {
+ const { viewMode } = useViewMode();
+ const isAdvanced = viewMode === "advanced";
+ const { section } = useNavSection();
+ const { selected } = usePeerDetail();
+
+ return (
+
+
+
+
+ {isAdvanced && (
+
}
+ overlayOpen={selected !== null}
+ >
+
+
+ {section === "peers" &&
}
+ {section === "networks" &&
}
+ {section === "exitNode" &&
}
+
+
+ )}
+
+ );
+};
diff --git a/client/ui/frontend/src/layouts/MainRightSide.tsx b/client/ui/frontend/src/layouts/MainRightSide.tsx
index 85791f57c..a3e28e4dc 100644
--- a/client/ui/frontend/src/layouts/MainRightSide.tsx
+++ b/client/ui/frontend/src/layouts/MainRightSide.tsx
@@ -1,20 +1,39 @@
import { ReactNode } from "react";
+import { motion } from "framer-motion";
import { cn } from "@/lib/cn.ts";
type Props = {
children: ReactNode;
+ overlay?: ReactNode;
+ overlayOpen?: boolean;
};
-export const MainRightSide = ({ children }: Props) => {
+// iOS-style push transition: incoming pane slides in from the right while
+// the outgoing pane shifts slightly left. Same easing on both sides so
+// they feel like one motion.
+const PANEL_TRANSITION = {
+ duration: 0.32,
+ ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
+};
+
+export const MainRightSide = ({ children, overlay, overlayOpen = false }: Props) => {
return (
- {children}
+
+ {children}
+
+ {overlay}
);
};
diff --git a/client/ui/frontend/src/layouts/Navigation.tsx b/client/ui/frontend/src/layouts/Navigation.tsx
index 9eaf03367..2e274a41f 100644
--- a/client/ui/frontend/src/layouts/Navigation.tsx
+++ b/client/ui/frontend/src/layouts/Navigation.tsx
@@ -1,75 +1,79 @@
-import { useMemo } from "react";
+import { ComponentType } from "react";
import { useTranslation } from "react-i18next";
-import { Layers3Icon, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
-import { CardNavItem } from "@/components/CardNavItem.tsx";
+import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
+import { useNavSection, type NavSection } from "@/lib/navSection";
import { useStatus } from "@/modules/daemon-status/StatusContext";
-import { useNetworks } from "@/modules/networks/NetworksContext";
-export type NavSection = "peers" | "networks" | "exitNode";
-
-type Props = {
- active: NavSection;
- onSelect: (section: NavSection) => void;
+type TabEntry = {
+ value: NavSection;
+ label: string;
+ icon: ComponentType;
};
-export const Navigation = ({ active, onSelect }: Props) => {
+export const Navigation = () => {
const { t } = useTranslation();
+ const { section, setSection } = useNavSection();
const { status } = useStatus();
- const { networkRoutes, exitNodes, activeExitNode } = useNetworks();
+ const isConnected = status?.status === "Connected";
- const peerCounts = useMemo(() => {
- const peers = status?.peers ?? [];
- const online = peers.filter((p) => p.connStatus === "Connected").length;
- return { online, total: peers.length };
- }, [status?.peers]);
-
- const networkCounts = useMemo(
- () => ({
- active: networkRoutes.filter((r) => r.selected).length,
- total: networkRoutes.length,
- }),
- [networkRoutes],
- );
-
- const exitNodeDescription = activeExitNode
- ? activeExitNode.id
- : t("nav.exitNode.none", { total: exitNodes.length });
+ const tabs: TabEntry[] = [
+ {
+ value: "peers",
+ label: t("nav.peers.title"),
+ icon: MonitorSmartphoneIcon,
+ },
+ {
+ value: "networks",
+ label: t("nav.resources.title"),
+ icon: Layers3Icon,
+ },
+ {
+ value: "exitNode",
+ label: t("nav.exitNode.title"),
+ icon: ExitNodeIcon,
+ },
+ ];
return (
-
- onSelect("peers")}
- />
- onSelect("networks")}
- />
-
+ {tabs.map((tab) => {
+ const isActive = tab.value === section;
+ const isDisabled = !isConnected && !isActive;
+ const Icon = tab.icon;
+ return (
+ setSection(tab.value)}
+ disabled={isDisabled}
className={cn(
- "transition-colors duration-150 rotate-45",
- active === "exitNode"
- ? "text-nb-gray-200"
- : "text-nb-gray-400",
+ "group relative flex flex-1 items-center justify-center",
+ "gap-2.5 px-5 py-3",
+ "outline-none transition-all",
+ isActive ? "text-netbird" : "text-nb-gray-500 hover:text-nb-gray-400",
+ isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
)}
- />
- }
- title={t("nav.exitNode.title")}
- description={exitNodeDescription}
- active={active === "exitNode"}
- onClick={() => onSelect("exitNode")}
- />
-
+ >
+
+ {tab.label}
+
+
+ );
+ })}
+
);
};
+
+const ExitNodeIcon = (props: LucideProps) => (
+
+);
+
+export type { NavSection } from "@/lib/navSection";
diff --git a/client/ui/frontend/src/layouts/SettingsLayout.tsx b/client/ui/frontend/src/layouts/SettingsLayout.tsx
index 92baf750b..d136df32a 100644
--- a/client/ui/frontend/src/layouts/SettingsLayout.tsx
+++ b/client/ui/frontend/src/layouts/SettingsLayout.tsx
@@ -9,12 +9,11 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
// settings window has its own native title bar and doesn't show the profile
// selector / panel toggle / settings icon.
//
-// The 38px placeholder strip at the top accounts for the macOS
+// The h-10 placeholder strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
// title bar is invisible but the traffic-light buttons still float in the
-// top-left corner. Without this strip the buttons would overlap the settings
-// content. The strip is `wails-draggable` so users can move the window by
-// dragging it.
+// top-left corner. The height also mirrors the main window's Header so the
+// MainRightSide panel ends up the same height in both windows.
export const SettingsLayout = () => {
return (
@@ -24,7 +23,7 @@ export const SettingsLayout = () => {
diff --git a/client/ui/frontend/src/lib/navSection.tsx b/client/ui/frontend/src/lib/navSection.tsx
new file mode 100644
index 000000000..91d9071cc
--- /dev/null
+++ b/client/ui/frontend/src/lib/navSection.tsx
@@ -0,0 +1,29 @@
+import { createContext, useContext, useState, type ReactNode } from "react";
+
+export type NavSection = "peers" | "networks" | "exitNode";
+
+type NavSectionContextValue = {
+ section: NavSection;
+ setSection: (s: NavSection) => void;
+};
+
+const NavSectionContext = createContext(null);
+
+export const useNavSection = (): NavSectionContextValue => {
+ const ctx = useContext(NavSectionContext);
+ if (!ctx) {
+ throw new Error(
+ "useNavSection must be used inside NavSectionProvider",
+ );
+ }
+ return ctx;
+};
+
+export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
+ const [section, setSection] = useState("peers");
+ return (
+
+ {children}
+
+ );
+};
diff --git a/client/ui/frontend/src/lib/useKeyboardShortcut.ts b/client/ui/frontend/src/lib/useKeyboardShortcut.ts
new file mode 100644
index 000000000..82f50016e
--- /dev/null
+++ b/client/ui/frontend/src/lib/useKeyboardShortcut.ts
@@ -0,0 +1,56 @@
+import { useEffect } from "react";
+
+export type Shortcut = {
+ key: string; // e.g. "k", "Escape", "/"
+ cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
+ shift?: boolean;
+ alt?: boolean;
+ // When true (default), preventDefault is called on a match.
+ preventDefault?: boolean;
+};
+
+// Listens for a keyboard shortcut on the window and invokes `callback` on
+// match. Disable conditionally via `enabled` to avoid stealing keys while a
+// dialog/panel is in the foreground.
+export const useKeyboardShortcut = (
+ shortcut: Shortcut,
+ callback: () => void,
+ enabled = true,
+) => {
+ useEffect(() => {
+ if (!enabled) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return;
+ const mod = e.metaKey || e.ctrlKey;
+ if (!!shortcut.cmd !== mod) return;
+ if (!!shortcut.shift !== e.shiftKey) return;
+ if (!!shortcut.alt !== e.altKey) return;
+ if (shortcut.preventDefault !== false) e.preventDefault();
+ callback();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [
+ shortcut.key,
+ shortcut.cmd,
+ shortcut.shift,
+ shortcut.alt,
+ shortcut.preventDefault,
+ callback,
+ enabled,
+ ]);
+};
+
+// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
+export const isMac =
+ typeof navigator !== "undefined" &&
+ /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
+
+export const formatShortcut = (shortcut: Shortcut): string => {
+ const parts: string[] = [];
+ if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
+ if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
+ if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
+ parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
+ return parts.join(isMac ? "" : "+");
+};
diff --git a/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx
index 476e33e12..9ee265b11 100644
--- a/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx
+++ b/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx
@@ -1,13 +1,26 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
+import { EmptyState } from "@/components/EmptyState";
+import { NoResults } from "@/components/NoResults";
+import { NotConnectedState } from "@/components/NotConnectedState";
+import { useStatus } from "@/modules/daemon-status/StatusContext";
+import {
+ formatShortcut,
+ useKeyboardShortcut,
+} from "@/lib/useKeyboardShortcut";
+
+const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
import { useNetworks } from "@/modules/networks/NetworksContext";
import { ExitNodesList } from "./ExitNodesList";
export const ExitNodes = () => {
const { t } = useTranslation();
+ const { status } = useStatus();
+ const isConnected = status?.status === "Connected";
const { exitNodes, toggleExitNode } = useNetworks();
const [search, setSearch] = useState("");
const searchRef = useRef(null);
@@ -16,14 +29,16 @@ export const ExitNodes = () => {
searchRef.current?.focus();
}, []);
+ useKeyboardShortcut(SEARCH_SHORTCUT, () => {
+ searchRef.current?.focus();
+ searchRef.current?.select();
+ });
+
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const matches = exitNodes.filter((r) => {
if (!q) return true;
- return (
- r.id.toLowerCase().includes(q) ||
- r.range.toLowerCase().includes(q)
- );
+ return r.id.toLowerCase().includes(q);
});
return matches.sort((a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
@@ -31,6 +46,32 @@ export const ExitNodes = () => {
});
}, [exitNodes, search]);
+ if (!isConnected) {
+ return (
+
+
+
+ );
+ }
+
+ if (exitNodes.length === 0) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -39,6 +80,7 @@ export const ExitNodes = () => {
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
+ shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
{
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
-
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ )}
- selected ? "bg-green-400" : "bg-nb-gray-500";
+const NONE_VALUE = "__none__";
type Props = {
data: Network[];
@@ -13,49 +12,64 @@ type Props = {
export const ExitNodesList = ({ data, onToggle }: Props) => {
const { t } = useTranslation();
- if (data.length === 0) {
- return (
-
- {t("exitNodes.empty")}
-
- );
- }
+ const active = data.find((n) => n.selected) ?? null;
+ const value = active?.id ?? NONE_VALUE;
+
+ const handleChange = (next: string) => {
+ if (next === value) return;
+ if (next === NONE_VALUE) {
+ if (active) onToggle(active.id, true);
+ return;
+ }
+ onToggle(next, false);
+ };
return (
-
+
+
{data.map((n) => (
-
- onToggle(n.id, n.selected)}
- className={cn(
- "h-2 w-2 rounded-full shrink-0 cursor-pointer",
- dotClass(n.selected),
- )}
- title={
- n.selected
- ? t("exitNodes.active")
- : t("exitNodes.inactive")
- }
- />
-
-
- {n.id}
-
-
-
-
- {n.range}
-
-
-
+
))}
-
+
);
};
+
+type RowProps = {
+ value: string;
+ label: string;
+};
+
+const Row = ({ value, label }: RowProps) => (
+
+
+
+
+
+ {label}
+
+
+);
diff --git a/client/ui/frontend/src/modules/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/networks/NetworkFilters.tsx
index c450f33c7..5f2b4bb4b 100644
--- a/client/ui/frontend/src/modules/networks/NetworkFilters.tsx
+++ b/client/ui/frontend/src/modules/networks/NetworkFilters.tsx
@@ -2,19 +2,20 @@ import { useTranslation } from "react-i18next";
import { SwitchItem } from "@/components/SwitchItem";
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
-export type NetworkFilter = "all" | "selected" | "overlapping";
+export type NetworkFilter = "all" | "active" | "overlapping";
type Props = {
value: NetworkFilter;
onChange: (value: NetworkFilter) => void;
counts: Record;
+ disabled?: boolean;
};
-export const NetworkFilters = ({ value, onChange, counts }: Props) => {
+export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
- { value: "selected", label: t("networks.filter.selected") },
+ { value: "active", label: t("networks.filter.active") },
{ value: "overlapping", label: t("networks.filter.overlapping") },
];
@@ -23,6 +24,7 @@ export const NetworkFilters = ({ value, onChange, counts }: Props) => {
key={i18n.language}
value={value}
onChange={(v) => onChange(v as NetworkFilter)}
+ disabled={disabled}
className={"w-full"}
>
{filters.map((f) => (
diff --git a/client/ui/frontend/src/modules/networks/Networks.tsx b/client/ui/frontend/src/modules/networks/Networks.tsx
index f2aa55783..3e2334593 100644
--- a/client/ui/frontend/src/modules/networks/Networks.tsx
+++ b/client/ui/frontend/src/modules/networks/Networks.tsx
@@ -1,13 +1,29 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { NetworkIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
-import { NetworkFilters, NetworkFilter } from "./NetworkFilters";
+import { EmptyState } from "@/components/EmptyState";
+import { NoResults } from "@/components/NoResults";
+import { NotConnectedState } from "@/components/NotConnectedState";
+import { useStatus } from "@/modules/daemon-status/StatusContext";
+import {
+ formatShortcut,
+ useKeyboardShortcut,
+} from "@/lib/useKeyboardShortcut";
+
+const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
+import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { NetworksList } from "./NetworksList";
import { useNetworks } from "./NetworksContext";
-const collectOverlapping = (routes: { id: string; range: string; domains: string[] }[]): Set => {
+// Map every range string -> ids of CIDR routes that share it. Domain routes
+// are skipped (they overlap on domain, not prefix). Single-entry buckets
+// aren't overlaps.
+const buildOverlapMap = (
+ routes: { id: string; range: string; domains: string[] }[],
+): Map => {
const byRange = new Map();
for (const r of routes) {
if (r.domains.length > 0) continue;
@@ -15,15 +31,17 @@ const collectOverlapping = (routes: { id: string; range: string; domains: string
arr.push(r.id);
byRange.set(r.range, arr);
}
- const out = new Set();
- for (const ids of byRange.values()) {
- if (ids.length > 1) ids.forEach((id) => out.add(id));
+ const out = new Map();
+ 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 } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
@@ -33,25 +51,38 @@ export const Networks = () => {
searchRef.current?.focus();
}, []);
- const overlappingIds = useMemo(
- () => collectOverlapping(networkRoutes),
+ useKeyboardShortcut(SEARCH_SHORTCUT, () => {
+ searchRef.current?.focus();
+ searchRef.current?.select();
+ });
+
+ const overlapGroups = useMemo(
+ () => buildOverlapMap(networkRoutes),
[networkRoutes],
);
+ const overlapById = useMemo(() => {
+ const map = new Map();
+ for (const ids of overlapGroups.values()) {
+ for (const id of ids) map.set(id, ids);
+ }
+ return map;
+ }, [overlapGroups]);
+
const counts = useMemo>(
() => ({
all: networkRoutes.length,
- selected: networkRoutes.filter((r) => r.selected).length,
- overlapping: overlappingIds.size,
+ active: networkRoutes.filter((r) => r.selected).length,
+ overlapping: overlapById.size,
}),
- [networkRoutes, overlappingIds],
+ [networkRoutes, overlapById],
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const matches = networkRoutes.filter((r) => {
- if (filter === "selected" && !r.selected) return false;
- if (filter === "overlapping" && !overlappingIds.has(r.id)) return false;
+ 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(" ")
@@ -64,7 +95,33 @@ export const Networks = () => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
});
- }, [networkRoutes, search, filter, overlappingIds]);
+ }, [networkRoutes, search, filter, overlapById]);
+
+ if (!isConnected) {
+ return (
+
+
+
+ );
+ }
+
+ if (networkRoutes.length === 0) {
+ return (
+
+
+
+ );
+ }
return (
@@ -74,6 +131,7 @@ export const Networks = () => {
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
+ shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
{
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
-
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ )}
- selected ? "bg-green-400" : "bg-nb-gray-500";
+// The daemon stringifies route.Network via netip.Prefix.String(). For
+// DNS-based routes the prefix is the zero value, which Go renders as
+// "invalid Prefix". Those rows render their domain + resolved IPs instead.
+const INVALID_PREFIX = "invalid Prefix";
+
+const isDnsRoute = (n: Network): boolean =>
+ n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
+
+// Mirror management's NetworkResourceType (resource.go GetResourceType):
+// a CIDR is a host when its prefix length equals the address width
+// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with
+// domains attached are domain resources.
+type ResourceType = "host" | "subnet" | "domain";
+
+const isHostCidr = (cidr: string): boolean => {
+ const [addr, bitsStr] = cidr.split("/");
+ if (!addr || !bitsStr) return false;
+ const bits = Number(bitsStr);
+ // IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'.
+ const isV6 = addr.includes(":");
+ return isV6 ? bits === 128 : bits === 32;
+};
+
+const resourceTypeOf = (n: Network): ResourceType => {
+ if (isDnsRoute(n)) return "domain";
+ // n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs
+ // come comma-joined, but those are filtered out upstream — guard
+ // defensively by inspecting only the first segment.
+ const primary = n.range.split(",")[0].trim();
+ return isHostCidr(primary) ? "host" : "subnet";
+};
+
+const ResourceIcon = ({ type }: { type: ResourceType }) => {
+ if (type === "host") return ;
+ if (type === "domain") return ;
+ return ;
+};
type Props = {
data: Network[];
@@ -13,13 +50,6 @@ type Props = {
export const NetworksList = ({ data, onToggle }: Props) => {
const { t } = useTranslation();
- if (data.length === 0) {
- return (
-
- {t("networks.empty")}
-
- );
- }
return (
@@ -28,30 +58,177 @@ export const NetworksList = ({ data, onToggle }: Props) => {
key={n.id}
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
>
- onToggle(n.id, n.selected)}
- className={cn(
- "h-2 w-2 rounded-full shrink-0 cursor-pointer",
- dotClass(n.selected),
- )}
- title={n.selected ? t("networks.selected") : t("networks.unselected")}
+ onToggle(n.id, n.selected)}
+ label={
+ n.selected
+ ? t("networks.selected")
+ : t("networks.unselected")
+ }
/>
-
-
- {n.id}
-
-
-
-
- {n.range}
-
-
+
+
+
+
+
+
+ {n.id}
+
+
+
+
))}
);
};
+
+const Subtitle = ({ network }: { network: Network }) => {
+ if (isDnsRoute(network)) {
+ const domain = network.domains[0];
+ const ips = network.resolvedIps[domain] ?? [];
+ return ;
+ }
+
+ if (network.range && network.range !== INVALID_PREFIX) {
+ return (
+
+
+ {network.range}
+
+
+ );
+ }
+
+ return null;
+};
+
+type DomainSubtitleProps = {
+ domain: string;
+ ips: string[];
+};
+
+const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
+ const first = ips[0];
+ const extra = ips.length - 1;
+
+ return (
+
+
+
+ {domain}
+
+
+ {first && (
+ <>
+ ·
+
+
+ {first}
+
+
+ {extra > 0 && }
+ >
+ )}
+
+ );
+};
+
+const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
+ const { t } = useTranslation();
+ const extra = ips.length - 1;
+
+ return (
+
+
+
+ {t("networks.ips.more", { count: extra })}
+
+
+
+
+
+ {t("networks.ips.heading")}
+
+
+ {ips.map((ip) => (
+
+
+
+ {ip}
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+type ToggleProps = {
+ checked: boolean;
+ onChange: () => void;
+ label: string;
+};
+
+const NetworkToggle = ({ checked, onChange, label }: ToggleProps) => (
+
+
+
+);
diff --git a/client/ui/frontend/src/modules/peers/PeerDetailContext.tsx b/client/ui/frontend/src/modules/peers/PeerDetailContext.tsx
new file mode 100644
index 000000000..0cbe6f898
--- /dev/null
+++ b/client/ui/frontend/src/modules/peers/PeerDetailContext.tsx
@@ -0,0 +1,28 @@
+import { createContext, useContext, useState, type ReactNode } from "react";
+import type { PeerStatus } from "@bindings/services/models.js";
+
+type PeerDetailContextValue = {
+ selected: PeerStatus | null;
+ setSelected: (p: PeerStatus | null) => void;
+};
+
+const PeerDetailContext = createContext(null);
+
+export const usePeerDetail = (): PeerDetailContextValue => {
+ const ctx = useContext(PeerDetailContext);
+ if (!ctx) {
+ throw new Error(
+ "usePeerDetail must be used inside PeerDetailProvider",
+ );
+ }
+ return ctx;
+};
+
+export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
+ const [selected, setSelected] = useState(null);
+ return (
+
+ {children}
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx
new file mode 100644
index 000000000..0745b1003
--- /dev/null
+++ b/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx
@@ -0,0 +1,122 @@
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { AnimatePresence, motion, type Transition } from "framer-motion";
+import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { ArrowLeftIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { useStatus } from "@/modules/daemon-status/StatusContext";
+import { PeerDetails } from "./PeerDetails";
+import { usePeerDetail } from "./PeerDetailContext";
+
+const DEFAULT_TRANSITION: Transition = {
+ duration: 0.32,
+ ease: [0.32, 0.72, 0, 1],
+};
+
+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 } = useStatus();
+
+ // Keep `selected` in sync with the live peer list so the panel reflects
+ // status / latency / byte updates without re-opening. If the peer
+ // disappears, close the panel.
+ useEffect(() => {
+ if (!selected) return;
+ const peers = status?.peers ?? [];
+ const fresh = peers.find((p) => p.pubKey === selected.pubKey);
+ if (!fresh) {
+ setSelected(null);
+ return;
+ }
+ if (fresh !== selected) setSelected(fresh);
+ }, [status, selected, setSelected]);
+
+ // Esc closes the panel.
+ useEffect(() => {
+ if (!selected) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setSelected(null);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [selected, setSelected]);
+
+ return (
+
+ {selected && (
+
+
+
setSelected(null)}
+ aria-label={t("common.close")}
+ className={cn(
+ "shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
+ "text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
+ "transition-colors outline-none cursor-default",
+ "wails-no-draggable",
+ )}
+ >
+
+
+
+
+ {selected.fqdn || selected.ip}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/peers/PeerDetails.tsx b/client/ui/frontend/src/modules/peers/PeerDetails.tsx
new file mode 100644
index 000000000..3039916cb
--- /dev/null
+++ b/client/ui/frontend/src/modules/peers/PeerDetails.tsx
@@ -0,0 +1,198 @@
+import { ComponentType, ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ArrowDownIcon,
+ ArrowUpDownIcon,
+ ArrowUpIcon,
+ CableIcon,
+ ClockIcon,
+ GaugeIcon,
+ HandshakeIcon,
+ KeyRoundIcon,
+ Layers3Icon,
+ LucideProps,
+ MapPinIcon,
+ MonitorIcon,
+ NetworkIcon,
+ Radio,
+ ZapIcon,
+} from "lucide-react";
+import type { PeerStatus } from "@bindings/services/models.js";
+import { cn } from "@/lib/cn";
+import { CopyToClipboard } from "@/components/CopyToClipboard";
+import { formatBytes, formatRelative, latencyColor } from "./format";
+
+type Props = {
+ peer: PeerStatus;
+};
+
+const DASH = "-";
+
+export const PeerDetails = ({ peer }: Props) => {
+ const { t } = useTranslation();
+ const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never");
+ const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH;
+ const isConnected = peer.connStatus === "Connected";
+ const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
+ const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
+
+ return (
+
+
+ {peer.ip ? (
+
+ {peer.ip}
+
+ ) : (
+ DASH
+ )}
+
+ {isConnected && (
+
+
+
+ {connectionLabel}
+
+
+ )}
+ {peer.latencyMs > 0 && (
+
+
+ {peer.latencyMs} ms
+
+
+ )}
+ {(peer.bytesRx > 0 || peer.bytesTx > 0) && (
+
+
+
+
+
{t("peers.details.bytesReceived")}:
+
{formatBytes(peer.bytesRx)}
+
+
+
+
{t("peers.details.bytesSent")}:
+
{formatBytes(peer.bytesTx)}
+
+
+
+ )}
+
+ {lastHandshake}
+
+
+ {statusSince}
+
+
+
+ {peer.relayed && (
+
+ {peer.relayAddress ? (
+
+ {peer.relayAddress}
+
+ ) : (
+ DASH
+ )}
+
+ )}
+ {peer.networks.length > 0 && (
+
+ {peer.networks.join(", ")}
+
+ )}
+
+ {peer.pubKey ? (
+
+ {peer.pubKey}
+
+ ) : (
+ DASH
+ )}
+
+
+ );
+};
+
+type RowProps = {
+ icon: ComponentType;
+ iconClassName?: string;
+ label: string;
+ children: ReactNode;
+};
+
+type IceRowProps = {
+ icon: ComponentType;
+ 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 (
+
+ {endpoint ? (
+
+ {endpoint}
+
+ ) : (
+ {capitalize(type)}
+ )}
+
+ );
+};
+
+const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
+
+
+ {label}
+
+ {children}
+
+
+);
diff --git a/client/ui/frontend/src/modules/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/peers/PeerFilters.tsx
index 6564c5a37..144fb063b 100644
--- a/client/ui/frontend/src/modules/peers/PeerFilters.tsx
+++ b/client/ui/frontend/src/modules/peers/PeerFilters.tsx
@@ -8,9 +8,10 @@ type Props = {
value: StatusFilter;
onChange: (value: StatusFilter) => void;
counts: Record;
+ disabled?: boolean;
};
-export const PeerFilters = ({ value, onChange, counts }: Props) => {
+export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
@@ -23,6 +24,7 @@ export const PeerFilters = ({ value, onChange, counts }: Props) => {
key={i18n.language}
value={value}
onChange={(v) => onChange(v as StatusFilter)}
+ disabled={disabled}
className={"w-full"}
>
{filters.map((f) => (
diff --git a/client/ui/frontend/src/modules/peers/Peers.tsx b/client/ui/frontend/src/modules/peers/Peers.tsx
index be5ae5e1c..44d9c1874 100644
--- a/client/ui/frontend/src/modules/peers/Peers.tsx
+++ b/client/ui/frontend/src/modules/peers/Peers.tsx
@@ -1,14 +1,24 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { LaptopIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
+import { EmptyState } from "@/components/EmptyState";
+import { NoResults } from "@/components/NoResults";
+import { NotConnectedState } from "@/components/NotConnectedState";
import { useStatus } from "@/modules/daemon-status/StatusContext";
+import {
+ formatShortcut,
+ useKeyboardShortcut,
+} from "@/lib/useKeyboardShortcut";
import { PeerFilters, StatusFilter } from "./PeerFilters";
import { PeersList } from "./PeersList";
const isOnline = (connStatus: string) => connStatus === "Connected";
+const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
+
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
@@ -23,6 +33,12 @@ export const Peers = () => {
searchRef.current?.focus();
}, []);
+ useKeyboardShortcut(SEARCH_SHORTCUT, () => {
+ searchRef.current?.focus();
+ searchRef.current?.select();
+ });
+
+ const isConnected = status?.status === "Connected";
const peers = status?.peers ?? [];
const counts = useMemo>(() => {
@@ -54,6 +70,32 @@ export const Peers = () => {
});
}, [peers, search, statusFilter]);
+ if (!isConnected) {
+ return (
+
+
+
+ );
+ }
+
+ if (peers.length === 0) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -62,6 +104,7 @@ export const Peers = () => {
placeholder={t("peers.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
+ shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
{
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
-
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ )}
{
switch (connStatus) {
@@ -15,34 +17,69 @@ const dotClass = (connStatus: string): string => {
};
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
- const { t } = useTranslation();
- if (data.length === 0) {
- return (
- {t("peers.empty")}
- );
- }
+ const { setSelected } = usePeerDetail();
return (
- {data.map((peer) => (
-
-
-
-
- {peer.fqdn}
-
-
- {
+ const isConnected = peer.connStatus === "Connected";
+ return (
+ setSelected(peer)}
+ className={cn(
+ "group flex items-start gap-2.5 px-7 py-3 min-w-0",
+ "hover:bg-nb-gray-900/40 transition-colors",
+ "wails-no-draggable cursor-pointer",
+ )}
>
- {peer.ip}
-
-
- ))}
+
+
+
+
+
+ {peer.fqdn}
+
+
+
+
+
+
+ {peer.ip}
+
+
+
+
+ {isConnected && peer.latencyMs > 0 && (
+
+ {peer.latencyMs} ms
+
+ )}
+
+
+ );
+ })}
);
};
diff --git a/client/ui/frontend/src/modules/peers/format.ts b/client/ui/frontend/src/modules/peers/format.ts
new file mode 100644
index 000000000..5ac70b161
--- /dev/null
+++ b/client/ui/frontend/src/modules/peers/format.ts
@@ -0,0 +1,35 @@
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ try {
+ if (bytes === 0) return "0 B";
+
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return (
+ parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) +
+ " " +
+ sizes[i]
+ );
+ } catch {
+ return "0 B";
+ }
+};
+
+export const latencyColor = (ms: number): string => {
+ if (ms <= 0) return "text-nb-gray-400";
+ if (ms < 100) return "text-green-400";
+ return "text-yellow-400";
+};
+
+export const formatRelative = (
+ unixSeconds: number,
+ nowMs: number = Date.now(),
+): string | null => {
+ if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
+ const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
+ if (diff < 60) return `${diff}s ago`;
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+};
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json
index 5b3d36897..526eb21d9 100644
--- a/client/ui/i18n/locales/de/common.json
+++ b/client/ui/i18n/locales/de/common.json
@@ -61,6 +61,11 @@
"common.refresh": "Aktualisieren",
"common.loading": "Lädt…",
"common.netbird": "NetBird",
+ "common.learnMoreAbout": "Mehr erfahren über",
+ "common.noResults.title": "Keine Ergebnisse gefunden",
+ "common.noResults.description": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter.",
+ "notConnected.title": "Nicht verbunden",
+ "notConnected.description": "Verbinden Sie sich mit NetBird, um Peers, Netzwerke und Exit Nodes anzuzeigen.",
"connect.status.disconnected": "Getrennt",
"connect.status.connecting": "Verbindet…",
@@ -74,11 +79,12 @@
"connect.error.disconnectTitle": "Trennen fehlgeschlagen",
"nav.peers.title": "Peers",
- "nav.peers.description": "{online} von {total} online",
+ "nav.peers.description": "{connected} von {total} verbunden",
"nav.resources.title": "Ressourcen",
"nav.resources.description": "{active} von {total} aktiv",
"nav.exitNode.title": "Exit-Node",
- "nav.exitNode.none": "{total} verfügbar",
+ "nav.exitNode.none": "Nicht aktiv",
+ "nav.exitNode.using": "Über {name}",
"header.openSettings": "Einstellungen öffnen",
"header.togglePanel": "Seitenleiste umschalten",
@@ -303,20 +309,42 @@
"peers.filter.all": "Alle",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
- "peers.empty": "Keine Peers entsprechen den aktuellen Filtern.",
+ "peers.empty.title": "Noch keine Peers",
+ "peers.empty.description": "Es sind keine Peers mit Ihrem Netzwerk verbunden. Fügen Sie einen Peer hinzu, um zu beginnen.",
+ "peers.details.domain": "Domain",
+ "peers.details.netbirdIp": "NetBird-IP",
+ "peers.details.publicKey": "Öffentlicher Schlüssel",
+ "peers.details.connection": "Verbindung",
+ "peers.details.latency": "Latenz",
+ "peers.details.lastHandshake": "Letzter Handshake",
+ "peers.details.statusSince": "Letzte Verbindungsaktualisierung",
+ "peers.details.bytes": "Bytes",
+ "peers.details.bytesSent": "Gesendet",
+ "peers.details.bytesReceived": "Empfangen",
+ "peers.details.localIce": "Lokales ICE",
+ "peers.details.remoteIce": "Remote ICE",
+ "peers.details.never": "Nie",
+ "peers.details.relayAddress": "Relay",
+ "peers.details.networks": "Netzwerke",
+ "peers.details.relayed": "Relayed",
+ "peers.details.p2p": "P2P",
+ "peers.details.rosenpass": "Rosenpass aktiviert",
"networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen",
"networks.filter.all": "Alle",
- "networks.filter.selected": "Ausgewählt",
+ "networks.filter.active": "Aktiv",
"networks.filter.overlapping": "Überlappend",
- "networks.empty": "Keine Netzwerke entsprechen den aktuellen Filtern.",
+ "networks.empty.title": "Keine Netzwerke verfügbar",
+ "networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
"networks.selected": "Ausgewählt",
"networks.unselected": "Nicht ausgewählt",
+ "networks.ips.more": "+{count} weitere",
+ "networks.ips.heading": "Aufgelöste IPs",
"exitNodes.search.placeholder": "Exit Nodes suchen",
- "exitNodes.empty": "Keine Exit Nodes verfügbar.",
- "exitNodes.active": "Aktiv",
- "exitNodes.inactive": "Inaktiv",
+ "exitNodes.none": "Keiner",
+ "exitNodes.empty.title": "Keine Exit Nodes verfügbar",
+ "exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
"quickActions.connect": "Verbinden",
"quickActions.disconnect": "Trennen",
diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json
index 23d22f2f4..1831e71fc 100644
--- a/client/ui/i18n/locales/en/common.json
+++ b/client/ui/i18n/locales/en/common.json
@@ -61,6 +61,11 @@
"common.refresh": "Refresh",
"common.loading": "Loading…",
"common.netbird": "NetBird",
+ "common.learnMoreAbout": "Learn more about",
+ "common.noResults.title": "Could not find any results",
+ "common.noResults.description": "We couldn't find any results. Please try a different search term or change your filters.",
+ "notConnected.title": "Not connected",
+ "notConnected.description": "Connect to NetBird to view peers, networks, and exit nodes.",
"connect.status.disconnected": "Disconnected",
"connect.status.connecting": "Connecting...",
@@ -74,11 +79,12 @@
"connect.error.disconnectTitle": "Disconnect Failed",
"nav.peers.title": "Peers",
- "nav.peers.description": "{online} of {total} Online",
+ "nav.peers.description": "{connected} of {total} connected",
"nav.resources.title": "Resources",
- "nav.resources.description": "{active} of {total} Active",
+ "nav.resources.description": "{active} of {total} active",
"nav.exitNode.title": "Exit Node",
- "nav.exitNode.none": "{total} available",
+ "nav.exitNode.none": "Not active",
+ "nav.exitNode.using": "Via {name}",
"header.openSettings": "Open settings",
"header.togglePanel": "Toggle side panel",
@@ -319,20 +325,42 @@
"peers.filter.all": "All",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
- "peers.empty": "No peers match the current filters.",
+ "peers.empty.title": "No peers yet",
+ "peers.empty.description": "No peers are connected to your network. Add a peer to get started.",
+ "peers.details.domain": "Domain",
+ "peers.details.netbirdIp": "NetBird IP",
+ "peers.details.publicKey": "Public key",
+ "peers.details.connection": "Connection",
+ "peers.details.latency": "Latency",
+ "peers.details.lastHandshake": "Last handshake",
+ "peers.details.statusSince": "Last connection update",
+ "peers.details.bytes": "Bytes",
+ "peers.details.bytesSent": "Sent",
+ "peers.details.bytesReceived": "Received",
+ "peers.details.localIce": "Local ICE",
+ "peers.details.remoteIce": "Remote ICE",
+ "peers.details.never": "Never",
+ "peers.details.relayAddress": "Relay",
+ "peers.details.networks": "Networks",
+ "peers.details.relayed": "Relayed",
+ "peers.details.p2p": "P2P",
+ "peers.details.rosenpass": "Rosenpass enabled",
"networks.search.placeholder": "Search by network, range or domain",
"networks.filter.all": "All",
- "networks.filter.selected": "Selected",
+ "networks.filter.active": "Active",
"networks.filter.overlapping": "Overlapping",
- "networks.empty": "No networks match the current filters.",
+ "networks.empty.title": "No networks available",
+ "networks.empty.description": "No routed networks have been shared with this peer.",
"networks.selected": "Selected",
"networks.unselected": "Not selected",
+ "networks.ips.more": "+{count} more",
+ "networks.ips.heading": "Resolved IPs",
"exitNodes.search.placeholder": "Search exit nodes",
- "exitNodes.empty": "No exit nodes available.",
- "exitNodes.active": "Active",
- "exitNodes.inactive": "Inactive",
+ "exitNodes.none": "None",
+ "exitNodes.empty.title": "No exit nodes available",
+ "exitNodes.empty.description": "No exit nodes have been shared with this peer.",
"quickActions.connect": "Connect",
"quickActions.disconnect": "Disconnect",
diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json
index ec046113e..4565bdd87 100644
--- a/client/ui/i18n/locales/hu/common.json
+++ b/client/ui/i18n/locales/hu/common.json
@@ -61,6 +61,11 @@
"common.refresh": "Frissítés",
"common.loading": "Betöltés…",
"common.netbird": "NetBird",
+ "common.learnMoreAbout": "Tudjon meg többet erről:",
+ "common.noResults.title": "Nincs találat",
+ "common.noResults.description": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket.",
+ "notConnected.title": "Nincs csatlakozva",
+ "notConnected.description": "Csatlakozzon a NetBirdhöz a társak, hálózatok és kilépő csomópontok megtekintéséhez.",
"connect.status.disconnected": "Lekapcsolva",
"connect.status.connecting": "Csatlakozás…",
@@ -74,11 +79,12 @@
"connect.error.disconnectTitle": "Bontás sikertelen",
"nav.peers.title": "Társak",
- "nav.peers.description": "{online} / {total} online",
+ "nav.peers.description": "{connected} / {total} csatlakoztatva",
"nav.resources.title": "Erőforrások",
"nav.resources.description": "{active} / {total} aktív",
"nav.exitNode.title": "Kilépő csomópont",
- "nav.exitNode.none": "{total} elérhető",
+ "nav.exitNode.none": "Nem aktív",
+ "nav.exitNode.using": "Ezen át: {name}",
"header.openSettings": "Beállítások megnyitása",
"header.togglePanel": "Oldalsó panel váltása",
@@ -303,20 +309,42 @@
"peers.filter.all": "Összes",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
- "peers.empty": "Egyetlen társ sem felel meg a jelenlegi szűrőknek.",
+ "peers.empty.title": "Még nincsenek társak",
+ "peers.empty.description": "Egyetlen társ sem csatlakozott a hálózatához. Adjon hozzá egy társat a kezdéshez.",
+ "peers.details.domain": "Domain",
+ "peers.details.netbirdIp": "NetBird IP",
+ "peers.details.publicKey": "Publikus kulcs",
+ "peers.details.connection": "Kapcsolat",
+ "peers.details.latency": "Késleltetés",
+ "peers.details.lastHandshake": "Utolsó kézfogás",
+ "peers.details.statusSince": "Utolsó kapcsolati frissítés",
+ "peers.details.bytes": "Bájtok",
+ "peers.details.bytesSent": "Küldve",
+ "peers.details.bytesReceived": "Fogadva",
+ "peers.details.localIce": "Helyi ICE",
+ "peers.details.remoteIce": "Távoli ICE",
+ "peers.details.never": "Soha",
+ "peers.details.relayAddress": "Relay",
+ "peers.details.networks": "Hálózatok",
+ "peers.details.relayed": "Relayed",
+ "peers.details.p2p": "P2P",
+ "peers.details.rosenpass": "Rosenpass engedélyezve",
"networks.search.placeholder": "Keresés hálózat, tartomány vagy domain alapján",
"networks.filter.all": "Összes",
- "networks.filter.selected": "Kiválasztott",
+ "networks.filter.active": "Aktív",
"networks.filter.overlapping": "Átfedő",
- "networks.empty": "Egyetlen hálózat sem felel meg a jelenlegi szűrőknek.",
+ "networks.empty.title": "Nincs elérhető hálózat",
+ "networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
"networks.selected": "Kiválasztva",
"networks.unselected": "Nincs kiválasztva",
+ "networks.ips.more": "+{count} további",
+ "networks.ips.heading": "Feloldott IP-címek",
"exitNodes.search.placeholder": "Keresés a kilépő csomópontok között",
- "exitNodes.empty": "Nincs elérhető kilépő csomópont.",
- "exitNodes.active": "Aktív",
- "exitNodes.inactive": "Inaktív",
+ "exitNodes.none": "Egyik sem",
+ "exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
+ "exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
"quickActions.connect": "Csatlakozás",
"quickActions.disconnect": "Bontás",