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
+37 -4
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 * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -9,8 +9,15 @@ type Props = {
align?: RTooltip.TooltipContentProps["align"]; align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number; delayDuration?: number;
sideOffset?: number; sideOffset?: number;
alignOffset?: number;
interactive?: boolean; interactive?: boolean;
keepOpenOnClick?: 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 = ({ export const Tooltip = ({
@@ -20,14 +27,35 @@ export const Tooltip = ({
align = "center", align = "center",
delayDuration = 200, delayDuration = 200,
sideOffset = 6, sideOffset = 6,
alignOffset = 0,
interactive = false, interactive = false,
keepOpenOnClick = true, keepOpenOnClick = true,
contentClassName,
closeDelay = 0,
}: Props) => { }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const hoveringRef = useRef(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) => { const handleOpenChange = (next: boolean) => {
if (!next && keepOpenOnClick && hoveringRef.current) return; if (!next && keepOpenOnClick && hoveringRef.current) return;
if (next) cancelClose();
setOpen(next); setOpen(next);
}; };
@@ -41,10 +69,11 @@ export const Tooltip = ({
asChild asChild
onPointerEnter={() => { onPointerEnter={() => {
hoveringRef.current = true; hoveringRef.current = true;
cancelClose();
}} }}
onPointerLeave={() => { onPointerLeave={() => {
hoveringRef.current = false; hoveringRef.current = false;
setOpen(false); scheduleClose();
}} }}
> >
{children} {children}
@@ -54,15 +83,19 @@ export const Tooltip = ({
side={side} side={side}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={ onPointerDownOutside={
interactive ? undefined : (e) => e.preventDefault() interactive ? undefined : (e) => e.preventDefault()
} }
className={cn( className={cn(
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", "z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
!interactive && "pointer-events-none", !interactive && "pointer-events-none",
contentClassName ??
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
)} )}
> >
{content} {content}
@@ -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 type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/inputs/SearchInput"; import { SearchInput } from "@/components/inputs/SearchInput";
import { TruncatedText } from "@/components/TruncatedText";
import { EmptyState } from "@/components/empty-state/EmptyState"; import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults"; import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext"; import { useStatus } from "@/contexts/StatusContext";
@@ -159,13 +160,14 @@ const Row = ({ value, label, first }: RowProps) => (
"wails-no-draggable cursor-pointer outline-none text-left", "wails-no-draggable cursor-pointer outline-none text-left",
)} )}
> >
<span <div className={"min-w-0 flex-1"}>
className={ <TruncatedText
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate" text={label}
} className={
> "block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-full"
{label} }
</span> />
</div>
<span <span
className={cn( className={cn(
"h-4 w-4 shrink-0 rounded-full border", "h-4 w-4 shrink-0 rounded-full border",
@@ -39,23 +39,17 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md", "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", "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", "disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable", "wails-no-draggable cursor-default",
)} )}
> >
<ListFilter size={14} className={"shrink-0"} /> <ListFilter size={14} className={"shrink-0"} />
<span> <span>
{active.label}{" "} {active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span> </span>
<ChevronDown <ChevronDown size={14} className={"ml-0.5 shrink-0"} />
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}> <DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => { {filters.map((f) => {
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
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";
import { CopyToClipboard } from "@/components/CopyToClipboard"; import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { SearchInput } from "@/components/inputs/SearchInput"; import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState"; import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults"; import { NoResults } from "@/components/empty-state/NoResults";
@@ -76,11 +77,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 { const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
networkRoutes: realNetworkRoutes,
toggleNetwork,
setNetworksSelected,
} = useNetworks();
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes); const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all"); const [filter, setFilter] = useState<NetworkFilter>("all");
@@ -164,9 +161,7 @@ export const Networks = () => {
const selectedInView = filtered.filter((r) => r.selected).length; const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length; const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const onBulkClick = () => { const onBulkClick = () => {
if (filtered.length === 0) return; if (filtered.length === 0) return;
@@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
key={n.id} key={n.id}
onClick={() => onToggle(n.id, n.selected)} onClick={() => onToggle(n.id, n.selected)}
className={cn( 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", "hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer", "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 className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div> <div>
<CopyToClipboard message={n.id}> <CopyToClipboard message={n.id}>
<span <TruncatedText
text={n.id}
className={ 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> </CopyToClipboard>
</div> </div>
<Subtitle network={n} /> <Subtitle network={n} />
</div> </div>
<div <div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
className={"shrink-0 self-center"}
onClick={(e) => e.stopPropagation()}
>
<NetworkToggle <NetworkToggle
checked={n.selected} checked={n.selected}
onChange={() => onToggle(n.id, n.selected)} onChange={() => onToggle(n.id, n.selected)}
label={ label={n.selected ? t("networks.selected") : t("networks.unselected")}
n.selected
? t("networks.selected")
: t("networks.unselected")
}
/> />
</div> </div>
</li> </li>
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
return ( return (
<div <div
className={cn( 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", "bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
)} )}
> >
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => {
return ( return (
<div> <div>
<CopyToClipboard message={network.range}> <CopyToClipboard message={network.range}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}> <TruncatedText
{network.range} text={network.range}
</span> className={
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
}
/>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
); );
@@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => {
}; };
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => { const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
const first = ips[0]; const span = (
const extra = ips.length - 1; <span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
{domain}
</span>
);
return ( return (
<> <div>
<div> <CopyToClipboard message={domain}>
<CopyToClipboard message={domain}> {ips.length > 0 ? (
<span className={"text-xs font-mono text-nb-gray-400 truncate"}> <Tooltip
{domain} content={<ResolvedIpsTooltip ips={ips} />}
</span> delayDuration={300}
</CopyToClipboard> closeDelay={300}
</div> side={"right"}
{first && ( align={"start"}
<div className={"flex items-center gap-1.5 min-w-0"}> alignOffset={-8}
<CopyToClipboard message={first}> interactive
<span className={"text-xs font-mono text-nb-gray-500 truncate"}> keepOpenOnClick
{first} contentClassName={cn(
</span> "max-w-[18rem] max-h-72 overflow-auto",
</CopyToClipboard> "rounded-lg border border-nb-gray-900 bg-nb-gray-935",
{extra > 0 && <ResolvedIpsPopover ips={ips} />} "p-2 pr-4",
</div> )}
)} >
</> {span}
</Tooltip>
) : (
span
)}
</CopyToClipboard>
</div>
); );
}; };
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => { const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const extra = ips.length - 1;
return ( return (
<Popover.Root> <>
<Popover.Trigger asChild> <div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
<button {t("networks.ips.heading")}
type={"button"} </div>
onClick={(e) => e.stopPropagation()} <ul className={"flex flex-col"}>
className={cn( {ips.map((ip) => (
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850", <li key={ip}>
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300", <CopyToClipboard message={ip} className={"px-1 py-0.5"}>
"wails-no-draggable cursor-pointer outline-none", <span
)} className={
> "font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
{t("networks.ips.more", { count: extra })} }
</button> >
</Popover.Trigger> {ip}
<Popover.Portal> </span>
<Popover.Content </CopyToClipboard>
side={"bottom"} </li>
align={"start"} ))}
sideOffset={6} </ul>
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>
); );
}; };
@@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<span <span
className={cn( className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform", "inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
? "translate-x-2.5"
: checked
? "translate-x-[1.125rem]"
: "translate-x-0.5",
)} )}
/> />
</button> </button>
@@ -1,12 +1,4 @@
import { import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
ComponentType,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion"; import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
@@ -17,6 +9,7 @@ import {
ArrowUpDownIcon, ArrowUpDownIcon,
ArrowUpIcon, ArrowUpIcon,
CableIcon, CableIcon,
ChevronDownIcon,
ClockIcon, ClockIcon,
GaugeIcon, GaugeIcon,
HandshakeIcon, HandshakeIcon,
@@ -34,6 +27,7 @@ import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard"; import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip"; import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters"; import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext"; import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext";
@@ -249,6 +243,22 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
</span> </span>
</Row> </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 && ( {peer.latencyMs > 0 && (
<Row icon={GaugeIcon} label={t("peers.details.latency")}> <Row icon={GaugeIcon} label={t("peers.details.latency")}>
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}> <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")}> <Row icon={ClockIcon} label={t("peers.details.statusSince")}>
{statusSince} {statusSince}
</Row> </Row>
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<IceRow <IceRow
icon={MonitorIcon} icon={MonitorIcon}
baseLabel={t("peers.details.localIce")} baseLabel={t("peers.details.localIce")}
@@ -294,27 +309,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
type={peer.remoteIceCandidateType} type={peer.remoteIceCandidateType}
endpoint={peer.remoteIceCandidateEndpoint} 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")}> <Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
{peer.pubKey ? ( {peer.pubKey ? (
<CopyToClipboard <CopyToClipboard
@@ -370,67 +364,35 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
); );
}; };
// "{N} Resources" trigger that opens a hover popover listing each routed // Single "View {n}" badge with a chevron that opens a click popover listing
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs // each routed resource on its own line with a click-to-copy entry. Avoids
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover). // the repetitive "first item + N more" pattern given the row already has a
// Row value: first network inline, plus a "+N more" hover pill opening a // "Resources" label and Layers icon.
// popover with the full list. Mirrors the resolved-IPs pattern in const ResourcesValue = ({ networks }: { networks: string[] }) => (
// Networks.tsx so the Resources tab and the peer detail row look consistent. <ResourcesPopover networks={networks} />
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>
);
};
const ResourcesMorePopover = ({ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
networks,
extra,
}: {
networks: string[];
extra: number;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false); 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 ( return (
<Popover.Root open={open} onOpenChange={setOpen}> <Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type={"button"} type={"button"}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
className={cn( className={cn(
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850", "shrink-0 inline-flex items-center gap-1 rounded",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300", "bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"wails-no-draggable cursor-default outline-none", "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> </button>
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
@@ -438,13 +400,11 @@ const ResourcesMorePopover = ({
side={"bottom"} side={"bottom"}
align={"end"} align={"end"}
sideOffset={6} sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
className={cn( 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", "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"}> <ul className={"flex flex-col"}>
@@ -453,7 +413,7 @@ const ResourcesMorePopover = ({
<CopyToClipboard message={n} className={"px-1 py-0.5"}> <CopyToClipboard message={n} className={"px-1 py-0.5"}>
<span <span
className={ className={
"font-mono text-[0.72rem] text-nb-gray-200 break-all" "font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
} }
> >
{n} {n}
@@ -468,37 +428,15 @@ const ResourcesMorePopover = ({
); );
}; };
// Truncates the value to one line with the row's available width; on hover, const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
// shows the full string in a tooltip — but only when actually clipped. Same <TruncatedText
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown. text={value}
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => { className={cn(
const ref = useRef<HTMLSpanElement>(null); "inline-block truncate align-middle min-w-0 max-w-[260px]",
const [overflowing, setOverflowing] = useState(false); mono && "font-mono",
)}
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 Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => ( 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"}> <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} disabled={disabled}
className={cn( className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md", "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", "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", "disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable cursor-default", "wails-no-draggable cursor-default",
@@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
> >
<ListFilter size={14} className={"shrink-0"} /> <ListFilter size={14} className={"shrink-0"} />
<span> <span>
{active.label}{" "} {active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span> </span>
<ChevronDown <ChevronDown size={14} className={"ml-0.5 shrink-0"} />
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}> <DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => { {filters.map((f) => {
@@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
> >
<span className={"flex-1 truncate"}> <span className={"flex-1 truncate"}>
{f.label}{" "} {f.label}{" "}
<span className={"tabular-nums"}> <span className={"tabular-nums"}>({counts[f.value]})</span>
({counts[f.value]})
</span>
</span> </span>
<span <span className={"w-4 shrink-0 flex items-center justify-center"}>
className={ {checked && <CheckIcon size={14} className={"text-netbird"} />}
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span> </span>
</DropdownMenuItem> </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 { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ChevronRightIcon, LaptopIcon } from "lucide-react"; import { ChevronRightIcon, LaptopIcon } from "lucide-react";
@@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext"; import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip"; import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { mockOr, mockPeers } from "@/lib/mock"; import { mockOr, mockPeers } from "@/lib/mock";
import { PeerFilters, StatusFilter } from "./PeerFilters"; import { PeerFilters, StatusFilter } from "./PeerFilters";
@@ -67,27 +68,55 @@ export const Peers = () => {
}; };
}, [peers]); }, [peers]);
// Initial order: online-first, then alphabetically by fqdn / ip. After // Initial order: online-first, then alphabetically by fqdn / ip. Once
// that, positions are sticky — a peer flipping Connected→Connecting→Idle // peers have settled, positions become sticky — a peer flipping
// no longer jumps groups. Newly discovered peers append at the end // Connected→Connecting→Idle no longer jumps groups. Newly discovered
// (sorted online-first / by-name among themselves). Mirrors the // peers append at the end (sorted online-first / by-name among
// networks-list and exit-nodes-list orderRef pattern. // 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 orderRef = useRef<string[]>([]);
const stickyRef = useRef(false);
const ordered = useMemo(() => { const ordered = useMemo(() => {
const byKey = new Map(peers.map((p) => [p.pubKey, p])); const sortOnlineFirst = (list: PeerStatus[]) =>
const kept = orderRef.current.filter((k) => byKey.has(k)); [...list].sort((a, b) => {
const known = new Set(kept);
const fresh = peers
.filter((p) => !known.has(p.pubKey))
.sort((a, b) => {
const aOnline = isOnline(a.connStatus); const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus); const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1; if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase(); const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase(); const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName); 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]; const next = [...kept, ...fresh];
orderRef.current = next; orderRef.current = next;
return next.map((k) => byKey.get(k)!); return next.map((k) => byKey.get(k)!);
@@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
key={peer.pubKey} key={peer.pubKey}
onClick={() => setSelected(peer)} onClick={() => setSelected(peer)}
className={cn( 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", "hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-default", "wails-no-draggable cursor-default",
)} )}
> >
<Tooltip <Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
content={t(peerStatusLabelKey(peer.connStatus))}
side={"left"}
>
<span <span
className={cn( className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2", "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 className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div> <div>
<CopyToClipboard message={peer.fqdn}> <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> </CopyToClipboard>
</div> </div>
<div> <div>
@@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</ul> </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>
);
};
-2
View File
@@ -355,7 +355,6 @@
"peers.status.disconnected": "Getrennt", "peers.status.disconnected": "Getrennt",
"peers.details.relayAddress": "Relay", "peers.details.relayAddress": "Relay",
"peers.details.networks": "Ressourcen", "peers.details.networks": "Ressourcen",
"peers.details.more": "+{count} weitere",
"peers.details.relayed": "Relayed", "peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P", "peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass aktiviert", "peers.details.rosenpass": "Rosenpass aktiviert",
@@ -368,7 +367,6 @@
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.", "networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
"networks.selected": "Ausgewählt", "networks.selected": "Ausgewählt",
"networks.unselected": "Nicht ausgewählt", "networks.unselected": "Nicht ausgewählt",
"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.selectionCount": "{selected} von {total} aktiv",
"networks.bulk.enableAll": "Alle aktivieren", "networks.bulk.enableAll": "Alle aktivieren",
-2
View File
@@ -371,7 +371,6 @@
"peers.status.disconnected": "Disconnected", "peers.status.disconnected": "Disconnected",
"peers.details.relayAddress": "Relay", "peers.details.relayAddress": "Relay",
"peers.details.networks": "Resources", "peers.details.networks": "Resources",
"peers.details.more": "+{count} more",
"peers.details.relayed": "Relayed", "peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P", "peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass enabled", "peers.details.rosenpass": "Rosenpass enabled",
@@ -384,7 +383,6 @@
"networks.empty.description": "No routed networks have been shared with this peer.", "networks.empty.description": "No routed networks have been shared with this peer.",
"networks.selected": "Selected", "networks.selected": "Selected",
"networks.unselected": "Not selected", "networks.unselected": "Not selected",
"networks.ips.more": "+{count} more",
"networks.ips.heading": "Resolved IPs", "networks.ips.heading": "Resolved IPs",
"networks.bulk.selectionCount": "{selected} of {total} Active", "networks.bulk.selectionCount": "{selected} of {total} Active",
"networks.bulk.enableAll": "Enable all", "networks.bulk.enableAll": "Enable all",
-2
View File
@@ -355,7 +355,6 @@
"peers.status.disconnected": "Lecsatlakozva", "peers.status.disconnected": "Lecsatlakozva",
"peers.details.relayAddress": "Relay", "peers.details.relayAddress": "Relay",
"peers.details.networks": "Erőforrások", "peers.details.networks": "Erőforrások",
"peers.details.more": "+{count} további",
"peers.details.relayed": "Relayed", "peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P", "peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass engedélyezve", "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.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
"networks.selected": "Kiválasztva", "networks.selected": "Kiválasztva",
"networks.unselected": "Nincs kiválasztva", "networks.unselected": "Nincs kiválasztva",
"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.selectionCount": "{selected} / {total} aktív",
"networks.bulk.enableAll": "Összes engedélyezése", "networks.bulk.enableAll": "Összes engedélyezése",