This commit is contained in:
Eduard Gert
2026-06-08 15:22:35 +02:00
parent 0e4d0128b6
commit 10d84b758f
10 changed files with 201 additions and 22 deletions

View File

@@ -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<HTMLDivElement>(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,
)}
>
<span className={cn("relative truncate min-w-0")}>
<span
className={cn(
"relative truncate min-w-0",
// [&_*] is Tailwind's arbitrary descendant variant: & is
// this element, _ is the CSS descendant combinator, * is
// every descendant. The generated selector has higher
// specificity than a child's own text-nb-gray-* class, so
// the hover colour wins the cascade.
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
>
{children}
<span
className={

View File

@@ -33,3 +33,14 @@ export const formatRelative = (
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
// shortenDns drops the domain suffix off a DNS name, returning just the
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
// is operator-configurable so we keep everything before the first dot rather
// than matching against a known suffix. The full DNS name still lands on
// the clipboard via the copy helpers' explicit message prop.
export const shortenDns = (fqdn: string | undefined | null): string => {
if (!fqdn) return "";
const dot = fqdn.indexOf(".");
return dot === -1 ? fqdn : fqdn.slice(0, dot);
};

View File

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

View File

@@ -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 (
<div
className={cn(
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-6",
// Anchored from the top so the FQDN/IP lines below the toggle
// can grow into a popover-aware layout without shifting the
// toggle itself (justify-center would slide everything up
// when the IP line is hidden during Disconnected).
"flex flex-col h-full w-full items-center gap-4",
"relative top-[11.7rem]",
)}
>
<img
@@ -422,32 +431,145 @@ export const MainConnectionStatusSwitch = () => {
</h1>
<CopyToClipboard
message={fqdn}
variant={"bright"}
className={cn(
"min-h-[1em] transition-opacity duration-300 max-w-full",
"relative left-[0.55rem]",
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
show && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<TruncatedText
text={fqdn || " "}
text={shortenDns(fqdn) || " "}
className={
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
}
/>
</CopyToClipboard>
<CopyToClipboard
message={ip}
className={cn(
"min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
</div>
</div>
);
};
// 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 (
<CopyToClipboard
message={ip}
variant={"bright"}
className={cn(
"min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
);
}
return (
<div
className={cn(
"min-h-[1em] transition-opacity duration-300 max-w-full",
"relative wails-no-draggable",
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
className={cn(
// relative so the chevron can be absolutely
// positioned alongside without widening the trigger
// — keeps the IP text centred in its parent and
// lets the popover centre cleanly on it.
"group relative inline-flex items-center outline-none cursor-default",
"transition-colors",
)}
>
<span
className={cn(
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
>
{ip || " "}
</span>
<ChevronDownIcon
size={14}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"center"}
sideOffset={6}
onOpenAutoFocus={(e) => 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 value={ip} />
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
<IpRow value={ipv6} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
};
// 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 (
<button
type={"button"}
onClick={handleClick}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"transition-colors outline-none cursor-default",
)}
>
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
<span className={"shrink-0 inline-flex items-center text-nb-gray-200"}>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};

View File

@@ -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]"}
>
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
{selected.fqdn || selected.ip}
{shortenDns(selected.fqdn) || selected.ip}
</span>
</CopyToClipboard>
<Tooltip content={t("peers.details.refresh")}>
@@ -233,6 +233,18 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
DASH
)}
</Row>
{peer.ipv6 && (
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
<CopyToClipboard
message={peer.ipv6}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.ipv6} mono />
</CopyToClipboard>
</Row>
)}
{isConnected && (
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
<span className={"whitespace-nowrap"}>{connectionLabel}</span>

View File

@@ -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[] }) => {
<div>
<CopyToClipboard message={peer.fqdn}>
<TruncatedText
text={peer.fqdn}
text={shortenDns(peer.fqdn)}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}

View File

@@ -392,6 +392,7 @@
"peers.empty.description": "Es sind keine Peers mit Ihrem Netzwerk verbunden. Fügen Sie einen Peer hinzu, um zu beginnen.",
"peers.details.domain": "Domain",
"peers.details.netbirdIp": "NetBird-IP",
"peers.details.netbirdIpv6": "NetBird-IPv6",
"peers.details.publicKey": "Öffentlicher Schlüssel",
"peers.details.connection": "Verbindung",
"peers.details.latency": "Latenz",

View File

@@ -394,6 +394,7 @@
"peers.empty.description": "No peers are connected to your network. Add a peer to get started.",
"peers.details.domain": "Domain",
"peers.details.netbirdIp": "NetBird IP",
"peers.details.netbirdIpv6": "NetBird IPv6",
"peers.details.publicKey": "Public key",
"peers.details.connection": "Connection",
"peers.details.latency": "Latency",

View File

@@ -392,6 +392,7 @@
"peers.empty.description": "Egyetlen társ sem csatlakozott a hálózatához. Adjon hozzá egy társat a kezdéshez.",
"peers.details.domain": "Domain",
"peers.details.netbirdIp": "NetBird IP",
"peers.details.netbirdIpv6": "NetBird IPv6",
"peers.details.publicKey": "Publikus kulcs",
"peers.details.connection": "Kapcsolat",
"peers.details.latency": "Késleltetés",

View File

@@ -100,6 +100,7 @@ type SystemEvent struct {
// troubleshooting expansion (ICE candidate types, endpoints, handshake age).
type PeerStatus struct {
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
PubKey string `json:"pubKey"`
ConnStatus string `json:"connStatus"`
ConnStatusUpdateUnix int64 `json:"connStatusUpdateUnix"`
@@ -129,6 +130,7 @@ type PeerLink struct {
// LocalPeer mirrors LocalPeerState — what this client looks like on the mesh.
type LocalPeer struct {
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
PubKey string `json:"pubKey"`
Fqdn string `json:"fqdn"`
Networks []string `json:"networks"`
@@ -582,6 +584,7 @@ func statusFromProto(resp *proto.StatusResponse) Status {
},
Local: LocalPeer{
IP: local.GetIP(),
IPv6: local.GetIpv6(),
PubKey: local.GetPubKey(),
Fqdn: local.GetFqdn(),
Networks: append([]string{}, local.GetNetworks()...),
@@ -591,6 +594,7 @@ func statusFromProto(resp *proto.StatusResponse) Status {
for _, p := range full.GetPeers() {
st.Peers = append(st.Peers, PeerStatus{
IP: p.GetIP(),
IPv6: p.GetIpv6(),
PubKey: p.GetPubKey(),
ConnStatus: p.GetConnStatus(),
ConnStatusUpdateUnix: p.GetConnStatusUpdate().GetSeconds(),