add peers, networks and exit node list (wip)

This commit is contained in:
Eduard Gert
2026-05-26 12:09:08 +02:00
parent b6d20edfeb
commit 67a1f3c4fe
14 changed files with 630 additions and 420 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<NavSection>("peers");
return (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
{/* 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. */}
<div className={"flex flex-col items-center shrink-0 w-[348px]"}>
<ConnectionStatusSwitch />
<NetworksProvider>
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
{/* 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. */}
<div
className={
"relative flex flex-col items-center shrink-0 w-[348px]"
}
>
<ConnectionStatusSwitch />
{isAdvanced && (
<div className={"absolute inset-x-0 bottom-0 px-1"}>
<Navigation
active={section}
onSelect={setSection}
/>
</div>
)}
</div>
{isAdvanced && (
<MainRightSide>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</MainRightSide>
)}
</div>
{isAdvanced && (
<MainRightSide>
<Peers />
</MainRightSide>
)}
</div>
</NetworksProvider>
);
};

View File

@@ -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 (
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
<CardNavItem
icon={MonitorSmartphoneIcon}
title={t("nav.peers.title")}
description={t("nav.peers.description", { online: 17, total: 25 })}
active={peersActive}
onClick={onPeersClick}
description={t("nav.peers.description", peerCounts)}
active={active === "peers"}
onClick={() => onSelect("peers")}
/>
<CardNavItem
icon={Layers3Icon}
title={t("nav.resources.title")}
description={t("nav.resources.description", { active: 13, total: 16 })}
description={t("nav.resources.description", networkCounts)}
iconSize={14}
active={active === "networks"}
onClick={() => onSelect("networks")}
/>
<CardNavItem
iconNode={
<img
src={deFlag}
alt={t("nav.exitNode.flagAlt", { country: "Germany" })}
className={
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
}
<SquareArrowUpRight
size={14}
className={cn(
"transition-colors duration-150 rotate-45",
active === "exitNode"
? "text-nb-gray-200"
: "text-nb-gray-400",
)}
/>
}
title={t("nav.exitNode.title", { location: "Berlin" })}
description={"100.92.14.37"}
title={t("nav.exitNode.title")}
description={exitNodeDescription}
active={active === "exitNode"}
onClick={() => onSelect("exitNode")}
/>
</nav>
);

View File

@@ -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<HTMLInputElement>(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 (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-6"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};

View File

@@ -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 (
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
{t("exitNodes.empty")}
</div>
);
}
return (
<ul className={"flex flex-col"}>
{data.map((n) => (
<li
key={n.id}
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
>
<button
type={"button"}
onClick={() => 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")
}
/>
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
{n.id}
</span>
</CopyToClipboard>
<CopyToClipboard
message={n.range}
className={cn("ml-auto shrink-0", "relative left-2.5")}
>
<span className={"text-xs font-mono text-nb-gray-400"}>
{n.range}
</span>
</CopyToClipboard>
</li>
))}
</ul>
);
};

View File

@@ -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<NetworkFilter, number>;
};
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 (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as NetworkFilter)}
className={"w-full"}
>
{filters.map((f) => (
<SwitchItem key={f.value} value={f.value} className={"flex-1"}>
{f.label}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</SwitchItem>
))}
</SwitchItemGroup>
);
};

View File

@@ -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<string> => {
const byRange = new Map<string, string[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r.id);
byRange.set(r.range, arr);
}
const out = new Set<string>();
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<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const overlappingIds = useMemo(
() => collectOverlapping(networkRoutes),
[networkRoutes],
);
const counts = useMemo<Record<NetworkFilter, number>>(
() => ({
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 (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-6"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<NetworkFilters
value={filter}
onChange={setFilter}
counts={counts}
/>
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<NetworksList data={filtered} onToggle={toggleNetwork} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};

View File

@@ -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<void>;
toggleNetwork: (id: string, selected: boolean) => Promise<void>;
toggleExitNode: (id: string, selected: boolean) => Promise<void>;
};
const NetworksContext = createContext<NetworksContextValue | null>(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<Network[]>([]);
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<NetworksContextValue>(() => {
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 (
<NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>
);
};

View File

@@ -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 (
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
{t("networks.empty")}
</div>
);
}
return (
<ul className={"flex flex-col"}>
{data.map((n) => (
<li
key={n.id}
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
>
<button
type={"button"}
onClick={() => 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")}
/>
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
{n.id}
</span>
</CopyToClipboard>
<CopyToClipboard
message={n.range}
className={cn("ml-auto shrink-0", "relative left-2.5")}
>
<span className={"text-xs font-mono text-nb-gray-400"}>
{n.range}
</span>
</CopyToClipboard>
</li>
))}
</ul>
);
};

View File

@@ -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[];
}
```

View File

@@ -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<Network[]>([]);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">Networks</h1>
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} strokeWidth={1.5} />
Refresh
</Button>
</div>
{error && (
<p className="mb-2 text-sm text-red-500">{error}</p>
)}
<div className="flex-1 overflow-hidden">
<Tabs
tabs={[
{
value: "all",
label: `All (${routes.length})`,
content: <NetworkList routes={routes} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "overlap",
label: `Overlapping (${overlapping.length})`,
content: <NetworkList routes={overlapping} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "exit",
label: `Exit-node (${exitNodes.length})`,
content: <NetworkList routes={exitNodes} onToggle={toggle} onSetAll={setAll} />,
},
]}
/>
</div>
</div>
);
}
function NetworkList({
routes,
onToggle,
onSetAll,
}: {
routes: Network[];
onToggle: (id: string, selected: boolean) => void;
onSetAll: (ids: string[], on: boolean) => void;
}) {
if (routes.length === 0) {
return <p className="p-4 text-sm text-nb-gray-500">No networks.</p>;
}
const ids = routes.map((r) => r.id);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-2 border-b border-nb-gray-200 px-4 py-2 dark:border-nb-gray-800">
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, true)}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, false)}>
Deselect all
</Button>
</div>
<ul className="flex-1 overflow-auto divide-y divide-nb-gray-200 dark:divide-nb-gray-800">
{routes.map((r) => (
<li key={r.id} className="flex items-start gap-3 px-4 py-3">
<input
type="checkbox"
checked={r.selected}
onChange={() => onToggle(r.id, r.selected)}
className="mt-1 h-4 w-4 accent-netbird"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{r.id}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{r.range}</p>
{r.domains.length > 0 && (
<p className="mt-0.5 truncate text-xs text-nb-gray-500">
{r.domains.join(", ")}
</p>
)}
</div>
</li>
))}
</ul>
</div>
);
}
// 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<string, Network[]>();
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;
}

View File

@@ -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<string | null>(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 (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">
Peers
<span className="ml-2 text-sm font-normal text-nb-gray-500">
{status?.peers?.length ?? 0}
</span>
</h1>
<div className="w-64">
<Input
placeholder="Filter by FQDN / IP / network"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
{peers.length === 0 ? (
<Card className="text-sm text-nb-gray-500">
{status?.peers?.length === 0
? "No peers visible from this client."
: "No peers match the filter."}
</Card>
) : (
<ul className="flex-1 divide-y divide-nb-gray-200 overflow-auto rounded-lg border border-nb-gray-200 dark:divide-nb-gray-800 dark:border-nb-gray-800">
{peers.map((p) => (
<PeerRow
key={p.pubKey}
peer={p}
expanded={expanded === p.pubKey}
onToggle={() => setExpanded(expanded === p.pubKey ? null : p.pubKey)}
/>
))}
</ul>
)}
</div>
);
}
function PeerRow({
peer,
expanded,
onToggle,
}: {
peer: PeerStatus;
expanded: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-nb-gray-50 dark:hover:bg-nb-gray-940"
>
<ChevronIcon expanded={expanded} />
<StateBadge state={peer.connStatus} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{peer.fqdn || "—"}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{peer.ip}</p>
</div>
<RouteIcon relayed={peer.relayed} connected={peer.connStatus === "Connected"} />
{peer.rosenpassEnabled && (
<ShieldCheck className="h-4 w-4 text-green-500" strokeWidth={1.5} />
)}
<span className="w-16 text-right text-xs text-nb-gray-500">
{peer.connStatus === "Connected" && peer.latencyMs > 0
? `${peer.latencyMs} ms`
: ""}
</span>
</button>
{expanded && <PeerDetails peer={peer} />}
</li>
);
}
function PeerDetails({ peer }: { peer: PeerStatus }) {
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-2 bg-nb-gray-50 px-12 py-3 text-xs dark:bg-nb-gray-940">
<Detail label="Public key" value={peer.pubKey} mono />
<Detail label="Last handshake" value={fmtRelative(peer.lastHandshakeUnix)} />
<Detail label="Status since" value={fmtRelative(peer.connStatusUpdateUnix)} />
<Detail
label="Bytes rx / tx"
value={`${fmtBytes(peer.bytesRx)} / ${fmtBytes(peer.bytesTx)}`}
/>
<Detail
label="Local candidate"
value={
peer.localIceCandidateType
? `${peer.localIceCandidateType} (${peer.localIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
<Detail
label="Remote candidate"
value={
peer.remoteIceCandidateType
? `${peer.remoteIceCandidateType} (${peer.remoteIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
{peer.relayed && (
<Detail label="Relay" value={peer.relayAddress || "—"} mono />
)}
{peer.networks.length > 0 && (
<Detail label="Networks" value={peer.networks.join(", ")} />
)}
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex min-w-0 gap-2">
<span className="shrink-0 text-nb-gray-500">{label}</span>
<span
className={cn(
"min-w-0 truncate text-nb-gray-700 dark:text-nb-gray-200",
mono && "font-mono",
)}
>
{value}
</span>
</div>
);
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
const Icon = expanded ? ChevronDown : ChevronRight;
return <Icon className="h-4 w-4 shrink-0 text-nb-gray-400" strokeWidth={1.5} />;
}
function StateBadge({ state }: { state: string }) {
const cls = "h-2 w-2 rounded-full shrink-0";
switch (state) {
case "Connected":
return <span className={cn(cls, "bg-green-500")} title="Connected" />;
case "Connecting":
return <span className={cn(cls, "bg-netbird animate-pulse")} title="Connecting" />;
case "Idle":
return <span className={cn(cls, "bg-yellow-500")} title="Idle" />;
default:
return <span className={cn(cls, "bg-nb-gray-400")} title={state || "Disconnected"} />;
}
}
function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) {
if (!connected) {
return <span className="w-4 shrink-0" />;
}
if (relayed) {
return (
<Network
className="h-4 w-4 text-yellow-600"
strokeWidth={1.5}
>
<title>Relayed</title>
</Network>
);
}
return (
<Zap className="h-4 w-4 text-green-600" strokeWidth={1.5}>
<title>P2P</title>
</Zap>
);
}
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`;
}