Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8aa7038ad
|
||
|
|
e887fa8946
|
||
|
|
d9c47ec030
|
||
|
|
4e8f4cbb10
|
||
|
|
1cc8bd5a4c
|
||
|
|
50f14c14d2
|
||
|
|
4f40850fd2
|
@@ -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.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user