From dccc0ebe4bd5a3788d74fe279a02f747bfaedf52 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 28 May 2026 14:28:51 +0200 Subject: [PATCH] update resources tab --- .../src/modules/networks/Networks.tsx | 78 ++++++++- .../src/modules/networks/NetworksContext.tsx | 153 ++++++++++++++---- .../src/modules/networks/NetworksList.tsx | 141 +++++++++------- client/ui/i18n/locales/de/common.json | 3 + client/ui/i18n/locales/en/common.json | 3 + client/ui/i18n/locales/hu/common.json | 3 + 6 files changed, 283 insertions(+), 98 deletions(-) diff --git a/client/ui/frontend/src/modules/networks/Networks.tsx b/client/ui/frontend/src/modules/networks/Networks.tsx index 58311c7c1..54816baba 100644 --- a/client/ui/frontend/src/modules/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/networks/Networks.tsx @@ -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("all"); const searchRef = useRef(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([]); + 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 (
@@ -133,6 +170,33 @@ export const Networks = () => { /> + {filtered.length > 0 && ( +
+ + {t("networks.bulk.selectionCount", { + selected: selectedInView, + total: filtered.length, + })} + + +
+ )}
); }; diff --git a/client/ui/frontend/src/modules/networks/NetworksContext.tsx b/client/ui/frontend/src/modules/networks/NetworksContext.tsx index c29eee4d8..c60c6e0b4 100644 --- a/client/ui/frontend/src/modules/networks/NetworksContext.tsx +++ b/client/ui/frontend/src/modules/networks/NetworksContext.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -28,6 +29,7 @@ type NetworksContextValue = { refresh: () => Promise; toggleNetwork: (id: string, selected: boolean) => Promise; toggleExitNode: (id: string, selected: boolean) => Promise; + setNetworksSelected: (ids: string[], selected: boolean) => Promise; }; const NetworksContext = createContext(null); @@ -43,13 +45,41 @@ export const useNetworks = () => { export const NetworksProvider = ({ children }: { children: ReactNode }) => { const { status } = useStatus(); const [routes, setRoutes] = useState([]); + // 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>(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(() => { - 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 {children}; }; diff --git a/client/ui/frontend/src/modules/networks/NetworksList.tsx b/client/ui/frontend/src/modules/networks/NetworksList.tsx index a21950a20..e49afd34d 100644 --- a/client/ui/frontend/src/modules/networks/NetworksList.tsx +++ b/client/ui/frontend/src/modules/networks/NetworksList.tsx @@ -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 ; - if (type === "domain") return ; - return ; +const resourceIconFor = (type: ResourceType): ComponentType => { + if (type === "host") return WorkflowIcon; + if (type === "domain") return GlobeIcon; + return NetworkIcon; +}; + +const ResourceIconBadge = ({ type }: { type: ResourceType }) => { + const Icon = resourceIconFor(type); + return ( +
+ +
+ ); }; type Props = { @@ -56,32 +71,42 @@ export const NetworksList = ({ data, onToggle }: Props) => { {data.map((n) => (
  • 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", + )} > - onToggle(n.id, n.selected)} - label={ - n.selected - ? t("networks.selected") - : t("networks.unselected") - } - /> - - - -
    - - - {n.id} - - + +
    +
    + + + {n.id} + + +
    +
    e.stopPropagation()} + > + onToggle(n.id, n.selected)} + label={ + n.selected + ? t("networks.selected") + : t("networks.unselected") + } + /> +
  • ))} @@ -97,15 +122,13 @@ const Subtitle = ({ network }: { network: Network }) => { if (network.range && network.range !== INVALID_PREFIX) { return ( - - - {network.range} - - +
    + + + {network.range} + + +
    ); } @@ -122,26 +145,25 @@ const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => { const extra = ips.length - 1; return ( -
    - - - {domain} - - + <> +
    + + + {domain} + + +
    {first && ( - <> - · +
    - + {first} {extra > 0 && } - +
    )} -
    + ); }; @@ -154,6 +176,7 @@ const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => { diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 1f7ef9fb6..00b90804f 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -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", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 34815498b..75e19272d 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -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", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index c01736a22..ae2273048 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -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",