/** * CardOwnerTooltip — CARD ownership hover tooltip * * Shows CARD asset ownership data (confirmed/unconfirmed/candidate teams) * when hovering over an IP address in the findings table. * Interactive — stays open when you hover into it, includes an Actions button. * Follows the same portal + positioning pattern as CveTooltip. */ import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; import ReactDOM from 'react-dom'; import { Loader, AlertCircle, ExternalLink } from 'lucide-react'; // ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only (no absolute URL fallback). // Other components use: const API_BASE = process.env.REACT_APP_API_BASE || '/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const TOOLTIP_GAP = 8; const ARROW_SIZE = 6; const BORDER_COLOR = '#7C3AED'; // purple to match CARD branding function calcPosition(anchorRect, tooltipHeight, viewportHeight) { const spaceAbove = anchorRect.top; const spaceBelow = viewportHeight - anchorRect.bottom; const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE; const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow; let top; if (placeAbove) { top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE; if (top < 0) top = 0; } else { top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE; if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight; } const left = anchorRect.left + anchorRect.width / 2; return { top, left, placeAbove }; } // --------------------------------------------------------------------------- // Main exported component // --------------------------------------------------------------------------- export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!ip) { setData(null); setLoading(false); setError(null); return; } if (!cardConfigured) { setError('CARD not configured'); setLoading(false); return; } // Check cache if (cache.current.has(ip)) { const cached = cache.current.get(ip); if (cached.error) { setError(cached.error); setData(null); } else { setData(cached); setError(null); } setLoading(false); return; } // Fetch const controller = new AbortController(); setLoading(true); setData(null); setError(null); fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1`, { credentials: 'include', signal: controller.signal, }) .then((res) => { if (res.status === 404) { const result = { notFound: true }; cache.current.set(ip, result); setData(result); setLoading(false); return; } if (res.status === 504) { // Timeout — don't cache, can be retried setError('CARD lookup timed out — try again'); setLoading(false); return; } if (res.status === 502) { // CARD unreachable — don't cache setError('CARD unavailable'); setLoading(false); return; } if (!res.ok) return res.json().then(d => { throw new Error(d.error || `HTTP ${res.status}`); }); return res.json(); }) .then((payload) => { if (!payload) return; // 404 already handled cache.current.set(ip, payload); setData(payload); setLoading(false); }) .catch((err) => { if (err.name === 'AbortError') return; cache.current.set(ip, { error: err.message }); setError(err.message); setLoading(false); }); return () => controller.abort(); }, [ip, cache, cardConfigured]); if (!ip || !anchorRect) return null; if (!loading && !data && !error) return null; return ReactDOM.createPortal( , document.body, ); } // --------------------------------------------------------------------------- // TooltipBody — inner component for measurement + rendering // --------------------------------------------------------------------------- function TooltipBody({ data, loading, error, anchorRect, ip, onAction, onMouseEnter, onMouseLeave }) { const tooltipRef = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true }); useLayoutEffect(() => { if (!tooltipRef.current || !anchorRect) return; const rect = tooltipRef.current.getBoundingClientRect(); const vp = window.innerHeight; setPos(calcPosition(anchorRect, rect.height, vp)); }, [anchorRect, data, loading, error]); const handleAction = useCallback(() => { if (onAction && ip) { onAction(ip, data); } }, [onAction, ip, data]); const tooltipStyle = { position: 'fixed', zIndex: 99999, top: pos.top, left: pos.left, transform: 'translateX(-50%)', maxWidth: 340, minWidth: 220, background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))', border: `1.5px solid ${BORDER_COLOR}`, borderRadius: '0.5rem', padding: '0.75rem', boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${BORDER_COLOR}33`, pointerEvents: 'auto', transition: 'opacity 0.15s ease', }; const arrowStyle = { position: 'absolute', left: '50%', transform: 'translateX(-50%)', width: 0, height: 0, borderLeft: `${ARROW_SIZE}px solid transparent`, borderRight: `${ARROW_SIZE}px solid transparent`, ...(pos.placeAbove ? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderBottom: 'none' } : { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderTop: 'none' }), }; const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' }; const BADGE = (color) => ({ display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem', fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace", background: `${color}18`, border: `1px solid ${color}50`, color, }); return (
{/* Header */}
CARD {ip}
{/* Loading */} {loading && (
)} {/* Error */} {error && !loading && (
{error}
)} {/* Not found */} {data && data.notFound && !loading && (
Not found in CARD
)} {/* Owner data */} {data && !data.notFound && !data.error && !loading && (
{/* Asset ID */} {data.asset_id && (
Asset ID
{data.asset_id}
)} {/* Confirmed */}
Confirmed Owner
{data.confirmed ? (
{data.confirmed.name} {data.confirmed.score != null && ( score: {data.confirmed.score} )}
) : ( )}
{/* Unconfirmed */} {data.unconfirmed && (
Unconfirmed
{data.unconfirmed.name} {data.unconfirmed.score != null && ( score: {data.unconfirmed.score} )}
)} {/* Candidates */} {data.candidate && data.candidate.length > 0 && (
Candidates
{data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => ( {c.name} ({c.score}) ))}
)} {/* Declined */} {data.declined && data.declined.length > 0 && (
Declined
{data.declined.map((d, i) => ( {d.name} ))}
)} {/* Actions button */} {onAction && (
)}
)}
); }