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:
@@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
async function getOwner(assetId, options) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
@@ -298,36 +298,55 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
/**
|
||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
||||
*
|
||||
* @param {string} ip - IP address or existing asset ID
|
||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
||||
*/
|
||||
async function resolveAssetId(ip) {
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
async function resolveAssetId(ip, options) {
|
||||
const quick = options && options.quick;
|
||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
||||
const result = await getOwner(trimmedIp);
|
||||
try {
|
||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
const candidate = `${trimmedIp}-${suffix}`;
|
||||
try {
|
||||
const result = await getOwner(candidate);
|
||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return candidate;
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare IP as last resort
|
||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
||||
if (!quick) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -508,8 +508,11 @@ function createCardApiRouter() {
|
||||
* confirm/decline/redirect operations.
|
||||
*
|
||||
* @param {string} ip - IP address (path parameter)
|
||||
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
|
||||
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
|
||||
* @response 400 - { error: string } — missing IP
|
||||
* @response 404 - { error: string } — IP not found in CARD
|
||||
* @response 504 - { error: string, timeout: true } — CARD lookup timed out
|
||||
* @response 503 - { error: string } — CARD not configured
|
||||
*/
|
||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
@@ -522,8 +525,19 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
|
||||
const quick = req.query.quick === '1';
|
||||
|
||||
// Resolve to full asset ID
|
||||
const assetId = await resolveAssetId(ip.trim());
|
||||
let assetId;
|
||||
try {
|
||||
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
|
||||
} catch (err) {
|
||||
if (err.message === 'CARD_TIMEOUT') {
|
||||
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
|
||||
}
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
if (!assetId) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
||||
}
|
||||
@@ -552,6 +566,208 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/confirm
|
||||
*
|
||||
* Directly confirm ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD confirm.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to confirm ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/decline
|
||||
*
|
||||
* Directly decline ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD decline.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to decline ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/redirect
|
||||
*
|
||||
* Directly redirect a CARD asset between teams (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD redirect.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { fromTeam, toTeam } = req.body || {};
|
||||
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /enrich-batch
|
||||
*
|
||||
|
||||
376
frontend/src/components/CardDetailModal.js
Normal file
376
frontend/src/components/CardDetailModal.js
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* CardDetailModal — Full CARD ownership detail view
|
||||
*
|
||||
* Opens from the CARD tooltip "Actions" button on the reporting page.
|
||||
* Shows the full ownership record and allows confirm/decline/redirect
|
||||
* directly against the CARD API (no queue item required).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
||||
width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto',
|
||||
padding: '1.5rem', position: 'relative',
|
||||
};
|
||||
const SECTION = {
|
||||
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
||||
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
||||
};
|
||||
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
||||
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const TEAM_BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
background: `${color}15`, border: `1px solid ${color}40`, color,
|
||||
});
|
||||
const INPUT = {
|
||||
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
||||
};
|
||||
|
||||
export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) {
|
||||
const [ownerData, setOwnerData] = useState(initialOwnerData || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [action, setAction] = useState('confirm');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [fromTeam, setFromTeam] = useState('');
|
||||
const [toTeam, setToTeam] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execError, setExecError] = useState(null);
|
||||
const [execSuccess, setExecSuccess] = useState(null);
|
||||
|
||||
// Fetch owner data if not provided or refresh on open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ip) return;
|
||||
|
||||
// If we already have data from the tooltip cache, use it
|
||||
if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) {
|
||||
setOwnerData(initialOwnerData);
|
||||
// Pre-fill team fields
|
||||
if (initialOwnerData.confirmed) {
|
||||
setTeamName(initialOwnerData.confirmed.name || '');
|
||||
setFromTeam(initialOwnerData.confirmed.name || '');
|
||||
} else if (initialOwnerData.unconfirmed) {
|
||||
setTeamName(initialOwnerData.unconfirmed.name || '');
|
||||
setFromTeam(initialOwnerData.unconfirmed.name || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
setOwnerData(data);
|
||||
if (data.confirmed) {
|
||||
setTeamName(data.confirmed.name || '');
|
||||
setFromTeam(data.confirmed.name || '');
|
||||
} else if (data.unconfirmed) {
|
||||
setTeamName(data.unconfirmed.name || '');
|
||||
setFromTeam(data.unconfirmed.name || '');
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen, ip, initialOwnerData]);
|
||||
|
||||
// Reset state on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
setComment('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!ownerData?.asset_id) return;
|
||||
setExecuting(true);
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
const assetId = ownerData.asset_id;
|
||||
|
||||
if (action === 'confirm') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'decline') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'redirect') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`;
|
||||
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setExecError(data.error || data.message || `${action} failed.`);
|
||||
} else {
|
||||
setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setExecError(err.message || 'Network error.');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [ownerData, action, teamName, fromTeam, toTeam, comment]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canExecute = () => {
|
||||
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
||||
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Details</h3>
|
||||
<div style={{ fontSize: '0.72rem', color: '#0EA5E9', fontFamily: "'JetBrains Mono', monospace", marginTop: '0.2rem' }}>
|
||||
{ip}
|
||||
</div>
|
||||
{ownerData?.asset_id && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.1rem' }}>
|
||||
{ownerData.asset_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{ownerData && !loading && (
|
||||
<>
|
||||
{/* Ownership section */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Ownership</div>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
||||
{ownerData.confirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
||||
{ownerData.unconfirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.unconfirmed.score})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.candidate.map((c, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ownerData.declined && ownerData.declined.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.declined.map((d, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action section */}
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||
<div style={LABEL}>Action</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
||||
{['confirm', 'decline', 'redirect'].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAction(a)}
|
||||
style={{
|
||||
...BTN,
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
||||
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
||||
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
||||
}}
|
||||
>
|
||||
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action-specific fields */}
|
||||
{(action === 'confirm' || action === 'decline') && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
||||
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
||||
<option value="">Select team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
||||
))}
|
||||
<option disabled>───────────</option>
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
||||
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'redirect' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
||||
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
||||
<option value="">Select from team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
||||
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
||||
<option value="">Select to team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{execSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#6EE7B7' }}>{execSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Close</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!canExecute() || executing || !!execSuccess}
|
||||
style={{
|
||||
...BTN,
|
||||
background: canExecute() && !executing && !execSuccess ? '#7C3AED' : '#1E293B',
|
||||
color: canExecute() && !executing && !execSuccess ? '#fff' : '#475569',
|
||||
cursor: canExecute() && !executing && !execSuccess ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
: [];
|
||||
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