mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-04 12:39:06 +02: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 * 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user