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 ( +
+
+
+
{profileSlot}
+
+
{settingsSlot}
); }; 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 ( - + > + + {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) => ( -
  • -
  • + ))} -
+ ); }; + +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"} > -
); }; + +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.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 && ( + +
+ + + + {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",