/**
* 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(