Adjust peers and resources tabs

This commit is contained in:
Eduard Gert
2026-06-02 11:37:56 +02:00
parent 16743c4ce5
commit fefd0da7bf
11 changed files with 273 additions and 329 deletions

View File

@@ -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}

View 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>
);
};

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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"}>

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",