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:
Jordan Ramos
2026-06-04 11:15:13 -06:00
parent d9c47ec030
commit e887fa8946
5 changed files with 1049 additions and 22 deletions

View File

@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
import AnomalyBanner from './AnomalyBanner';
import CveTooltip from '../CveTooltip';
import CardOwnerTooltip from '../CardOwnerTooltip';
import CardDetailModal from '../CardDetailModal';
import RedirectModal from '../RedirectModal';
import AtlasBadge from '../AtlasBadge';
import LoaderModal from '../LoaderModal';
@@ -1186,7 +1188,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
switch (colKey) {
case 'findingId':
return (
@@ -1259,7 +1261,11 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
);
case 'ipAddress':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
<td
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: finding.ipAddress ? 'help' : 'default' }}
onMouseEnter={onIpMouseEnter && finding.ipAddress ? (e) => onIpMouseEnter(finding.ipAddress, e) : undefined}
onMouseLeave={onIpMouseLeave || undefined}
>
{finding.ipAddress || '—'}
</td>
);
@@ -5832,6 +5838,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const tooltipCacheRef = useRef(new Map());
const hoverTimerRef = useRef(null);
// CARD owner tooltip state & refs
const [cardTooltipIp, setCardTooltipIp] = useState(null);
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
const cardTooltipCacheRef = useRef(new Map());
const cardHoverTimerRef = useRef(null);
// Atlas action plan state
const [metricsTab, setMetricsTab] = useState('ivanti');
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
@@ -5924,6 +5936,49 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
setTooltipAnchorRect(null);
}, []);
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
const handleIpMouseEnter = useCallback((ip, e) => {
if (!ip) return;
clearTimeout(cardHoverTimerRef.current);
cardHoverTimerRef.current = setTimeout(() => {
setCardTooltipIp(ip);
setCardTooltipAnchorRect(e.target.getBoundingClientRect());
}, 400);
}, []);
const handleIpMouseLeave = useCallback(() => {
clearTimeout(cardHoverTimerRef.current);
// Delay hiding to allow mouse to move into tooltip
cardHoverTimerRef.current = setTimeout(() => {
setCardTooltipIp(null);
setCardTooltipAnchorRect(null);
}, 150);
}, []);
const handleCardTooltipEnter = useCallback(() => {
// Mouse entered tooltip — cancel the hide timer
clearTimeout(cardHoverTimerRef.current);
}, []);
const handleCardTooltipLeave = useCallback(() => {
// Mouse left tooltip — hide it
clearTimeout(cardHoverTimerRef.current);
setCardTooltipIp(null);
setCardTooltipAnchorRect(null);
}, []);
// CARD action — open CardActionModal from tooltip
const [cardActionIp, setCardActionIp] = useState(null);
const [cardActionData, setCardActionData] = useState(null);
const handleCardAction = useCallback((ip, data) => {
setCardActionIp(ip);
setCardActionData(data);
// Close the tooltip
setCardTooltipIp(null);
setCardTooltipAnchorRect(null);
}, []);
const applyState = (data) => {
setTotal(data.total ?? 0);
setFindings(data.findings || []);
@@ -6004,6 +6059,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
// CARD API — fetch status and teams (session-level caching)
const cardTeamsFetchedRef = useRef(false);
const cardTeamsRetryRef = useRef(0);
const fetchCardStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
@@ -6011,19 +6067,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const data = await res.json();
setCardConfigured(data.configured === true);
if (data.configured && !cardTeamsFetchedRef.current) {
cardTeamsFetchedRef.current = true;
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
if (teamsRes.ok) {
const teamsData = await teamsRes.json();
const teams = Array.isArray(teamsData)
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
: [];
setCardTeams(teams);
if (teams.length > 0) {
setCardTeams(teams);
cardTeamsFetchedRef.current = true;
}
} else if (cardTeamsRetryRef.current < 3) {
// Retry silently after a delay (CARD teams endpoint can be slow)
cardTeamsRetryRef.current += 1;
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
}
}
}
} catch (err) {
console.error('[card-api] Failed to fetch CARD status:', err.message);
// Retry on network error too
if (cardTeamsRetryRef.current < 3) {
cardTeamsRetryRef.current += 1;
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
}
}
}, []);
@@ -7264,7 +7331,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
@@ -7372,7 +7439,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
@@ -7473,7 +7540,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/>
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
@@ -7569,6 +7636,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
anchorRect={tooltipAnchorRect}
cache={tooltipCacheRef}
/>
<CardOwnerTooltip
ip={cardTooltipIp}
anchorRect={cardTooltipAnchorRect}
cache={cardTooltipCacheRef}
cardConfigured={cardConfigured}
onAction={handleCardAction}
onMouseEnter={handleCardTooltipEnter}
onMouseLeave={handleCardTooltipLeave}
/>
<CardDetailModal
isOpen={!!cardActionIp}
onClose={() => { setCardActionIp(null); setCardActionData(null); }}
ip={cardActionIp}
ownerData={cardActionData}
cardTeams={cardTeams}
/>
{atlasPanelOpen && atlasSelectedHostId && (
<AtlasSlideOutPanel
hostId={atlasSelectedHostId}