From 67a1f3c4fed07f34365e0bcc9b1e4e587a9d3786 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 26 May 2026 12:09:08 +0200 Subject: [PATCH] add peers, networks and exit node list (wip) --- .../frontend/src/i18n/locales/de/common.json | 17 +- .../frontend/src/i18n/locales/en/common.json | 17 +- .../frontend/src/i18n/locales/hu/common.json | 17 +- client/ui/frontend/src/layouts/Main.tsx | 49 ++-- client/ui/frontend/src/layouts/Navigation.tsx | 66 ++++-- .../src/modules/exit-nodes/ExitNodes.tsx | 67 ++++++ .../src/modules/exit-nodes/ExitNodesList.tsx | 61 +++++ .../src/modules/networks/NetworkFilters.tsx | 36 +++ .../src/modules/networks/Networks.tsx | 107 +++++++++ .../src/modules/networks/NetworksContext.tsx | 126 +++++++++++ .../src/modules/networks/NetworksList.tsx | 57 +++++ client/ui/frontend/src/modules/peers/peers.md | 48 ++++ client/ui/frontend/src/screens/Networks.tsx | 171 -------------- client/ui/frontend/src/screens/Peers.tsx | 211 ------------------ 14 files changed, 630 insertions(+), 420 deletions(-) create mode 100644 client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx create mode 100644 client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx create mode 100644 client/ui/frontend/src/modules/networks/NetworkFilters.tsx create mode 100644 client/ui/frontend/src/modules/networks/Networks.tsx create mode 100644 client/ui/frontend/src/modules/networks/NetworksContext.tsx create mode 100644 client/ui/frontend/src/modules/networks/NetworksList.tsx create mode 100644 client/ui/frontend/src/modules/peers/peers.md delete mode 100644 client/ui/frontend/src/screens/Networks.tsx delete mode 100644 client/ui/frontend/src/screens/Peers.tsx diff --git a/client/ui/frontend/src/i18n/locales/de/common.json b/client/ui/frontend/src/i18n/locales/de/common.json index 4300ca22c..50a50c033 100644 --- a/client/ui/frontend/src/i18n/locales/de/common.json +++ b/client/ui/frontend/src/i18n/locales/de/common.json @@ -69,8 +69,8 @@ "nav.peers.description": "{online} von {total} online", "nav.resources.title": "Ressourcen", "nav.resources.description": "{active} von {total} aktiv", - "nav.exitNode.title": "Exit-Node {location}", - "nav.exitNode.flagAlt": "{country}", + "nav.exitNode.title": "Exit-Node", + "nav.exitNode.none": "{total} verfügbar", "header.openSettings": "Einstellungen öffnen", "header.togglePanel": "Seitenleiste umschalten", @@ -297,6 +297,19 @@ "peers.filter.offline": "Offline", "peers.empty": "Keine Peers entsprechen den aktuellen Filtern.", + "networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen", + "networks.filter.all": "Alle", + "networks.filter.selected": "Ausgewählt", + "networks.filter.overlapping": "Überlappend", + "networks.empty": "Keine Netzwerke entsprechen den aktuellen Filtern.", + "networks.selected": "Ausgewählt", + "networks.unselected": "Nicht ausgewählt", + + "exitNodes.search.placeholder": "Exit Nodes suchen", + "exitNodes.empty": "Keine Exit Nodes verfügbar.", + "exitNodes.active": "Aktiv", + "exitNodes.inactive": "Inaktiv", + "quickActions.connect": "Verbinden", "quickActions.disconnect": "Trennen", diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json index b798bcbdf..759d0709e 100644 --- a/client/ui/frontend/src/i18n/locales/en/common.json +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -69,8 +69,8 @@ "nav.peers.description": "{online} of {total} Online", "nav.resources.title": "Resources", "nav.resources.description": "{active} of {total} Active", - "nav.exitNode.title": "Exit Node {location}", - "nav.exitNode.flagAlt": "{country}", + "nav.exitNode.title": "Exit Node", + "nav.exitNode.none": "{total} available", "header.openSettings": "Open settings", "header.togglePanel": "Toggle side panel", @@ -313,6 +313,19 @@ "peers.filter.offline": "Offline", "peers.empty": "No peers match the current filters.", + "networks.search.placeholder": "Search by network, range or domain", + "networks.filter.all": "All", + "networks.filter.selected": "Selected", + "networks.filter.overlapping": "Overlapping", + "networks.empty": "No networks match the current filters.", + "networks.selected": "Selected", + "networks.unselected": "Not selected", + + "exitNodes.search.placeholder": "Search exit nodes", + "exitNodes.empty": "No exit nodes available.", + "exitNodes.active": "Active", + "exitNodes.inactive": "Inactive", + "quickActions.connect": "Connect", "quickActions.disconnect": "Disconnect", diff --git a/client/ui/frontend/src/i18n/locales/hu/common.json b/client/ui/frontend/src/i18n/locales/hu/common.json index 1166894d4..71fa001b5 100644 --- a/client/ui/frontend/src/i18n/locales/hu/common.json +++ b/client/ui/frontend/src/i18n/locales/hu/common.json @@ -69,8 +69,8 @@ "nav.peers.description": "{online} / {total} online", "nav.resources.title": "Erőforrások", "nav.resources.description": "{active} / {total} aktív", - "nav.exitNode.title": "Kilépő csomópont: {location}", - "nav.exitNode.flagAlt": "{country}", + "nav.exitNode.title": "Kilépő csomópont", + "nav.exitNode.none": "{total} elérhető", "header.openSettings": "Beállítások megnyitása", "header.togglePanel": "Oldalsó panel váltása", @@ -297,6 +297,19 @@ "peers.filter.offline": "Offline", "peers.empty": "Egyetlen társ sem felel meg a jelenlegi szűrőknek.", + "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.overlapping": "Átfedő", + "networks.empty": "Egyetlen hálózat sem felel meg a jelenlegi szűrőknek.", + "networks.selected": "Kiválasztva", + "networks.unselected": "Nincs kiválasztva", + + "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", + "quickActions.connect": "Csatlakozás", "quickActions.disconnect": "Bontás", diff --git a/client/ui/frontend/src/layouts/Main.tsx b/client/ui/frontend/src/layouts/Main.tsx index 3c507770e..1455abf30 100644 --- a/client/ui/frontend/src/layouts/Main.tsx +++ b/client/ui/frontend/src/layouts/Main.tsx @@ -1,28 +1,47 @@ +import { useState } from "react"; import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx"; import { MainRightSide } from "@/layouts/MainRightSide.tsx"; +import { Navigation, NavSection } from "@/layouts/Navigation.tsx"; 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"; export const Main = () => { const { viewMode } = useViewMode(); const isAdvanced = viewMode === "advanced"; + const [section, setSection] = useState("peers"); return ( -
- {/* Fixed-width column for the connection switch — same in both - default and advanced view so the NetBird logo doesn't shift - horizontally when the window grows. In default view the - inner row is 348px so the column fills it; in advanced view - the column sits on the left with Peers in the remaining - space. */} -
- + +
+ {/* 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" && } + + )}
- {isAdvanced && ( - - - - )} -
+ ); }; diff --git a/client/ui/frontend/src/layouts/Navigation.tsx b/client/ui/frontend/src/layouts/Navigation.tsx index 3f1622ab2..9eaf03367 100644 --- a/client/ui/frontend/src/layouts/Navigation.tsx +++ b/client/ui/frontend/src/layouts/Navigation.tsx @@ -1,42 +1,74 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { Layers3Icon, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react"; import { CardNavItem } from "@/components/CardNavItem.tsx"; -import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react"; -import deFlag from "@/assets/flags/1x1/de.svg"; +import { cn } from "@/lib/cn"; +import { useStatus } from "@/modules/daemon-status/StatusContext"; +import { useNetworks } from "@/modules/networks/NetworksContext"; + +export type NavSection = "peers" | "networks" | "exitNode"; type Props = { - peersActive?: boolean; - onPeersClick?: () => void; + active: NavSection; + onSelect: (section: NavSection) => void; }; -export const Navigation = ({ peersActive = false, onPeersClick }: Props) => { +export const Navigation = ({ active, onSelect }: Props) => { const { t } = useTranslation(); + const { status } = useStatus(); + const { networkRoutes, exitNodes, activeExitNode } = useNetworks(); + + 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 }); + return ( ); diff --git a/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx new file mode 100644 index 000000000..476e33e12 --- /dev/null +++ b/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { SearchInput } from "@/components/SearchInput"; +import { useNetworks } from "@/modules/networks/NetworksContext"; +import { ExitNodesList } from "./ExitNodesList"; + +export const ExitNodes = () => { + const { t } = useTranslation(); + const { exitNodes, toggleExitNode } = useNetworks(); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + 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 matches.sort((a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }); + }, [exitNodes, search]); + + return ( +
+
+ setSearch(e.target.value)} + /> +
+ + + + + + + + +
+ ); +}; diff --git a/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx b/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx new file mode 100644 index 000000000..2b8820b91 --- /dev/null +++ b/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import type { Network } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; + +const dotClass = (selected: boolean): string => + selected ? "bg-green-400" : "bg-nb-gray-500"; + +type Props = { + data: Network[]; + onToggle: (id: string, selected: boolean) => void; +}; + +export const ExitNodesList = ({ data, onToggle }: Props) => { + const { t } = useTranslation(); + if (data.length === 0) { + return ( +
+ {t("exitNodes.empty")} +
+ ); + } + + return ( +
    + {data.map((n) => ( +
  • +
  • + ))} +
+ ); +}; diff --git a/client/ui/frontend/src/modules/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/networks/NetworkFilters.tsx new file mode 100644 index 000000000..c450f33c7 --- /dev/null +++ b/client/ui/frontend/src/modules/networks/NetworkFilters.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { SwitchItem } from "@/components/SwitchItem"; +import { SwitchItemGroup } from "@/components/SwitchItemGroup"; + +export type NetworkFilter = "all" | "selected" | "overlapping"; + +type Props = { + value: NetworkFilter; + onChange: (value: NetworkFilter) => void; + counts: Record; +}; + +export const NetworkFilters = ({ value, onChange, counts }: 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: "overlapping", label: t("networks.filter.overlapping") }, + ]; + + return ( + onChange(v as NetworkFilter)} + className={"w-full"} + > + {filters.map((f) => ( + + {f.label} + ({counts[f.value]}) + + ))} + + ); +}; diff --git a/client/ui/frontend/src/modules/networks/Networks.tsx b/client/ui/frontend/src/modules/networks/Networks.tsx new file mode 100644 index 000000000..f2aa55783 --- /dev/null +++ b/client/ui/frontend/src/modules/networks/Networks.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { SearchInput } from "@/components/SearchInput"; +import { NetworkFilters, NetworkFilter } from "./NetworkFilters"; +import { NetworksList } from "./NetworksList"; +import { useNetworks } from "./NetworksContext"; + +const collectOverlapping = (routes: { id: string; range: string; domains: string[] }[]): Set => { + const byRange = new Map(); + for (const r of routes) { + if (r.domains.length > 0) continue; + const arr = byRange.get(r.range) ?? []; + arr.push(r.id); + byRange.set(r.range, arr); + } + const out = new Set(); + for (const ids of byRange.values()) { + if (ids.length > 1) ids.forEach((id) => out.add(id)); + } + return out; +}; + +export const Networks = () => { + const { t } = useTranslation(); + const { networkRoutes, toggleNetwork } = useNetworks(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const overlappingIds = useMemo( + () => collectOverlapping(networkRoutes), + [networkRoutes], + ); + + const counts = useMemo>( + () => ({ + all: networkRoutes.length, + selected: networkRoutes.filter((r) => r.selected).length, + overlapping: overlappingIds.size, + }), + [networkRoutes, overlappingIds], + ); + + 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 (q) { + const haystack = [r.id, r.range, ...r.domains] + .join(" ") + .toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + return matches.sort((a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }); + }, [networkRoutes, search, filter, overlappingIds]); + + return ( +
+
+ setSearch(e.target.value)} + /> + +
+ + + + + + + + +
+ ); +}; diff --git a/client/ui/frontend/src/modules/networks/NetworksContext.tsx b/client/ui/frontend/src/modules/networks/NetworksContext.tsx new file mode 100644 index 000000000..14768c1fb --- /dev/null +++ b/client/ui/frontend/src/modules/networks/NetworksContext.tsx @@ -0,0 +1,126 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Networks as NetworksSvc } from "@bindings/services"; +import type { Network } from "@bindings/services/models.js"; + +// A range is treated as an exit-node candidate when any of its CIDRs is a +// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single +// comma-joined range string for one peer. +export const isDefaultRoute = (range: string): boolean => + range.split(",").some((part) => { + const trimmed = part.trim(); + return trimmed === "0.0.0.0/0" || trimmed === "::/0"; + }); + +type NetworksContextValue = { + routes: Network[]; + networkRoutes: Network[]; + exitNodes: Network[]; + activeExitNode: Network | null; + refresh: () => Promise; + toggleNetwork: (id: string, selected: boolean) => Promise; + toggleExitNode: (id: string, selected: boolean) => Promise; +}; + +const NetworksContext = createContext(null); + +export const useNetworks = () => { + const ctx = useContext(NetworksContext); + if (!ctx) { + throw new Error("useNetworks must be used inside NetworksProvider"); + } + return ctx; +}; + +export const NetworksProvider = ({ children }: { children: ReactNode }) => { + const [routes, setRoutes] = useState([]); + + const refresh = useCallback(async () => { + try { + const list = await NetworksSvc.List(); + setRoutes(list); + } catch (e) { + console.error(e); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const toggleNetwork = useCallback( + async (id: string, selected: boolean) => { + try { + if (selected) { + await NetworksSvc.Deselect({ + networkIds: [id], + append: false, + all: false, + }); + } else { + await NetworksSvc.Select({ + networkIds: [id], + append: true, + all: false, + }); + } + await refresh(); + } catch (e) { + console.error(e); + } + }, + [refresh], + ); + + // Exit nodes are mutually exclusive — Select with append=false clears any + // other selection before activating the new one. + const toggleExitNode = useCallback( + async (id: string, selected: boolean) => { + try { + if (selected) { + await NetworksSvc.Deselect({ + networkIds: [id], + append: false, + all: false, + }); + } else { + await NetworksSvc.Select({ + networkIds: [id], + append: false, + all: false, + }); + } + await refresh(); + } catch (e) { + console.error(e); + } + }, + [refresh], + ); + + const value = useMemo(() => { + const networkRoutes = routes.filter((r) => !isDefaultRoute(r.range)); + const exitNodes = routes.filter((r) => isDefaultRoute(r.range)); + const activeExitNode = exitNodes.find((r) => r.selected) ?? null; + return { + routes, + networkRoutes, + exitNodes, + activeExitNode, + refresh, + toggleNetwork, + toggleExitNode, + }; + }, [routes, refresh, toggleNetwork, toggleExitNode]); + + return ( + {children} + ); +}; diff --git a/client/ui/frontend/src/modules/networks/NetworksList.tsx b/client/ui/frontend/src/modules/networks/NetworksList.tsx new file mode 100644 index 000000000..644205372 --- /dev/null +++ b/client/ui/frontend/src/modules/networks/NetworksList.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import type { Network } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; + +const dotClass = (selected: boolean): string => + selected ? "bg-green-400" : "bg-nb-gray-500"; + +type Props = { + data: Network[]; + onToggle: (id: string, selected: boolean) => void; +}; + +export const NetworksList = ({ data, onToggle }: Props) => { + const { t } = useTranslation(); + if (data.length === 0) { + return ( +
+ {t("networks.empty")} +
+ ); + } + + return ( +
    + {data.map((n) => ( +
  • +
  • + ))} +
+ ); +}; diff --git a/client/ui/frontend/src/modules/peers/peers.md b/client/ui/frontend/src/modules/peers/peers.md new file mode 100644 index 000000000..ce8c34765 --- /dev/null +++ b/client/ui/frontend/src/modules/peers/peers.md @@ -0,0 +1,48 @@ +# Peers — info missing in PeersList.tsx + +`PeersList.tsx` currently shows only: `connStatus` (dot), `fqdn`, `ip`. + +`screens/Peers.tsx` additionally surfaces the following fields from `PeerStatus`: + +## Row chrome (collapsed) +- `peer.relayed` — Network (relayed, yellow) vs Zap (P2P, green) icon, gated on `connStatus === "Connected"`. +- `peer.rosenpassEnabled` — ShieldCheck icon when true. +- `peer.latencyMs` — `"{n} ms"` on the right when Connected and > 0. + +## Top-level controls +- Filter input — matches against `fqdn`, `ip`, and each entry in `networks`. +- Peer count — `status.peers.length` next to the title. +- Expand/collapse per row (chevron). + +## Expanded details panel +- `peer.pubKey` — Public key (mono). +- `peer.lastHandshakeUnix` — Last handshake (relative time). +- `peer.connStatusUpdateUnix` — Status since (relative time). +- `peer.bytesRx` / `peer.bytesTx` — formatted B/KB/MB/GB. +- `peer.localIceCandidateType` + `peer.localIceCandidateEndpoint` — Local candidate. +- `peer.remoteIceCandidateType` + `peer.remoteIceCandidateEndpoint` — Remote candidate. +- `peer.relayAddress` — shown only when `peer.relayed`. +- `peer.networks` — joined list, shown when non-empty. + +## `PeerStatus` interface (from `@bindings/services/models.js`) +```ts +interface PeerStatus { + ip: string; + pubKey: string; + connStatus: string; // "Connected" | "Connecting" | "Idle" | ... + connStatusUpdateUnix: number; + relayed: boolean; + localIceCandidateType: string; + remoteIceCandidateType: string; + localIceCandidateEndpoint: string; + remoteIceCandidateEndpoint: string; + fqdn: string; + bytesRx: number; + bytesTx: number; + latencyMs: number; + relayAddress: string; + lastHandshakeUnix: number; + rosenpassEnabled: boolean; + networks: string[]; +} +``` diff --git a/client/ui/frontend/src/screens/Networks.tsx b/client/ui/frontend/src/screens/Networks.tsx deleted file mode 100644 index 7e82d71a6..000000000 --- a/client/ui/frontend/src/screens/Networks.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { RefreshCw } from "lucide-react"; -import { Networks as NetworksSvc } from "@bindings/services"; -import type { Network } from "@bindings/services/models.js"; -import { Button } from "../components/Button"; -import { Tabs } from "../components/Tabs"; - -export default function Networks() { - const [routes, setRoutes] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const list = await NetworksSvc.List(); - setRoutes(list); - setError(null); - } catch (e) { - setError(String(e)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - - const toggle = async (id: string, selected: boolean) => { - try { - if (selected) { - await NetworksSvc.Deselect({ networkIds: [id], append: false, all: false }); - } else { - await NetworksSvc.Select({ networkIds: [id], append: true, all: false }); - } - await refresh(); - } catch (e) { - setError(String(e)); - } - }; - - const setAll = async (ids: string[], on: boolean) => { - try { - if (on) { - await NetworksSvc.Select({ networkIds: ids, append: false, all: true }); - } else { - await NetworksSvc.Deselect({ networkIds: ids, append: false, all: true }); - } - await refresh(); - } catch (e) { - setError(String(e)); - } - }; - - const overlapping = useMemo(() => filterOverlapping(routes), [routes]); - const exitNodes = useMemo( - () => routes.filter((r) => isDefaultRoute(r.range)), - [routes], - ); - - return ( -
-
-

Networks

- -
- - {error && ( -

{error}

- )} - -
- , - }, - { - value: "overlap", - label: `Overlapping (${overlapping.length})`, - content: , - }, - { - value: "exit", - label: `Exit-node (${exitNodes.length})`, - content: , - }, - ]} - /> -
-
- ); -} - -function NetworkList({ - routes, - onToggle, - onSetAll, -}: { - routes: Network[]; - onToggle: (id: string, selected: boolean) => void; - onSetAll: (ids: string[], on: boolean) => void; -}) { - if (routes.length === 0) { - return

No networks.

; - } - const ids = routes.map((r) => r.id); - return ( -
-
- - -
-
    - {routes.map((r) => ( -
  • - onToggle(r.id, r.selected)} - className="mt-1 h-4 w-4 accent-netbird" - /> -
    -

    {r.id}

    -

    {r.range}

    - {r.domains.length > 0 && ( -

    - {r.domains.join(", ")} -

    - )} -
    -
  • - ))} -
-
- ); -} - -// range is the merged display string from the daemon, e.g. "0.0.0.0/0", -// "::/0", or "0.0.0.0/0, ::/0" when a v4 exit node has a paired v6 entry. -function isDefaultRoute(range: string): boolean { - return range.split(",").some((part) => { - const trimmed = part.trim(); - return trimmed === "0.0.0.0/0" || trimmed === "::/0"; - }); -} - -function filterOverlapping(routes: Network[]): Network[] { - const byRange = new Map(); - for (const r of routes) { - if (r.domains.length > 0) continue; - const arr = byRange.get(r.range) ?? []; - arr.push(r); - byRange.set(r.range, arr); - } - const out: Network[] = []; - for (const arr of byRange.values()) { - if (arr.length > 1) out.push(...arr); - } - return out; -} diff --git a/client/ui/frontend/src/screens/Peers.tsx b/client/ui/frontend/src/screens/Peers.tsx deleted file mode 100644 index 919eeceb0..000000000 --- a/client/ui/frontend/src/screens/Peers.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useMemo, useState } from "react"; -import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react"; -import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; -import type { PeerStatus } from "@bindings/services/models.js"; -import { Card } from "../components/Card"; -import { Input } from "../components/Input"; -import { cn } from "../lib/cn"; - -export default function Peers() { - const { status } = useStatus(); - const [filter, setFilter] = useState(""); - const [expanded, setExpanded] = useState(null); - - const peers = useMemo(() => { - const all = status?.peers ?? []; - if (!filter.trim()) return all; - const q = filter.trim().toLowerCase(); - return all.filter( - (p) => - p.fqdn.toLowerCase().includes(q) || - p.ip.toLowerCase().includes(q) || - p.networks.some((n) => n.toLowerCase().includes(q)), - ); - }, [status?.peers, filter]); - - return ( -
-
-

- Peers - - {status?.peers?.length ?? 0} - -

-
- setFilter(e.target.value)} - /> -
-
- - {peers.length === 0 ? ( - - {status?.peers?.length === 0 - ? "No peers visible from this client." - : "No peers match the filter."} - - ) : ( -
    - {peers.map((p) => ( - setExpanded(expanded === p.pubKey ? null : p.pubKey)} - /> - ))} -
- )} -
- ); -} - -function PeerRow({ - peer, - expanded, - onToggle, -}: { - peer: PeerStatus; - expanded: boolean; - onToggle: () => void; -}) { - return ( -
  • - - - {expanded && } -
  • - ); -} - -function PeerDetails({ peer }: { peer: PeerStatus }) { - return ( -
    - - - - - - - {peer.relayed && ( - - )} - {peer.networks.length > 0 && ( - - )} -
    - ); -} - -function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
    - {label} - - {value} - -
    - ); -} - -function ChevronIcon({ expanded }: { expanded: boolean }) { - const Icon = expanded ? ChevronDown : ChevronRight; - return ; -} - -function StateBadge({ state }: { state: string }) { - const cls = "h-2 w-2 rounded-full shrink-0"; - switch (state) { - case "Connected": - return ; - case "Connecting": - return ; - case "Idle": - return ; - default: - return ; - } -} - -function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) { - if (!connected) { - return ; - } - if (relayed) { - return ( - - Relayed - - ); - } - return ( - - P2P - - ); -} - -function fmtBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; - return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; -} - -function fmtRelative(unixSec: number): string { - if (!unixSec) return "—"; - const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSec); - if (ageSec < 60) return `${ageSec}s ago`; - if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`; - if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`; - return `${Math.floor(ageSec / 86400)}d ago`; -}