mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-05 21:51:43 -04:00
Adjust peers and resources tabs
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -9,8 +9,15 @@ type Props = {
|
||||
align?: RTooltip.TooltipContentProps["align"];
|
||||
delayDuration?: number;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
interactive?: boolean;
|
||||
keepOpenOnClick?: boolean;
|
||||
// Overrides the default tooltip-content chrome (background, padding,
|
||||
// border, radius). Use when a richer body needs popover-style layout.
|
||||
contentClassName?: string;
|
||||
// Ms to wait after pointer-leave before closing. Lets the user cross
|
||||
// a gap between trigger and content without the tooltip snapping shut.
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -20,14 +27,35 @@ export const Tooltip = ({
|
||||
align = "center",
|
||||
delayDuration = 200,
|
||||
sideOffset = 6,
|
||||
alignOffset = 0,
|
||||
interactive = false,
|
||||
keepOpenOnClick = true,
|
||||
contentClassName,
|
||||
closeDelay = 0,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hoveringRef = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
if (closeDelay <= 0) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next && keepOpenOnClick && hoveringRef.current) return;
|
||||
if (next) cancelClose();
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
@@ -41,10 +69,11 @@ export const Tooltip = ({
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
hoveringRef.current = true;
|
||||
cancelClose();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoveringRef.current = false;
|
||||
setOpen(false);
|
||||
scheduleClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -54,15 +83,19 @@ export const Tooltip = ({
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
onPointerEnter={interactive ? cancelClose : undefined}
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={
|
||||
interactive ? undefined : (e) => e.preventDefault()
|
||||
}
|
||||
className={cn(
|
||||
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
"text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
|
||||
!interactive && "pointer-events-none",
|
||||
contentClassName ??
|
||||
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
|
||||
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
tooltipContent?: ReactNode;
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
|
||||
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
|
||||
// the "tooltip on hover even though everything fits" annoyance. The caller
|
||||
// supplies the wrapper styling (font, max-width, etc.) via className — this
|
||||
// component only owns the truncate + measure + tooltip behavior.
|
||||
export const TruncatedText = ({
|
||||
text,
|
||||
className,
|
||||
tooltipContent,
|
||||
delayDuration = 600,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [text]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { WaypointsIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -159,13 +160,14 @@ const Row = ({ value, label, first }: RowProps) => (
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<TruncatedText
|
||||
text={label}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
|
||||
@@ -39,23 +39,17 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
@@ -76,11 +77,7 @@ export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const {
|
||||
networkRoutes: realNetworkRoutes,
|
||||
toggleNetwork,
|
||||
setNetworksSelected,
|
||||
} = useNetworks();
|
||||
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
@@ -164,9 +161,7 @@ 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 bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
@@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
key={n.id}
|
||||
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",
|
||||
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
@@ -271,29 +266,21 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<span
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{n.id}
|
||||
</span>
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
<div
|
||||
className={"shrink-0 self-center"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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")
|
||||
}
|
||||
label={n.selected ? t("networks.selected") : t("networks.unselected")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={network.range}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{network.range}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
className={
|
||||
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
@@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
|
||||
const first = ips[0];
|
||||
const extra = ips.length - 1;
|
||||
|
||||
const span = (
|
||||
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{domain}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{first && (
|
||||
<div className={"flex items-center gap-1.5 min-w-0"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
|
||||
{first}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
{ips.length > 0 ? (
|
||||
<Tooltip
|
||||
content={<ResolvedIpsTooltip ips={ips} />}
|
||||
delayDuration={300}
|
||||
closeDelay={300}
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
alignOffset={-8}
|
||||
interactive
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
{span}
|
||||
</Tooltip>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
|
||||
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const extra = ips.length - 1;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<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",
|
||||
"wails-no-draggable cursor-pointer outline-none",
|
||||
)}
|
||||
>
|
||||
{t("networks.ips.more", { count: extra })}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
className={"px-1 py-0.5"}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<>
|
||||
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"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",
|
||||
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
@@ -17,6 +9,7 @@ import {
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
CableIcon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
GaugeIcon,
|
||||
HandshakeIcon,
|
||||
@@ -34,6 +27,7 @@ import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
@@ -249,6 +243,22 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
{peer.relayed && (
|
||||
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.latencyMs > 0 && (
|
||||
<Row icon={GaugeIcon} label={t("peers.details.latency")}>
|
||||
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}>
|
||||
@@ -282,6 +292,11 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
|
||||
{statusSince}
|
||||
</Row>
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<IceRow
|
||||
icon={MonitorIcon}
|
||||
baseLabel={t("peers.details.localIce")}
|
||||
@@ -294,27 +309,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
type={peer.remoteIceCandidateType}
|
||||
endpoint={peer.remoteIceCandidateEndpoint}
|
||||
/>
|
||||
{peer.relayed && (
|
||||
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
|
||||
{peer.pubKey ? (
|
||||
<CopyToClipboard
|
||||
@@ -370,67 +364,35 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// "{N} Resources" trigger that opens a hover popover listing each routed
|
||||
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs
|
||||
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover).
|
||||
// Row value: first network inline, plus a "+N more" hover pill opening a
|
||||
// popover with the full list. Mirrors the resolved-IPs pattern in
|
||||
// Networks.tsx so the Resources tab and the peer detail row look consistent.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => {
|
||||
const first = networks[0];
|
||||
const extra = networks.length - 1;
|
||||
return (
|
||||
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<TruncatedRowValue value={first} mono />
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Single "View {n}" badge with a chevron that opens a click popover listing
|
||||
// each routed resource on its own line with a click-to-copy entry. Avoids
|
||||
// the repetitive "first item + N more" pattern given the row already has a
|
||||
// "Resources" label and Layers icon.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => (
|
||||
<ResourcesPopover networks={networks} />
|
||||
);
|
||||
|
||||
const ResourcesMorePopover = ({
|
||||
networks,
|
||||
extra,
|
||||
}: {
|
||||
networks: string[];
|
||||
extra: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
// Close with a small delay so the mouse can cross the sideOffset gap
|
||||
// between trigger and content without the popover snapping shut.
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 300);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
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",
|
||||
"wails-no-draggable cursor-default outline-none",
|
||||
"shrink-0 inline-flex items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
"border border-nb-gray-900",
|
||||
"px-2 py-1 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
)}
|
||||
>
|
||||
{t("peers.details.more", { count: extra })}
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -438,13 +400,11 @@ const ResourcesMorePopover = ({
|
||||
side={"bottom"}
|
||||
align={"end"}
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"z-50 max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
"p-2 pr-4 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<ul className={"flex flex-col"}>
|
||||
@@ -453,7 +413,7 @@ const ResourcesMorePopover = ({
|
||||
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
@@ -468,37 +428,15 @@ const ResourcesMorePopover = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Truncates the value to one line with the row's available width; on hover,
|
||||
// shows the full string in a tooltip — but only when actually clipped. Same
|
||||
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown.
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [value]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={value} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
|
||||
<TruncatedText
|
||||
text={value}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable cursor-default",
|
||||
@@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[f.value]})
|
||||
</span>
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
)}
|
||||
<span className={"w-4 shrink-0 flex items-center justify-center"}>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { mockOr, mockPeers } from "@/lib/mock";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
|
||||
@@ -67,27 +68,55 @@ export const Peers = () => {
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. After
|
||||
// that, positions are sticky — a peer flipping Connected→Connecting→Idle
|
||||
// no longer jumps groups. Newly discovered peers append at the end
|
||||
// (sorted online-first / by-name among themselves). Mirrors the
|
||||
// networks-list and exit-nodes-list orderRef pattern.
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. Once
|
||||
// peers have settled, positions become sticky — a peer flipping
|
||||
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
|
||||
// peers append at the end (sorted online-first / by-name among
|
||||
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
|
||||
// pattern.
|
||||
//
|
||||
// Stay in live-sort mode until every peer has reached a stable state
|
||||
// (Connected or Idle). The daemon emits all peers as "Connecting" right
|
||||
// after Up, which collapses the online-first sort into pure
|
||||
// alphabetical — committing then would lock that incorrect order and
|
||||
// the list would stay alphabetical even after every peer becomes
|
||||
// Connected. Once nothing is Connecting we commit and go sticky.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const stickyRef = useRef(false);
|
||||
const ordered = useMemo(() => {
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = peers
|
||||
.filter((p) => !known.has(p.pubKey))
|
||||
.sort((a, b) => {
|
||||
const sortOnlineFirst = (list: PeerStatus[]) =>
|
||||
[...list].sort((a, b) => {
|
||||
const aOnline = isOnline(a.connStatus);
|
||||
const bOnline = isOnline(b.connStatus);
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
const aName = (a.fqdn || a.ip).toLowerCase();
|
||||
const bName = (b.fqdn || b.ip).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
})
|
||||
.map((p) => p.pubKey);
|
||||
});
|
||||
|
||||
// Reset on empty (Disconnect → reconnect) so the next session
|
||||
// re-sorts from scratch instead of replaying the stale orderRef.
|
||||
if (peers.length === 0) {
|
||||
orderRef.current = [];
|
||||
stickyRef.current = false;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!stickyRef.current) {
|
||||
const sorted = sortOnlineFirst(peers);
|
||||
if (peers.every((p) => p.connStatus !== "Connecting")) {
|
||||
orderRef.current = sorted.map((p) => p.pubKey);
|
||||
stickyRef.current = true;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
|
||||
(p) => p.pubKey,
|
||||
);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((k) => byKey.get(k)!);
|
||||
@@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={t(peerStatusLabelKey(peer.connStatus))}
|
||||
side={"left"}
|
||||
>
|
||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
@@ -190,7 +216,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<TruncatedName name={peer.fqdn} />
|
||||
<TruncatedText
|
||||
text={peer.fqdn}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
@@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a
|
||||
// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout,
|
||||
// and wrap in a Tooltip only when the text actually overflows. Avoids the
|
||||
// "tooltip on hover even though everything fits" annoyance.
|
||||
const TruncatedName = ({ name }: { name: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [name]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={name} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"peers.status.disconnected": "Getrennt",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Ressourcen",
|
||||
"peers.details.more": "+{count} weitere",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass aktiviert",
|
||||
@@ -368,7 +367,6 @@
|
||||
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
|
||||
"networks.selected": "Ausgewählt",
|
||||
"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",
|
||||
|
||||
@@ -371,7 +371,6 @@
|
||||
"peers.status.disconnected": "Disconnected",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Resources",
|
||||
"peers.details.more": "+{count} more",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass enabled",
|
||||
@@ -384,7 +383,6 @@
|
||||
"networks.empty.description": "No routed networks have been shared with this peer.",
|
||||
"networks.selected": "Selected",
|
||||
"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",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"peers.status.disconnected": "Lecsatlakozva",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Erőforrások",
|
||||
"peers.details.more": "+{count} további",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass engedélyezve",
|
||||
@@ -368,7 +367,6 @@
|
||||
"networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
|
||||
"networks.selected": "Kiválasztva",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user