mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 06:39:08 +02:00
add peers, networks and exit node list (wip)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user