mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-30 12:39:54 +00:00
update resources tab
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user