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.
|
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||||
*/
|
*/
|
||||||
async function getOwner(assetId) {
|
async function getOwner(assetId, options) {
|
||||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
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.
|
* 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.
|
* 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) {
|
async function resolveAssetId(ip, options) {
|
||||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
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();
|
const trimmedIp = (ip || '').trim();
|
||||||
if (!trimmedIp) return null;
|
if (!trimmedIp) return null;
|
||||||
|
|
||||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
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;
|
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
|
// Try each suffix
|
||||||
for (const suffix of SUFFIXES) {
|
for (const suffix of SUFFIXES) {
|
||||||
const candidate = `${trimmedIp}-${suffix}`;
|
const candidate = `${trimmedIp}-${suffix}`;
|
||||||
try {
|
try {
|
||||||
const result = await getOwner(candidate);
|
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||||
if (result.ok) return candidate;
|
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
|
// 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 {
|
try {
|
||||||
const result = await getOwner(trimmedIp);
|
const result = await getOwner(trimmedIp);
|
||||||
if (result.ok) return trimmedIp;
|
if (result.ok) return trimmedIp;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not found
|
// Not found
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,8 +508,11 @@ function createCardApiRouter() {
|
|||||||
* confirm/decline/redirect operations.
|
* confirm/decline/redirect operations.
|
||||||
*
|
*
|
||||||
* @param {string} ip - IP address (path parameter)
|
* @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 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 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
|
* @response 503 - { error: string } — CARD not configured
|
||||||
*/
|
*/
|
||||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
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.' });
|
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
|
// 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) {
|
if (!assetId) {
|
||||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
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
|
* 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 IvantiCountsChart from './IvantiCountsChart';
|
||||||
import AnomalyBanner from './AnomalyBanner';
|
import AnomalyBanner from './AnomalyBanner';
|
||||||
import CveTooltip from '../CveTooltip';
|
import CveTooltip from '../CveTooltip';
|
||||||
|
import CardOwnerTooltip from '../CardOwnerTooltip';
|
||||||
|
import CardDetailModal from '../CardDetailModal';
|
||||||
import RedirectModal from '../RedirectModal';
|
import RedirectModal from '../RedirectModal';
|
||||||
import AtlasBadge from '../AtlasBadge';
|
import AtlasBadge from '../AtlasBadge';
|
||||||
import LoaderModal from '../LoaderModal';
|
import LoaderModal from '../LoaderModal';
|
||||||
@@ -1186,7 +1188,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Render a single table cell by column key
|
// 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) {
|
switch (colKey) {
|
||||||
case 'findingId':
|
case 'findingId':
|
||||||
return (
|
return (
|
||||||
@@ -1259,7 +1261,11 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
|||||||
);
|
);
|
||||||
case 'ipAddress':
|
case 'ipAddress':
|
||||||
return (
|
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 || '—'}
|
{finding.ipAddress || '—'}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -5832,6 +5838,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
const tooltipCacheRef = useRef(new Map());
|
const tooltipCacheRef = useRef(new Map());
|
||||||
const hoverTimerRef = useRef(null);
|
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
|
// Atlas action plan state
|
||||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||||
@@ -5924,6 +5936,49 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
setTooltipAnchorRect(null);
|
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) => {
|
const applyState = (data) => {
|
||||||
setTotal(data.total ?? 0);
|
setTotal(data.total ?? 0);
|
||||||
setFindings(data.findings || []);
|
setFindings(data.findings || []);
|
||||||
@@ -6004,6 +6059,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
|
|
||||||
// CARD API — fetch status and teams (session-level caching)
|
// CARD API — fetch status and teams (session-level caching)
|
||||||
const cardTeamsFetchedRef = useRef(false);
|
const cardTeamsFetchedRef = useRef(false);
|
||||||
|
const cardTeamsRetryRef = useRef(0);
|
||||||
const fetchCardStatus = useCallback(async () => {
|
const fetchCardStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
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();
|
const data = await res.json();
|
||||||
setCardConfigured(data.configured === true);
|
setCardConfigured(data.configured === true);
|
||||||
if (data.configured && !cardTeamsFetchedRef.current) {
|
if (data.configured && !cardTeamsFetchedRef.current) {
|
||||||
cardTeamsFetchedRef.current = true;
|
|
||||||
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
||||||
if (teamsRes.ok) {
|
if (teamsRes.ok) {
|
||||||
const teamsData = await teamsRes.json();
|
const teamsData = await teamsRes.json();
|
||||||
const teams = Array.isArray(teamsData)
|
const teams = Array.isArray(teamsData)
|
||||||
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
||||||
: [];
|
: [];
|
||||||
|
if (teams.length > 0) {
|
||||||
setCardTeams(teams);
|
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) {
|
} catch (err) {
|
||||||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
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' }} />
|
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||||
</td>
|
</td>
|
||||||
{visibleCols.map((col) => (
|
{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>
|
</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' }} />
|
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||||
</td>
|
</td>
|
||||||
{visibleCols.map((col) => (
|
{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>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -7473,7 +7540,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{visibleCols.map((col) => (
|
{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>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -7569,6 +7636,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
anchorRect={tooltipAnchorRect}
|
anchorRect={tooltipAnchorRect}
|
||||||
cache={tooltipCacheRef}
|
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 && (
|
{atlasPanelOpen && atlasSelectedHostId && (
|
||||||
<AtlasSlideOutPanel
|
<AtlasSlideOutPanel
|
||||||
hostId={atlasSelectedHostId}
|
hostId={atlasSelectedHostId}
|
||||||
|
|||||||
Reference in New Issue
Block a user