Add CARD ownership tooltip and direct action modal on IP hover
Hover over any IP address in the findings table to see CARD ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Click 'Actions' to open a full modal for confirm/decline/redirect — no queue item required. Backend: - Add direct /api/card/owner/:assetId/confirm|decline|redirect endpoints - Add quick mode to resolveAssetId (CTEC only, 15s timeout) for tooltip use - owner-lookup supports ?quick=1 query param with 504 on timeout - getOwner accepts options for custom timeout Frontend: - New CardOwnerTooltip component (portal, hover bridge, cached results) - New CardDetailModal for confirm/decline/redirect from tooltip - IP cells show help cursor, trigger tooltip on 400ms hover - Timeouts (504) not cached — retry on re-hover - Teams fetch retries silently up to 3x on failure - Redirect dropdowns show owner-data teams as fallback when teams API fails
This commit is contained in:
333
frontend/src/components/CardOwnerTooltip.js
Normal file
333
frontend/src/components/CardOwnerTooltip.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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(
|
||||
<TooltipBody
|
||||
data={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
anchorRect={anchorRect}
|
||||
ip={ip}
|
||||
onAction={onAction}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>,
|
||||
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 (
|
||||
<div ref={tooltipRef} style={tooltipStyle} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<div style={arrowStyle} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#7C3AED', fontFamily: 'monospace', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
CARD
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace", fontWeight: '600' }}>
|
||||
{ip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ width: 16, height: 16, color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found */}
|
||||
{data && data.notFound && !loading && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', fontFamily: 'monospace' }}>
|
||||
Not found in CARD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{data && !data.notFound && !data.error && !loading && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{/* Asset ID */}
|
||||
{data.asset_id && (
|
||||
<div>
|
||||
<div style={LABEL}>Asset ID</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#A78BFA', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{data.asset_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmed */}
|
||||
<div>
|
||||
<div style={LABEL}>Confirmed Owner</div>
|
||||
{data.confirmed ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#10B981')}>{data.confirmed.name}</span>
|
||||
{data.confirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.confirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unconfirmed */}
|
||||
{data.unconfirmed && (
|
||||
<div>
|
||||
<div style={LABEL}>Unconfirmed</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#F59E0B')}>{data.unconfirmed.name}</span>
|
||||
{data.unconfirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.unconfirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidates */}
|
||||
{data.candidate && data.candidate.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Candidates</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => (
|
||||
<span key={i} style={BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Declined */}
|
||||
{data.declined && data.declined.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Declined</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.declined.map((d, i) => (
|
||||
<span key={i} style={BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions button */}
|
||||
{onAction && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(124, 58, 237, 0.2)' }}>
|
||||
<button
|
||||
onClick={handleAction}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: 'rgba(124, 58, 237, 0.12)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.4)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#A78BFA',
|
||||
fontSize: '0.65rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.25)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.6)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 11, height: 11 }} />
|
||||
Actions
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user