import { type ComponentType, Fragment, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion, type Transition } from "framer-motion"; import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { ArrowDownIcon, ArrowLeftIcon, ArrowUpDownIcon, ArrowUpIcon, Check as CheckIcon, ChevronDownIcon, ChevronsLeftRightEllipsisIcon, ClockIcon, Copy as CopyIcon, GaugeIcon, HandshakeIcon, KeyRoundIcon, Layers3Icon, type LucideProps, MapPinIcon, MonitorIcon, Radio, RefreshCwIcon, WaypointsIcon, } from "lucide-react"; import type { PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { peerStatusLabelKey } from "./Peers"; const DEFAULT_TRANSITION: Transition = { duration: 0.32, ease: [0.32, 0.72, 0, 1], }; const DASH = "-"; const dotClass = (connStatus: string): string => { switch (connStatus) { case "Connected": return "bg-green-500 dark:bg-green-400"; case "Connecting": return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300"; default: return "bg-nb-gray-500"; } }; type Props = { transition?: Transition; }; export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { t } = useTranslation(); const { selected, setSelected } = usePeerDetail(); const { status, refresh } = useStatus(); useEffect(() => { if (!selected) return; const peers = status?.peers ?? []; const fresh = peers.find((p) => p.pubKey === selected.pubKey); if (!fresh) { setSelected(null); return; } if (fresh !== selected) setSelected(fresh); }, [status, selected, setSelected]); // Daemon updates latency/bytes/handshake without pushing a fresh status // snapshot, so tick locally to keep relative timestamps live. const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!selected) return; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [selected]); const [refreshing, setRefreshing] = useState(false); const onRefresh = useCallback(async () => { if (refreshing) return; setRefreshing(true); const MIN_SPIN_MS = 600; const minDelay = new Promise((r) => setTimeout(r, MIN_SPIN_MS)); try { await Promise.all([refresh(), minDelay]); } finally { setRefreshing(false); } }, [refresh, refreshing]); useEffect(() => { if (!selected) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setSelected(null); return; } if (e.key === "ArrowLeft") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; setSelected(null); } }; globalThis.addEventListener("keydown", onKey); return () => globalThis.removeEventListener("keydown", onKey); }, [selected, setSelected]); const dialogRef = useRef(null); const backButtonRef = useRef(null); useEffect(() => { if (!selected) return; // Defer focus until the slide-in animation has started rendering. // preventScroll avoids the browser scrolling the parent to chase the // still-offscreen button, which lands as a stutter at the end of the slide. requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected?.pubKey]); const getFocusable = (): HTMLElement[] => { const root = dialogRef.current; if (!root) return []; const sel = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; return Array.from(root.querySelectorAll(sel)).filter( (el) => el.offsetParent !== null || el === document.activeElement, ); }; const onDialogKeyDown = (e: ReactKeyboardEvent) => { if (e.key !== "Tab") return; const focusables = getFocusable(); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement as HTMLElement | null; if (e.shiftKey) { if (active === first || !active || !dialogRef.current?.contains(active)) { e.preventDefault(); last.focus(); } } else if (active === last) { e.preventDefault(); first.focus(); } }; return ( {selected && (
{shortenDns(selected.fqdn) || selected.ip}
)}
); }; const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { const { t } = useTranslation(); const formatAge = (unix: number, fallback: string): string => { if (!Number.isFinite(unix) || unix <= 0) return fallback; const diff = Math.floor(now / 1000 - unix); if (diff < 1) return t("peers.details.justNow"); return formatRelative(unix, now) ?? fallback; }; const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never")); const statusSince = formatAge(peer.connStatusUpdateUnix, DASH); const isConnected = peer.connStatus === "Connected"; const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p"); return (
    {peer.ip ? ( {peer.ip} ) : ( DASH )} {peer.ipv6 && ( )} {isConnected && ( {connectionLabel} )} {peer.relayed && ( {peer.relayAddress ? ( ) : ( DASH )} )} {peer.latencyMs > 0 && ( {peer.latencyMs} ms )} {(peer.bytesRx > 0 || peer.bytesTx > 0) && (
    {t("peers.details.bytesReceived")}: {formatBytes(peer.bytesRx)}
    {t("peers.details.bytesSent")}: {formatBytes(peer.bytesTx)}
    )} {lastHandshake} {statusSince} {peer.networks.length > 0 && ( )} {peer.pubKey ? ( ) : ( DASH )}
); }; type RowProps = { icon: ComponentType; iconClassName?: string; label: string; children: ReactNode; }; type IceRowProps = { icon: ComponentType; baseLabel: string; type: string; endpoint: string; }; const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s); const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { if (!type && !endpoint) return null; const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel; return ( {endpoint ? ( ) : ( {capitalize(type)} )} ); }; const ResourcesValue = ({ networks }: { networks: string[] }) => ( ); const ResourcesPopover = ({ networks }: { networks: string[] }) => { const [open, setOpen] = useState(false); return ( e.preventDefault()} className={cn( "z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto", "rounded-lg border border-nb-gray-900 bg-nb-gray-935", "p-1 text-nb-gray-200 shadow-lg outline-none", "flex flex-col", )} > {networks.map((n, i) => ( {i > 0 &&
} ))} ); }; const ResourceRow = ({ value }: { value: string }) => { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const isFocusVisible = useFocusVisible(); const handleClick = async () => { if (!value) return; try { await navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 500); } catch (e) { console.warn("copy resource to clipboard failed", e); } }; return ( ); }; const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( ); const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
  • {label} {children}
  • );