mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-05 13:09:07 +02:00
update resources tab
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user