Files
cve-dashboard/frontend/src/components/CveTooltip.js

244 lines
7.6 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Severity color mapping — matches DESIGN_SYSTEM.md badge colors
// ---------------------------------------------------------------------------
const SEVERITY_COLORS = {
Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' },
High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' },
Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' },
Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' },
};
// ---------------------------------------------------------------------------
// Pure positioning function — exported for testability
// ---------------------------------------------------------------------------
const TOOLTIP_GAP = 8;
const ARROW_SIZE = 6;
export function calcTooltipPosition(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 };
}
// ---------------------------------------------------------------------------
// CveTooltip component
// ---------------------------------------------------------------------------
export default function CveTooltip({ cveId, anchorRect, cache }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!cveId) {
setData(null);
setLoading(false);
return;
}
// Check cache first
if (cache.current.has(cveId)) {
setData(cache.current.get(cveId));
setLoading(false);
return;
}
// Cache miss — fetch from API
const controller = new AbortController();
setLoading(true);
setData(null);
fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, {
credentials: 'include',
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((payload) => {
cache.current.set(cveId, payload);
setData(payload);
setLoading(false);
})
.catch((err) => {
if (err.name === 'AbortError') return;
// Do not cache transient errors
console.error('CveTooltip fetch error:', err);
setData(null);
setLoading(false);
});
return () => controller.abort();
}, [cveId, cache]);
// Nothing to show
if (!cveId || !anchorRect) return null;
if (!loading && !data) return null;
if (data && data.exists === false) return null;
const severity = data?.severity || '';
const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium;
return ReactDOM.createPortal(
<TooltipBody
data={data}
loading={loading}
anchorRect={anchorRect}
colors={colors}
severity={severity}
/>,
document.body,
);
}
// ---------------------------------------------------------------------------
// TooltipBody — inner component that measures itself for positioning
// ---------------------------------------------------------------------------
function TooltipBody({ data, loading, anchorRect, colors, severity }) {
const tooltipRef = React.useRef(null);
const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true });
React.useLayoutEffect(() => {
if (!tooltipRef.current || !anchorRect) return;
const rect = tooltipRef.current.getBoundingClientRect();
const vp = window.innerHeight;
setPos(calcTooltipPosition(anchorRect, rect.height, vp));
}, [anchorRect, data, loading]);
const tooltipStyle = {
position: 'fixed',
zIndex: 99999,
top: pos.top,
left: pos.left,
transform: 'translateX(-50%)',
maxWidth: 320,
minWidth: 200,
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
border: `1.5px solid ${colors.border}`,
borderRadius: '0.5rem',
padding: '0.75rem',
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`,
pointerEvents: 'none',
transition: 'opacity 0.15s ease',
};
// Directional arrow
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 ${colors.border}`,
borderBottom: 'none',
}
: {
top: -ARROW_SIZE,
borderBottom: `${ARROW_SIZE}px solid ${colors.border}`,
borderTop: 'none',
}),
};
return (
<div ref={tooltipRef} style={tooltipStyle} data-testid="cve-tooltip">
{/* Arrow */}
<div style={arrowStyle} data-testid="cve-tooltip-arrow" />
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
<Loader
style={{ width: 18, height: 18, color: '#0EA5E9', animation: 'spin 1s linear infinite' }}
data-testid="cve-tooltip-loader"
/>
</div>
) : data && data.exists ? (
<>
{/* CVE ID header */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.8rem',
fontWeight: 700,
color: '#E2E8F0',
marginBottom: '0.4rem',
letterSpacing: '0.02em',
}}>
{data.cve_id}
</div>
{/* Severity badge */}
{severity && (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.2rem 0.5rem',
borderRadius: '0.25rem',
border: `1.5px solid ${colors.border}`,
background: colors.bg,
marginBottom: '0.5rem',
}}>
{/* Glow dot */}
<span style={{
width: 7,
height: 7,
borderRadius: '50%',
background: colors.dot,
boxShadow: `0 0 6px ${colors.dot}`,
flexShrink: 0,
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.65rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: colors.text,
}}>
{severity}
</span>
</div>
)}
{/* Description */}
{data.description && (
<div style={{
fontSize: '0.75rem',
lineHeight: 1.5,
color: '#CBD5E1',
wordBreak: 'break-word',
}}>
{data.description}
</div>
)}
</>
) : null}
</div>
);
}