feat: add CVE tooltip on hover in Reporting Page
- Add GET /api/cves/:cveId/tooltip backend endpoint with description truncation - Create CveTooltip portal component with caching, severity badges, and viewport-aware positioning - Integrate tooltip into ReportingPage with 300ms hover delay on CVE badge spans
This commit is contained in:
243
frontend/src/components/CveTooltip.js
Normal file
243
frontend/src/components/CveTooltip.js
Normal file
@@ -0,0 +1,243 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, Chevr
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -920,7 +921,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding, canWrite }) {
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -956,7 +957,12 @@ function TableCell({ colKey, finding, canWrite }) {
|
||||
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||
{shown.map((cve) => (
|
||||
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
|
||||
<span
|
||||
key={cve}
|
||||
onMouseEnter={onCveMouseEnter ? (e) => onCveMouseEnter(cve, e) : undefined}
|
||||
onMouseLeave={onCveMouseLeave || undefined}
|
||||
style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{cve}
|
||||
</span>
|
||||
))}
|
||||
@@ -2312,11 +2318,32 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
|
||||
const [batchVendor, setBatchVendor] = useState('');
|
||||
|
||||
// CVE tooltip state & refs
|
||||
const [tooltipCveId, setTooltipCveId] = useState(null);
|
||||
const [tooltipAnchorRect, setTooltipAnchorRect] = useState(null);
|
||||
const tooltipCacheRef = useRef(new Map());
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
}, []);
|
||||
|
||||
// CVE tooltip hover handlers
|
||||
const handleCveMouseEnter = useCallback((cveId, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setTooltipCveId(cveId);
|
||||
setTooltipAnchorRect(e.target.getBoundingClientRect());
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleCveMouseLeave = useCallback(() => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
setTooltipCveId(null);
|
||||
setTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const applyState = (data) => {
|
||||
setTotal(data.total ?? 0);
|
||||
setFindings(data.findings || []);
|
||||
@@ -2358,7 +2385,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) applyState(data);
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
tooltipCacheRef.current.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading findings:', e);
|
||||
} finally {
|
||||
@@ -2373,6 +2403,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
tooltipCacheRef.current.clear();
|
||||
fetchCounts(); // refresh counts after sync
|
||||
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
|
||||
}
|
||||
@@ -3182,7 +3213,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
/>
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
@@ -3245,6 +3276,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
selectedItems={fpModalItems}
|
||||
onSuccess={handleFpWorkflowSuccess}
|
||||
/>
|
||||
<CveTooltip
|
||||
cveId={tooltipCveId}
|
||||
anchorRect={tooltipAnchorRect}
|
||||
cache={tooltipCacheRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user