7 Commits

Author SHA1 Message Date
Jordan Ramos
e8aa7038ad Release v2.2.0 2026-06-04 11:16:45 -06:00
Jordan Ramos
e887fa8946 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
2026-06-04 11:15:13 -06:00
Jordan Ramos
d9c47ec030 Add Group by Host toggle to Ivanti findings table
Client-side grouping that collapses duplicate assets (same hostname + IP)
with multiple finding IDs into expandable host rows. Hosts with only one
finding remain as normal flat rows.

- Toggle button in toolbar switches between flat and grouped views
- Group header rows preserve column alignment (severity, host, IP in proper columns)
- Expanded sub-rows show full finding details with all interactions intact
- Selection, queue, hide, and workflow actions all work in both modes
- Groups sorted by highest severity; expand/collapse all controls included
2026-06-03 15:44:48 -06:00
Jordan Ramos
4e8f4cbb10 Allow redirecting pending queue items in place without duplicating
Previously, redirecting a queue item required completing it first, which
created a duplicate entry. Now:
- Pending items: redirect updates workflow_type in place (no new row)
- Completed items: still creates a new pending item (legacy behavior)
- Redirect arrow now visible on all items, not just completed ones
- Frontend handles in-place updates by replacing the item in state
2026-06-03 13:55:10 -06:00
Jordan Ramos
1cc8bd5a4c Improve CARD decline error diagnostics and prevent accidental modal dismiss
- Log the full owner response in audit when update_token is missing so
  we can see what CARD actually returned
- Improve error message to suggest the asset may have already been actioned
- Remove backdrop-click-to-close on TemplateFormModal to prevent
  accidental data loss while filling in template content
2026-06-03 13:33:24 -06:00
Jordan Ramos
50f14c14d2 Add inline view panel to Template Manager with copy buttons
Click the model name or eye icon to expand a read-only view of all
template sections with per-section copy-to-clipboard and Copy All.
2026-06-03 11:04:25 -06:00
Jordan Ramos
4f40850fd2 ci: force pipeline refresh for v2.1.0 deploy 2026-06-03 07:47:30 -06:00
10 changed files with 1555 additions and 71 deletions

View File

@@ -3,6 +3,7 @@
# ============================================================================= # =============================================================================
# Executor: Docker (LXC 108 — 71.85.90.8) # Executor: Docker (LXC 108 — 71.85.90.8)
# Build/test jobs run in node:18 containers. # Build/test jobs run in node:18 containers.
# Release: v2.1.0
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9) # Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
# and production (71.85.90.6) via SSH. # and production (71.85.90.6) via SSH.
# ============================================================================= # =============================================================================

View File

@@ -6,6 +6,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
--- ---
## [2.2.0] — 2026-06-04
### Features
- **Group by Host toggle** on the Ivanti findings table — collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views from the toolbar.
- **CARD ownership tooltip on IP hover** — hover over any IP address in the findings table to see CARD asset ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Results cached per session for instant re-display.
- **CARD direct action modal** — click "Actions" in the CARD tooltip to open a full confirm/decline/redirect modal that works directly against the CARD API without needing a queue item.
- **Inline view panel** in the Archer Template Manager with per-section copy buttons
- **Queue item redirect in place** — pending queue items can now be redirected without duplicating
### Bug Fixes
- Improve CARD decline error diagnostics and prevent accidental modal dismiss
- CARD teams fetch retries silently up to 3x on failure with increasing delay
- Redirect dropdowns show owner-data teams as fallback when the full teams API fails
- CARD tooltip uses quick mode (CTEC suffix only, 15s timeout) to avoid multi-minute waits
- Timeouts (504) are not cached — re-hover will retry the lookup
---
## [2.1.0] — 2026-06-06 ## [2.1.0] — 2026-06-06
### Features ### Features

View File

@@ -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;
} }

View File

@@ -363,8 +363,8 @@ function createCardApiRouter() {
const updateToken = ownerData.owner && ownerData.owner.update_token; const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record. The asset may have already been actioned or the owner record is in an unexpected state.';
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip }); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null, ownerResponse: ownerData }, ipAddress: req.ip });
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
} }
@@ -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
* *

View File

@@ -318,16 +318,17 @@ function createIvantiTodoQueueRouter() {
/** /**
* POST /api/ivanti/todo-queue/:id/redirect * POST /api/ivanti/todo-queue/:id/redirect
* *
* Redirects a completed queue item to a different workflow by creating a new * Redirects a queue item to a different workflow type. If the item is pending,
* pending queue item with the same finding data but a new workflow type/vendor. * updates workflow_type in place. If the item is complete, creates a new pending
* queue item with the same finding data but a new workflow type/vendor.
* Requires Admin or Standard_User group. * Requires Admin or Standard_User group.
* *
* @param {string} id — Queue item ID of the completed item (URL parameter) * @param {string} id — Queue item ID (URL parameter)
* @body {Object} * @body {Object}
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
* @returns {Object} The newly created queue item with parsed `cves` array * @returns {Object} The updated or newly created queue item with parsed `cves` array
* @error 400 Invalid input or item not in complete status * @error 400 Invalid input
* @error 404 Queue item not found * @error 404 Queue item not found
* @error 500 Internal server error * @error 500 Internal server error
*/ */
@@ -358,10 +359,38 @@ function createIvantiTodoQueueRouter() {
if (!original) { if (!original) {
return res.status(404).json({ error: 'Queue item not found.' }); return res.status(404).json({ error: 'Queue item not found.' });
} }
if (original.status !== 'complete') {
return res.status(400).json({ error: 'Only completed queue items can be redirected.' }); // If the item is still pending, update workflow_type in place (no duplication)
if (original.status === 'pending') {
const { rows } = await pool.query(
`UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW()
WHERE id = $3 AND user_id = $4 RETURNING *`,
[workflow_type, vendorVal, id, req.user.id]
);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'queue_item_redirected',
entityType: 'ivanti_todo_queue',
entityId: String(original.id),
details: {
original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type,
method: 'in_place_update',
vendor: vendorVal,
},
ipAddress: req.ip,
});
const result = {
...rows[0],
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
};
return res.json(result);
} }
// If the item is complete, create a new pending item (legacy behavior)
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO ivanti_todo_queue `INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
@@ -379,6 +408,7 @@ function createIvantiTodoQueueRouter() {
details: { details: {
original_workflow_type: original.workflow_type, original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type, target_workflow_type: workflow_type,
method: 'new_item_from_complete',
new_item_id: rows[0].id, new_item_id: rows[0].id,
vendor: vendorVal, vendor: vendorVal,
}, },

View 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>
);
}

View 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>
);
}

View File

@@ -8,7 +8,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { X, Save, AlertCircle, Loader } from 'lucide-react'; import { X, Save, AlertCircle, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // ⚠️ CONVENTION: Prefer relative API paths (e.g. '/api') over absolute URL fallback
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section definitions — ordered as static first, then semi-static // Section definitions — ordered as static first, then semi-static
@@ -386,7 +386,6 @@ export default function TemplateFormModal({ mode = 'create', template = null, on
aria-modal="true" aria-modal="true"
aria-labelledby="template-form-modal-title" aria-labelledby="template-form-modal-title"
style={STYLES.backdrop} style={STYLES.backdrop}
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
> >
<div style={STYLES.modal}> <div style={STYLES.modal}>
{/* Header */} {/* Header */}

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight, FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
Loader, AlertCircle, RefreshCw, Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import TemplateFormModal from '../TemplateFormModal'; import TemplateFormModal from '../TemplateFormModal';
@@ -14,6 +14,18 @@ import DeleteConfirmModal from '../DeleteConfirmModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// Section field mapping — ordered: static first, then semi-static
const SECTIONS = [
{ key: 'environment_overview', label: 'Environment Overview' },
{ key: 'segmentation', label: 'Segmentation' },
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
{ key: 'additional_info', label: 'Additional Info/Background' },
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
{ key: 'data_classification', label: 'Data Classification' },
{ key: 'charter_network', label: 'Charter Network' },
{ key: 'additional_access_list', label: 'Additional Access List' },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles — dark theme tactical intelligence aesthetic // Styles — dark theme tactical intelligence aesthetic
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -223,6 +235,11 @@ export default function ArcherTemplatePage() {
// Modal state for create/edit/clone // Modal state for create/edit/clone
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null }); const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
// View panel state — which template ID is expanded for viewing
const [viewExpandedId, setViewExpandedId] = useState(null);
// Copy state for view panel
const [copiedSections, setCopiedSections] = useState({});
const [copyAllCopied, setCopyAllCopied] = useState(false);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Fetch templates // Fetch templates
@@ -265,6 +282,41 @@ export default function ArcherTemplatePage() {
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] })); setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
}; };
// -------------------------------------------------------------------------
// View panel toggle and copy handlers
// -------------------------------------------------------------------------
const toggleView = (templateId) => {
setViewExpandedId(prev => prev === templateId ? null : templateId);
setCopiedSections({});
setCopyAllCopied(false);
};
const handleCopySection = async (sectionKey, content) => {
if (!content) return;
try {
await navigator.clipboard.writeText(content);
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
setTimeout(() => {
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
}, 2000);
} catch (_err) { /* clipboard failed */ }
};
const handleCopyAll = async (template) => {
const parts = [];
for (const section of SECTIONS) {
const content = template[section.key];
if (content && content.trim()) {
parts.push(`${section.label}\n${content}`);
}
}
try {
await navigator.clipboard.writeText(parts.join('\n\n'));
setCopyAllCopied(true);
setTimeout(() => setCopyAllCopied(false), 2000);
} catch (_err) { /* clipboard failed */ }
};
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Grouped data // Grouped data
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -363,13 +415,23 @@ export default function ArcherTemplatePage() {
<div key={platform} style={STYLES.platformSubgroup}> <div key={platform} style={STYLES.platformSubgroup}>
<div style={STYLES.platformLabel}>{platform}</div> <div style={STYLES.platformLabel}>{platform}</div>
{platTemplates.map(template => ( {platTemplates.map(template => (
<div key={template.id}>
<div <div
key={template.id}
style={STYLES.templateRow} style={STYLES.templateRow}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }} onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
> >
<span style={STYLES.templateModel}>{template.model}</span> <span
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
onClick={() => toggleView(template.id)}
title="View template sections"
>
{viewExpandedId === template.id
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
: <Eye size={13} style={{ color: '#64748B' }} />
}
{template.model}
</span>
{canWrite() && ( {canWrite() && (
<div style={STYLES.templateActions}> <div style={STYLES.templateActions}>
<button <button
@@ -396,6 +458,86 @@ export default function ArcherTemplatePage() {
</div> </div>
)} )}
</div> </div>
{/* Expandable view panel */}
{viewExpandedId === template.id && (
<div style={{
margin: '0.25rem 0 0.75rem 1.5rem',
padding: '0.75rem 1rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(0, 212, 255, 0.12)',
borderRadius: '8px',
}}>
{/* Copy All button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
<button
onClick={() => handleCopyAll(template)}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '5px',
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
color: copyAllCopied ? '#22c55e' : '#00d4ff',
fontSize: '0.7rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
</button>
</div>
{/* Section blocks */}
{SECTIONS.map(section => {
const content = template[section.key];
const isEmpty = !content || !content.trim();
const isCopied = copiedSections[section.key];
return (
<div key={section.key} style={{
marginBottom: '0.5rem',
padding: '0.5rem 0.6rem',
background: 'rgba(30, 41, 59, 0.5)',
border: '1px solid rgba(100, 116, 139, 0.12)',
borderRadius: '6px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{section.label}
</span>
<button
onClick={() => handleCopySection(section.key, content)}
disabled={isEmpty}
style={{
padding: '0.2rem 0.4rem',
borderRadius: '4px',
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
color: isCopied ? '#22c55e' : '#94a3b8',
fontSize: '0.65rem',
cursor: isEmpty ? 'not-allowed' : 'pointer',
opacity: isEmpty ? 0.4 : 1,
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
}}
>
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
</button>
</div>
{isEmpty ? (
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
) : (
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
{content}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))} ))}
</div> </div>
))} ))}

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet } from 'lucide-react'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext'; 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>
); );
@@ -2002,13 +2008,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
</div> </div>
)} )}
{/* Redirect button — completed items only */} {/* Redirect button — available on all items */}
{canWrite && done && ( {canWrite && (
<button <button
onClick={() => setRedirectItem(item)} onClick={() => setRedirectItem(item)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: done ? '#334155' : '#475569', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'} onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'} onMouseLeave={(e) => e.currentTarget.style.color = done ? '#334155' : '#475569'}
title="Redirect to another workflow" title="Redirect to another workflow"
> >
<CornerUpRight style={{ width: '13px', height: '13px' }} /> <CornerUpRight style={{ width: '13px', height: '13px' }} />
@@ -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());
@@ -5852,6 +5864,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [cardConfigured, setCardConfigured] = useState(false); const [cardConfigured, setCardConfigured] = useState(false);
const [cardTeams, setCardTeams] = useState([]); const [cardTeams, setCardTeams] = useState([]);
// Group-by-host toggle state
const [groupByHost, setGroupByHost] = useState(false);
const [expandedHosts, setExpandedHosts] = useState(new Set());
const updateColumns = useCallback((newOrder) => { const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder); setColumnOrder(newOrder);
saveColumnOrder(newOrder); saveColumnOrder(newOrder);
@@ -5920,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 || []);
@@ -6000,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' });
@@ -6007,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);
}
} }
}, []); }, []);
@@ -6165,6 +6236,67 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
return sort.dir === 'asc' ? cmp : -cmp; return sort.dir === 'asc' ? cmp : -cmp;
}), [filtered, sort]); }), [filtered, sort]);
// Grouped view — aggregate findings by hostName + ipAddress
const groupedByHost = useMemo(() => {
if (!groupByHost) return { groups: [], singles: [] };
const map = new Map();
sorted.forEach(f => {
const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`;
if (!map.has(hostKey)) {
map.set(hostKey, {
hostKey,
hostName: f.overrides?.hostName || f.hostName || '',
ipAddress: f.ipAddress || '',
findings: [],
highestSeverity: 0,
highestVrrGroup: '',
cveSet: new Set(),
});
}
const group = map.get(hostKey);
group.findings.push(f);
if (f.severity > group.highestSeverity) {
group.highestSeverity = f.severity;
group.highestVrrGroup = f.vrrGroup || '';
}
(f.cves || []).forEach(c => group.cveSet.add(c));
});
// Separate: groups with 2+ findings vs singles that stay flat
const groups = [];
const singles = [];
for (const g of map.values()) {
if (g.findings.length > 1) groups.push(g);
else singles.push(g.findings[0]);
}
groups.sort((a, b) => b.highestSeverity - a.highestSeverity);
return { groups, singles };
}, [sorted, groupByHost]);
// Combined render order for grouped mode: grouped hosts first, then singles
const groupedRenderList = useMemo(() => {
if (!groupByHost) return [];
const list = [];
groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g }));
groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f }));
return list;
}, [groupByHost, groupedByHost]);
const toggleHostExpand = useCallback((hostKey) => {
setExpandedHosts(prev => {
const next = new Set(prev);
if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey);
return next;
});
}, []);
const expandAllHosts = useCallback(() => {
setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey)));
}, [groupedByHost]);
const collapseAllHosts = useCallback(() => {
setExpandedHosts(new Set());
}, []);
// Select/deselect all visible rows // Select/deselect all visible rows
const toggleSelectAll = useCallback(() => { const toggleSelectAll = useCallback(() => {
const allVisibleIds = sorted.map(f => String(f.id)); const allVisibleIds = sorted.map(f => String(f.id));
@@ -6908,6 +7040,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</span> </span>
)} )}
</button> </button>
<button
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
borderRadius: '0.375rem',
color: groupByHost ? '#A78BFA' : '#7C3AED',
cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<Layers style={{ width: '13px', height: '13px' }} />
{groupByHost ? 'Grouped' : 'Group'}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} /> <ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} /> <RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button <button
@@ -7136,6 +7286,178 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{groupByHost ? (
/* ---- Grouped-by-host view ---- */
<>
{groupedRenderList.length > 0 && (
<tr>
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
</span>
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
</div>
</td>
</tr>
)}
{groupedRenderList.map((item, itemIdx) => {
if (item.type === 'single') {
// Render single-finding hosts as normal flat rows
const finding = item.finding;
const isSelected = selectedIds.has(finding.id);
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
const queued = isQueued(finding.id);
return (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
{selectedRowIds.has(String(finding.id))
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
}
</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
</button>
</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
<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} 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>
);
}
// Render grouped host header + expandable sub-rows
const group = item.group;
const isExpanded = expandedHosts.has(group.hostKey);
const sc = severityColor(group.highestVrrGroup);
return (
<React.Fragment key={group.hostKey}>
{/* Host group header — uses same columns as regular rows */}
<tr
onClick={() => toggleHostExpand(group.hostKey)}
style={{
borderBottom: '1px solid rgba(139,92,246,0.15)',
background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }}
>
{/* Expand/collapse icon in first fixed column */}
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
{isExpanded
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
}
</td>
{/* Empty cells for hide + checkbox columns */}
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
{/* Render each column cell — show host-level summary data in the matching column positions */}
{visibleCols.map((col) => {
switch (col.key) {
case 'findingId':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
{group.findings.length} findings
</span>
</td>
);
case 'severity':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
{group.highestSeverity.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
</span>
</td>
);
case 'hostName':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
{group.hostName || '—'}
</span>
</td>
);
case 'ipAddress':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{group.ipAddress || '—'}
</span>
</td>
);
case 'cves':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
</span>
</td>
);
default:
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
}
})}
</tr>
{/* Expanded sub-rows — individual findings */}
{isExpanded && group.findings.map((finding, idx) => {
const isSelected = selectedIds.has(finding.id);
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)');
const queued = isQueued(finding.id);
return (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
{selectedRowIds.has(String(finding.id))
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
}
</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
</button>
</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
<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} 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>
);
})}
</React.Fragment>
);
})}
{groupedRenderList.length === 0 && (
<tr>
<td colSpan={visibleCols.length + 3} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td>
</tr>
)}
</>
) : (
/* ---- Flat view (default) ---- */
<>
{sorted.map((finding, idx) => { {sorted.map((finding, idx) => {
const isSelected = selectedIds.has(finding.id); const isSelected = selectedIds.has(finding.id);
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'); const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
@@ -7218,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>
); );
@@ -7230,6 +7552,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</td> </td>
</tr> </tr>
)} )}
</>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -7273,10 +7597,18 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onDeleteMany={deleteQueueItems} onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted} onClearCompleted={clearCompleted}
onCreateFpWorkflow={handleCreateFpWorkflow} onCreateFpWorkflow={handleCreateFpWorkflow}
onRedirectComplete={(newItem) => { onRedirectComplete={(updatedItem) => {
setQueueItems((prev) => [...prev, newItem].sort((a, b) => setQueueItems((prev) => {
// If item already exists (in-place update), replace it
const exists = prev.some(i => i.id === updatedItem.id);
if (exists) {
return prev.map(i => i.id === updatedItem.id ? updatedItem : i);
}
// Otherwise it's a new item (redirect from completed), add it
return [...prev, updatedItem].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id (a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
)); );
});
}} }}
canWrite={canWrite} canWrite={canWrite}
fpSubmissions={fpSubmissionsFiltered} fpSubmissions={fpSubmissionsFiltered}
@@ -7304,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}