mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 15:31:30 +02:00
add ipv6
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user