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

View File

@@ -35,7 +35,7 @@ export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork } = useNetworks();
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
@@ -63,9 +63,31 @@ export const Networks = () => {
[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 q = search.trim().toLowerCase();
const matches = networkRoutes.filter((r) => {
return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
@@ -74,11 +96,7 @@ export const Networks = () => {
}
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, overlapById]);
}, [ordered, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) {
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 (
<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"}>
@@ -133,6 +170,33 @@ export const Networks = () => {
/>
</ScrollArea.Scrollbar>
</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>
);
};

View File

@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
@@ -28,6 +29,7 @@ type NetworksContextValue = {
refresh: () => Promise<void>;
toggleNetwork: (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);
@@ -43,13 +45,41 @@ export const useNetworks = () => {
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
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 () => {
try {
const list = await NetworksSvc.List();
setRoutes(list);
} catch (e) {
console.error(e);
console.error("[NetworksContext] refresh failed", e);
}
}, []);
@@ -63,71 +93,124 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
void refresh();
}, [refresh, networksRevision]);
const toggleNetwork = useCallback(
async (id: string, selected: boolean) => {
// When the server snapshot agrees with a pending optimistic value, the
// 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 {
if (selected) {
await NetworksSvc.Deselect({
networkIds: [id],
append: false,
all: false,
});
await NetworksSvc.Select({ networkIds: ids, append: true, all: false });
} else {
await NetworksSvc.Select({
networkIds: [id],
append: true,
all: false,
});
await NetworksSvc.Deselect({ networkIds: ids, append: false, 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();
} catch (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],
);
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 —
// 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(
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,
});
const target = !selected;
const updates: Array<[string, boolean]> = [[id, target]];
const rollback: Array<[string, boolean]> = [[id, selected]];
if (target) {
for (const r of routes) {
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
updates.push([r.id, false]);
rollback.push([r.id, true]);
}
}
await refresh();
} catch (e) {
console.error(e);
}
setPendingFor(updates);
await mutate([id], target, rollback).catch(() => {});
},
[refresh],
[mutate, setPendingFor, routes],
);
const value = useMemo<NetworksContextValue>(() => {
const networkRoutes = routes.filter((r) => !isDefaultRoute(r.range));
const exitNodes = routes.filter((r) => isDefaultRoute(r.range));
// Apply pending overrides on top of the server snapshot. The override
// 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;
return {
routes,
routes: effective,
networkRoutes,
exitNodes,
activeExitNode,
refresh,
toggleNetwork,
toggleExitNode,
setNetworksSelected,
};
}, [routes, refresh, toggleNetwork, toggleExitNode]);
}, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]);
return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>;
};

View File

@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
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 type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
@@ -37,10 +38,24 @@ const resourceTypeOf = (n: Network): ResourceType => {
return isHostCidr(primary) ? "host" : "subnet";
};
const ResourceIcon = ({ type }: { type: ResourceType }) => {
if (type === "host") return <WorkflowIcon size={15} />;
if (type === "domain") return <GlobeIcon size={15} />;
return <NetworkIcon size={15} />;
const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
if (type === "host") return WorkflowIcon;
if (type === "domain") return GlobeIcon;
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 = {
@@ -56,32 +71,42 @@ export const NetworksList = ({ data, onToggle }: Props) => {
{data.map((n) => (
<li
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
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
label={
n.selected
? t("networks.selected")
: t("networks.unselected")
}
/>
<span className={"shrink-0 text-nb-gray-400"}>
<ResourceIcon type={resourceTypeOf(n)} />
</span>
<div className={"min-w-0 flex-1 flex flex-col gap-0.5"}>
<CopyToClipboard message={n.id}>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100"
}
>
{n.id}
</span>
</CopyToClipboard>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={n.id}>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{n.id}
</span>
</CopyToClipboard>
</div>
<Subtitle network={n} />
</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>
))}
</ul>
@@ -97,15 +122,13 @@ const Subtitle = ({ network }: { network: Network }) => {
if (network.range && network.range !== INVALID_PREFIX) {
return (
<CopyToClipboard message={network.range}>
<span
className={
"text-xs font-mono text-nb-gray-400 truncate"
}
>
{network.range}
</span>
</CopyToClipboard>
<div>
<CopyToClipboard message={network.range}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{network.range}
</span>
</CopyToClipboard>
</div>
);
}
@@ -122,26 +145,25 @@ const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
const extra = ips.length - 1;
return (
<div className={"flex items-center gap-1.5 text-xs min-w-0"}>
<CopyToClipboard message={domain}>
<span className={"font-mono text-nb-gray-400 truncate"}>
{domain}
</span>
</CopyToClipboard>
<>
<div>
<CopyToClipboard message={domain}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{domain}
</span>
</CopyToClipboard>
</div>
{first && (
<>
<span className={"text-nb-gray-600"}>·</span>
<div className={"flex items-center gap-1.5 min-w-0"}>
<CopyToClipboard message={first}>
<span
className={"font-mono text-nb-gray-500 truncate"}
>
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
{first}
</span>
</CopyToClipboard>
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
</>
</div>
)}
</div>
</>
);
};
@@ -154,6 +176,7 @@ const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
<Popover.Trigger asChild>
<button
type={"button"}
onClick={(e) => e.stopPropagation()}
className={cn(
"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",
@@ -209,25 +232,31 @@ type ToggleProps = {
checked: boolean;
onChange: () => void;
label: string;
mixed?: boolean;
};
const NetworkToggle = ({ checked, onChange, label }: ToggleProps) => (
export const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<button
type={"button"}
role={"switch"}
aria-checked={checked}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
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",
checked ? "bg-netbird" : "bg-nb-gray-700",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
>
<span
className={cn(
"inline-block h-3 w-3 rounded-full bg-white transition-transform",
checked ? "translate-x-3.5" : "translate-x-0.5",
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed
? "translate-x-2.5"
: checked
? "translate-x-[1.125rem]"
: "translate-x-0.5",
)}
/>
</button>

View File

@@ -346,6 +346,9 @@
"networks.unselected": "Nicht ausgewählt",
"networks.ips.more": "+{count} weitere",
"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.none": "Keiner",

View File

@@ -362,6 +362,9 @@
"networks.unselected": "Not selected",
"networks.ips.more": "+{count} more",
"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.none": "None",

View File

@@ -346,6 +346,9 @@
"networks.unselected": "Nincs kiválasztva",
"networks.ips.more": "+{count} további",
"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.none": "Egyik sem",