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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user