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