diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 6affc3008..73703e462 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -2,6 +2,15 @@ import { useRef, useState, type ReactNode } from "react"; import { Check, Copy } from "lucide-react"; import { cn } from "@/lib/cn"; +// Static map — Tailwind JIT only picks up literal class names, so dynamic +// template strings would be invisible to it. +const VARIANT_HOVER = { + default: "group-hover/copy:[&_*]:text-nb-gray-300", + bright: "group-hover/copy:[&_*]:text-nb-gray-200", +} as const; + +type CopyToClipboardVariant = keyof typeof VARIANT_HOVER; + type CopyToClipboardProps = { children: ReactNode; message?: string; @@ -10,6 +19,11 @@ type CopyToClipboardProps = { className?: string; iconClassName?: string; alwaysShowIcon?: boolean; + // variant picks the text colour the wrapped content fades into on hover. + // - "default" → nb-gray-300 (peer-details, settings, etc.) + // - "bright" → nb-gray-200 (deeper-surface contexts like the main + // connection card where text needs more lift) + variant?: CopyToClipboardVariant; }; export const CopyToClipboard = ({ @@ -20,6 +34,7 @@ export const CopyToClipboard = ({ className, iconClassName, alwaysShowIcon = false, + variant = "default", }: CopyToClipboardProps) => { const wrapperRef = useRef(null); const [copied, setCopied] = useState(false); @@ -43,11 +58,22 @@ export const CopyToClipboard = ({ ref={wrapperRef} onClick={handleClick} className={cn( - "inline-flex gap-2 items-center group/copy cursor-pointer wails-no-draggable", + "inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable", className, )} > - + {children} { + if (!fqdn) return ""; + const dot = fqdn.indexOf("."); + return dot === -1 ? fqdn : fqdn.slice(0, dot); +}; diff --git a/client/ui/frontend/src/lib/mock.ts b/client/ui/frontend/src/lib/mock.ts index 5f7d1b22f..a0789bfda 100644 --- a/client/ui/frontend/src/lib/mock.ts +++ b/client/ui/frontend/src/lib/mock.ts @@ -23,6 +23,7 @@ export const mockPeers: PeerStatus[] = [ // eyeballing layout at maximum density. new PeerStatus({ ip: "100.64.0.1", + ipv6: "fd00:dead:beef::1", pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=", connStatus: "Connected", connStatusUpdateUnix: SECONDS(4), diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index c9d425a7d..b58b32296 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -11,6 +11,9 @@ import { cn } from "@/lib/cn.ts"; import { formatErrorMessage } from "@/lib/errors.ts"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { TruncatedText } from "@/components/TruncatedText"; +import { shortenDns } from "@/lib/formatters"; +import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; +import * as Popover from "@radix-ui/react-popover"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; // EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close @@ -387,14 +390,20 @@ export const MainConnectionStatusSwitch = () => { }); } }; - const showLocal = connState === ConnectionState.Connected; + const show = connState === ConnectionState.Connected; const fqdn = status?.local.fqdn || ""; const ip = status?.local.ip || ""; + const ipv6 = status?.local.ipv6 || ""; return ( ); }; + +// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has +// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens +// a popover containing both v4 and v6, each independently click-to-copy. +const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { + const [open, setOpen] = useState(false); + const hasV6 = !!ipv6; + + if (!hasV6) { + return ( + + + {ip || " "} + + + ); + } + + return ( +
+ + + + + + e.preventDefault()} + className={cn( + "z-50 min-w-64 max-w-[280px] overflow-hidden", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 shadow-lg outline-none text-nb-gray-200", + "flex flex-col", + )} + > + +
+ + + + +
+ ); +}; + +// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors +// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy +// icon on the right that flips to a checkmark briefly after a successful copy. +const IpRow = ({ value }: { value: string }) => { + const [copied, setCopied] = useState(false); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch { + // ignore + } + }; + return ( + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index e7e4b30a8..26cff6f9f 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -27,7 +27,7 @@ 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 { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { mockOr, mockPeers } from "@/lib/mock"; @@ -156,7 +156,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { iconClassName={"top-[2px]"} > - {selected.fqdn || selected.ip} + {shortenDns(selected.fqdn) || selected.ip} @@ -233,6 +233,18 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { DASH )} + {peer.ipv6 && ( + + + + + + )} {isConnected && ( {connectionLabel} diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 9dd57f848..3c7d78525 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -8,7 +8,7 @@ import { CopyToClipboard } from "@/components/CopyToClipboard"; import { SearchInput } from "@/components/inputs/SearchInput"; import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; -import { latencyColor } from "@/lib/formatters"; +import { latencyColor, shortenDns } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; @@ -217,7 +217,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {