mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 07:39:56 +00:00
Brings two main-side PRs' UI behavior across the Fyne→Wails rewrite: - #5631 (IPv6 overlay support): add "Enable IPv6" row to the polished SettingsNetwork tab; the legacy screens/Settings.tsx already had it, but modules/settings/SettingsNetwork.tsx (the user-visible Settings window) was missing the toggle. - #6150 (mirror v4 exit selection onto v6 pair): replace the literal "0.0.0.0/0" || "::/0" filter in screens/Networks.tsx with an isDefaultRoute() helper that handles the daemon's merged-range display string (e.g. "0.0.0.0/0, ::/0"), so paired v4/v6 exit nodes are classified correctly.
172 lines
5.2 KiB
TypeScript
172 lines
5.2 KiB
TypeScript
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;
|
|
}
|