update resources tab

This commit is contained in:
Eduard Gert
2026-05-28 14:28:51 +02:00
parent 35498c572a
commit dccc0ebe4b
6 changed files with 283 additions and 98 deletions
@@ -35,7 +35,7 @@ export const Networks = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { status } = useStatus(); const { status } = useStatus();
const isConnected = status?.status === "Connected"; const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork } = useNetworks(); const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all"); const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -63,9 +63,31 @@ export const Networks = () => {
[networkRoutes, overlapById], [networkRoutes, overlapById],
); );
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it, and newly discovered routes append at
// the end (sorted active-first / by-id among themselves). The ref carries
// the previous order across renders so the reconciliation is synchronous
// with networkRoutes updates (no useEffect lag → no visual hop).
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = networkRoutes
.filter((r) => !known.has(r.id))
.sort((a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((r) => r.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [networkRoutes]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
const matches = networkRoutes.filter((r) => { return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false; if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false; if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) { if (q) {
@@ -74,11 +96,7 @@ export const Networks = () => {
} }
return true; return true;
}); });
return matches.sort((a, b) => { }, [ordered, search, filter, overlapById]);
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [networkRoutes, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) { if (isConnected && networkRoutes.length === 0) {
return ( return (
@@ -98,6 +116,25 @@ export const Networks = () => {
); );
} }
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
void setNetworksSelected(
filtered.map((r) => r.id),
false,
);
} else {
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
void setNetworksSelected(ids, true);
}
};
return ( return (
<div className={"flex flex-col w-full h-full min-h-0"}> <div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}> <div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
@@ -133,6 +170,33 @@ export const Networks = () => {
/> />
</ScrollArea.Scrollbar> </ScrollArea.Scrollbar>
</ScrollArea.Root> </ScrollArea.Root>
{filtered.length > 0 && (
<div
className={cn(
"flex items-center gap-3 px-6 py-3.5",
"border-t border-nb-gray-910",
)}
>
<span className={"flex-1 text-xs font-medium text-nb-gray-300 tabular-nums"}>
{t("networks.bulk.selectionCount", {
selected: selectedInView,
total: filtered.length,
})}
</span>
<button
type={"button"}
onClick={onBulkClick}
className={cn(
"inline-flex items-center h-8 px-3 rounded-md",
"text-xs font-medium text-nb-gray-100",
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
"transition-colors outline-none wails-no-draggable cursor-pointer",
)}
>
{bulkLabel}
</button>
</div>
)}
</div> </div>
); );
}; };
@@ -4,6 +4,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -28,6 +29,7 @@ type NetworksContextValue = {
refresh: () => Promise<void>; refresh: () => Promise<void>;
toggleNetwork: (id: string, selected: boolean) => Promise<void>; toggleNetwork: (id: string, selected: boolean) => Promise<void>;
toggleExitNode: (id: string, selected: boolean) => Promise<void>; toggleExitNode: (id: string, selected: boolean) => Promise<void>;
setNetworksSelected: (ids: string[], selected: boolean) => Promise<void>;
}; };
const NetworksContext = createContext<NetworksContextValue | null>(null); const NetworksContext = createContext<NetworksContextValue | null>(null);
@@ -43,13 +45,41 @@ export const useNetworks = () => {
export const NetworksProvider = ({ children }: { children: ReactNode }) => { export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus(); const { status } = useStatus();
const [routes, setRoutes] = useState<Network[]>([]); const [routes, setRoutes] = useState<Network[]>([]);
// Optimistic overrides: id → expected `selected` value. Applied on top of
// the server-side `routes` so toggles paint instantly. Entries are cleared
// either when the next server snapshot agrees (success path) or when the
// RPC throws (rollback). Linear-style optimistic mutation tracking.
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
// Mirror of `pending` for use inside async callbacks without re-binding
// them on every change.
const pendingRef = useRef(pending);
useEffect(() => {
pendingRef.current = pending;
}, [pending]);
const setPendingFor = useCallback((updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
}, []);
const clearPendingFor = useCallback((ids: string[]) => {
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
}, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const list = await NetworksSvc.List(); const list = await NetworksSvc.List();
setRoutes(list); setRoutes(list);
} catch (e) { } catch (e) {
console.error(e); console.error("[NetworksContext] refresh failed", e);
} }
}, []); }, []);
@@ -63,71 +93,124 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
void refresh(); void refresh();
}, [refresh, networksRevision]); }, [refresh, networksRevision]);
const toggleNetwork = useCallback( // When the server snapshot agrees with a pending optimistic value, the
async (id: string, selected: boolean) => { // mutation is confirmed — drop the override so the row tracks the server
// again. Runs whenever routes change.
useEffect(() => {
if (pendingRef.current.size === 0) return;
const confirmed: string[] = [];
for (const r of routes) {
const expected = pendingRef.current.get(r.id);
if (expected !== undefined && r.selected === expected) {
confirmed.push(r.id);
}
}
if (confirmed.length > 0) clearPendingFor(confirmed);
}, [routes, clearPendingFor]);
const mutate = useCallback(
async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => {
try { try {
if (selected) { if (selected) {
await NetworksSvc.Deselect({ await NetworksSvc.Select({ networkIds: ids, append: true, all: false });
networkIds: [id],
append: false,
all: false,
});
} else { } else {
await NetworksSvc.Select({ await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
networkIds: [id],
append: true,
all: false,
});
} }
// Don't clear pending here — let the revision-driven refresh
// confirm via the snapshot-match effect. That avoids a flash
// back to old state if the refresh races the RPC return.
await refresh(); await refresh();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
// Roll back to the last server-observed value for each id.
setPending((prev) => {
const next = new Map(prev);
for (const [id] of rollback) next.delete(id);
return next;
});
throw e;
} }
}, },
[refresh], [refresh],
); );
const toggleNetwork = useCallback(
async (id: string, selected: boolean) => {
const target = !selected;
setPendingFor([[id, target]]);
await mutate([id], target, [[id, selected]]).catch(() => {});
},
[mutate, setPendingFor],
);
// Batch toggle for the bottom-bar select-all switch. The daemon's
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
// per-ID calls — one round-trip + one refresh.
const setNetworksSelected = useCallback(
async (ids: string[], selected: boolean) => {
if (ids.length === 0) return;
const prevById = new Map(routes.map((r) => [r.id, r.selected]));
const rollback: Array<[string, boolean]> = ids.map((id) => [
id,
prevById.get(id) ?? !selected,
]);
setPendingFor(ids.map((id) => [id, selected]));
await mutate(ids, selected, rollback).catch(() => {});
},
[mutate, setPendingFor, routes],
);
// Exit nodes are mutually exclusive, but the daemon enforces that now — // Exit nodes are mutually exclusive, but the daemon enforces that now —
// selecting one deselects the other exit nodes. Append so activating an // selecting one deselects the other exit nodes. Append so activating an
// exit node doesn't wipe the user's network-route selections. // exit node doesn't wipe the user's network-route selections. We also
// mirror that mutual-exclusion locally so the optimistic paint matches
// the daemon's eventual state.
const toggleExitNode = useCallback( const toggleExitNode = useCallback(
async (id: string, selected: boolean) => { async (id: string, selected: boolean) => {
try { const target = !selected;
if (selected) { const updates: Array<[string, boolean]> = [[id, target]];
await NetworksSvc.Deselect({ const rollback: Array<[string, boolean]> = [[id, selected]];
networkIds: [id], if (target) {
append: false, for (const r of routes) {
all: false, if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
}); updates.push([r.id, false]);
} else { rollback.push([r.id, true]);
await NetworksSvc.Select({ }
networkIds: [id],
append: true,
all: false,
});
} }
await refresh();
} catch (e) {
console.error(e);
} }
setPendingFor(updates);
await mutate([id], target, rollback).catch(() => {});
}, },
[refresh], [mutate, setPendingFor, routes],
); );
const value = useMemo<NetworksContextValue>(() => { const value = useMemo<NetworksContextValue>(() => {
const networkRoutes = routes.filter((r) => !isDefaultRoute(r.range)); // Apply pending overrides on top of the server snapshot. The override
const exitNodes = routes.filter((r) => isDefaultRoute(r.range)); // map is usually empty or tiny (one entry per in-flight toggle), so
// the per-route lookup is effectively free.
const effective =
pending.size === 0
? routes
: routes.map((r) => {
const override = pending.get(r.id);
return override === undefined || override === r.selected
? r
: { ...r, selected: override };
});
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
const activeExitNode = exitNodes.find((r) => r.selected) ?? null; const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
return { return {
routes, routes: effective,
networkRoutes, networkRoutes,
exitNodes, exitNodes,
activeExitNode, activeExitNode,
refresh, refresh,
toggleNetwork, toggleNetwork,
toggleExitNode, toggleExitNode,
setNetworksSelected,
}; };
}, [routes, refresh, toggleNetwork, toggleExitNode]); }, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]);
return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>; return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>;
}; };
@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react"; import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Network } from "@bindings/services/models.js"; import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -37,10 +38,24 @@ const resourceTypeOf = (n: Network): ResourceType => {
return isHostCidr(primary) ? "host" : "subnet"; return isHostCidr(primary) ? "host" : "subnet";
}; };
const ResourceIcon = ({ type }: { type: ResourceType }) => { const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
if (type === "host") return <WorkflowIcon size={15} />; if (type === "host") return WorkflowIcon;
if (type === "domain") return <GlobeIcon size={15} />; if (type === "domain") return GlobeIcon;
return <NetworkIcon size={15} />; return NetworkIcon;
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
<div
className={cn(
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
)}
>
<Icon size={14} />
</div>
);
}; };
type Props = { type Props = {
@@ -56,32 +71,42 @@ export const NetworksList = ({ data, onToggle }: Props) => {
{data.map((n) => ( {data.map((n) => (
<li <li
key={n.id} key={n.id}
className={"flex items-center gap-3 px-7 py-3 min-w-0"} onClick={() => onToggle(n.id, n.selected)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}
> >
<NetworkToggle <ResourceIconBadge type={resourceTypeOf(n)} />
checked={n.selected} <div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
onChange={() => onToggle(n.id, n.selected)} <div>
label={ <CopyToClipboard message={n.id}>
n.selected <span
? t("networks.selected") className={
: t("networks.unselected") "text-[0.81rem] font-medium text-nb-gray-100 truncate"
} }
/> >
<span className={"shrink-0 text-nb-gray-400"}> {n.id}
<ResourceIcon type={resourceTypeOf(n)} /> </span>
</span> </CopyToClipboard>
<div className={"min-w-0 flex-1 flex flex-col gap-0.5"}> </div>
<CopyToClipboard message={n.id}>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100"
}
>
{n.id}
</span>
</CopyToClipboard>
<Subtitle network={n} /> <Subtitle network={n} />
</div> </div>
<div
className={"shrink-0 self-center"}
onClick={(e) => e.stopPropagation()}
>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
label={
n.selected
? t("networks.selected")
: t("networks.unselected")
}
/>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -97,15 +122,13 @@ const Subtitle = ({ network }: { network: Network }) => {
if (network.range && network.range !== INVALID_PREFIX) { if (network.range && network.range !== INVALID_PREFIX) {
return ( return (
<CopyToClipboard message={network.range}> <div>
<span <CopyToClipboard message={network.range}>
className={ <span className={"text-xs font-mono text-nb-gray-400 truncate"}>
"text-xs font-mono text-nb-gray-400 truncate" {network.range}
} </span>
> </CopyToClipboard>
{network.range} </div>
</span>
</CopyToClipboard>
); );
} }
@@ -122,26 +145,25 @@ const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
const extra = ips.length - 1; const extra = ips.length - 1;
return ( return (
<div className={"flex items-center gap-1.5 text-xs min-w-0"}> <>
<CopyToClipboard message={domain}> <div>
<span className={"font-mono text-nb-gray-400 truncate"}> <CopyToClipboard message={domain}>
{domain} <span className={"text-xs font-mono text-nb-gray-400 truncate"}>
</span> {domain}
</CopyToClipboard> </span>
</CopyToClipboard>
</div>
{first && ( {first && (
<> <div className={"flex items-center gap-1.5 min-w-0"}>
<span className={"text-nb-gray-600"}>·</span>
<CopyToClipboard message={first}> <CopyToClipboard message={first}>
<span <span className={"text-xs font-mono text-nb-gray-500 truncate"}>
className={"font-mono text-nb-gray-500 truncate"}
>
{first} {first}
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{extra > 0 && <ResolvedIpsPopover ips={ips} />} {extra > 0 && <ResolvedIpsPopover ips={ips} />}
</> </div>
)} )}
</div> </>
); );
}; };
@@ -154,6 +176,7 @@ const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type={"button"} type={"button"}
onClick={(e) => e.stopPropagation()}
className={cn( className={cn(
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850", "shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300", "px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
@@ -209,25 +232,31 @@ type ToggleProps = {
checked: boolean; checked: boolean;
onChange: () => void; onChange: () => void;
label: string; label: string;
mixed?: boolean;
}; };
const NetworkToggle = ({ checked, onChange, label }: ToggleProps) => ( export const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<button <button
type={"button"} type={"button"}
role={"switch"} role={"switch"}
aria-checked={checked} aria-checked={mixed ? "mixed" : checked}
aria-label={label} aria-label={label}
onClick={onChange} onClick={onChange}
className={cn( className={cn(
"shrink-0 inline-flex h-4 w-7 items-center rounded-full", "shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable", "transition-colors cursor-pointer wails-no-draggable",
checked ? "bg-netbird" : "bg-nb-gray-700", checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)} )}
> >
<span <span
className={cn( className={cn(
"inline-block h-3 w-3 rounded-full bg-white transition-transform", "inline-block h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-3.5" : "translate-x-0.5", mixed
? "translate-x-2.5"
: checked
? "translate-x-[1.125rem]"
: "translate-x-0.5",
)} )}
/> />
</button> </button>
+3
View File
@@ -346,6 +346,9 @@
"networks.unselected": "Nicht ausgewählt", "networks.unselected": "Nicht ausgewählt",
"networks.ips.more": "+{count} weitere", "networks.ips.more": "+{count} weitere",
"networks.ips.heading": "Aufgelöste IPs", "networks.ips.heading": "Aufgelöste IPs",
"networks.bulk.selectionCount": "{selected} von {total} aktiv",
"networks.bulk.enableAll": "Alle aktivieren",
"networks.bulk.disableAll": "Alle deaktivieren",
"exitNodes.search.placeholder": "Exit Nodes suchen", "exitNodes.search.placeholder": "Exit Nodes suchen",
"exitNodes.none": "Keiner", "exitNodes.none": "Keiner",
+3
View File
@@ -362,6 +362,9 @@
"networks.unselected": "Not selected", "networks.unselected": "Not selected",
"networks.ips.more": "+{count} more", "networks.ips.more": "+{count} more",
"networks.ips.heading": "Resolved IPs", "networks.ips.heading": "Resolved IPs",
"networks.bulk.selectionCount": "{selected} of {total} Active",
"networks.bulk.enableAll": "Enable all",
"networks.bulk.disableAll": "Disable all",
"exitNodes.search.placeholder": "Search exit nodes", "exitNodes.search.placeholder": "Search exit nodes",
"exitNodes.none": "None", "exitNodes.none": "None",
+3
View File
@@ -346,6 +346,9 @@
"networks.unselected": "Nincs kiválasztva", "networks.unselected": "Nincs kiválasztva",
"networks.ips.more": "+{count} további", "networks.ips.more": "+{count} további",
"networks.ips.heading": "Feloldott IP-címek", "networks.ips.heading": "Feloldott IP-címek",
"networks.bulk.selectionCount": "{selected} / {total} aktív",
"networks.bulk.enableAll": "Összes engedélyezése",
"networks.bulk.disableAll": "Összes letiltása",
"exitNodes.search.placeholder": "Keresés a kilépő csomópontok között", "exitNodes.search.placeholder": "Keresés a kilépő csomópontok között",
"exitNodes.none": "Egyik sem", "exitNodes.none": "Egyik sem",