Files
cve-dashboard/frontend/src/components/pages/ReportingPage.js

6559 lines
346 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
import AnomalyBanner from './AnomalyBanner';
import CveTooltip from '../CveTooltip';
import RedirectModal from '../RedirectModal';
import AtlasBadge from '../AtlasBadge';
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
import AtlasIcon from '../AtlasIcon';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2';
// Sentinel used in filter Sets to represent cells with no value (blank / —)
const EMPTY_SENTINEL = '__EMPTY__';
// ---------------------------------------------------------------------------
// Column definitions — source of truth for labels, sort behaviour, rendering
// ---------------------------------------------------------------------------
const COLUMN_DEFS = {
findingId: { label: 'Finding ID', sortable: true, filterable: false },
severity: { label: 'Severity', sortable: true, filterable: true },
title: { label: 'Title', sortable: true, filterable: true },
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
hostName: { label: 'Host', sortable: true, filterable: true },
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
dns: { label: 'DNS', sortable: true, filterable: true },
dueDate: { label: 'Due Date', sortable: true, filterable: true },
slaStatus: { label: 'SLA', sortable: true, filterable: true },
buOwnership: { label: 'BU', sortable: true, filterable: true },
workflow: { label: 'Workflow', sortable: true, filterable: true },
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
note: { label: 'Notes', sortable: false, filterable: false },
};
const DEFAULT_COLUMN_ORDER = [
{ key: 'findingId', visible: true },
{ key: 'severity', visible: true },
{ key: 'title', visible: true },
{ key: 'cves', visible: true },
{ key: 'hostName', visible: true },
{ key: 'ipAddress', visible: true },
{ key: 'dns', visible: true },
{ key: 'dueDate', visible: true },
{ key: 'slaStatus', visible: true },
{ key: 'buOwnership', visible: true },
{ key: 'workflow', visible: true },
{ key: 'lastFoundOn', visible: true },
{ key: 'note', visible: true },
];
// ---------------------------------------------------------------------------
// Persist / load column config
// ---------------------------------------------------------------------------
function loadColumnOrder() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (saved && Array.isArray(saved)) {
const savedKeys = new Set(saved.map((c) => c.key));
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
DEFAULT_COLUMN_ORDER.forEach((d) => {
if (!savedKeys.has(d.key)) merged.push({ ...d });
});
return merged;
}
} catch { /* ignore */ }
return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c }));
}
function saveColumnOrder(order) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Persist / load hidden row IDs (row visibility feature)
// ---------------------------------------------------------------------------
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
function loadHiddenRows() {
try {
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
if (saved && Array.isArray(saved)) return new Set(saved);
} catch { /* corrupted — treat as empty */ }
return new Set();
}
function saveHiddenRows(hiddenSet) {
try { localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet])); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Sort accessor by column key
// ---------------------------------------------------------------------------
function getVal(finding, key) {
switch (key) {
case 'findingId': return finding.id ?? '';
case 'severity': return finding.severity ?? 0;
case 'title': return finding.title ?? '';
case 'hostName': return finding.overrides?.hostName || finding.hostName || '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return finding.overrides?.dns || finding.dns || '';
case 'dueDate': return finding.dueDate ?? '';
case 'slaStatus': return finding.slaStatus ?? '';
case 'cves': return (finding.cves || []).length; // sort by CVE count
case 'buOwnership': return finding.buOwnership ?? '';
case 'workflow': return finding.workflow?.id ?? '';
case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? '';
default: return '';
}
}
// ---------------------------------------------------------------------------
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
// ---------------------------------------------------------------------------
function getFilterVal(finding, key) {
if (key === 'severity') return finding.vrrGroup || '';
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
if (key === 'workflow') return finding.workflow?.id || '';
return String(getVal(finding, key) ?? '');
}
// ---------------------------------------------------------------------------
// Export value accessor — plain text representation for CSV/XLSX
// ---------------------------------------------------------------------------
function getExportVal(finding, key) {
switch (key) {
case 'findingId': return finding.id ?? '';
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
case 'title': return finding.title ?? '';
case 'cves': return (finding.cves || []).join(', ');
case 'hostName': return finding.overrides?.hostName || finding.hostName || '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return finding.overrides?.dns || finding.dns || '';
case 'dueDate': return finding.dueDate ?? '';
case 'slaStatus': return finding.slaStatus ?? '';
case 'buOwnership': return finding.buOwnership ?? '';
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? '';
default: return '';
}
}
// ---------------------------------------------------------------------------
// Action coverage classification — used by chart and filter
// ---------------------------------------------------------------------------
const EXC_PATTERN = /EXC-\d+/i;
function classifyFinding(finding) {
if (finding.workflow != null) return 'fp';
if (EXC_PATTERN.test(finding.note || '')) return 'archer';
return 'pending';
}
// ---------------------------------------------------------------------------
// Style helpers
// ---------------------------------------------------------------------------
function severityColor(vrrGroup) {
switch ((vrrGroup || '').toUpperCase()) {
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
}
}
function slaColor(slaStatus) {
switch ((slaStatus || '').toUpperCase()) {
case 'OVERDUE': return '#EF4444';
case 'AT_RISK': return '#F59E0B';
case 'WITHIN_SLA': return '#10B981';
default: return '#64748B';
}
}
function dueDateColor(dueDate) {
if (!dueDate) return '#64748B';
const today = new Date();
const due = new Date(dueDate);
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return '#EF4444';
if (diffDays <= 30) return '#F59E0B';
return '#94A3B8';
}
function workflowStyle(state) {
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
switch ((state || '').toLowerCase()) {
case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state
}
}
function lifecycleStatusBadge(status) {
switch ((status || '').toLowerCase()) {
case 'submitted':
case 'resubmitted':
return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.4)', text: '#0EA5E9' };
case 'approved':
return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.4)', text: '#10B981' };
case 'rejected':
return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' };
case 'rework':
return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' };
default:
return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' };
}
}
// ---------------------------------------------------------------------------
// SVG Donut Chart — Open vs Closed findings
// ---------------------------------------------------------------------------
function polarToCartesian(cx, cy, r, angleDeg) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
// Full circle must be split into two arcs (SVG can't render a 360° arc)
if (Math.abs(endDeg - startDeg) >= 359.9) {
const mid = startDeg + 180;
return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' +
donutArcPath(cx, cy, outerR, innerR, mid, endDeg);
}
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
const s = polarToCartesian(cx, cy, outerR, startDeg);
const e = polarToCartesian(cx, cy, outerR, endDeg);
const si = polarToCartesian(cx, cy, innerR, endDeg);
const ei = polarToCartesian(cx, cy, innerR, startDeg);
return [
`M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`,
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`,
`L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`,
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`,
'Z',
].join(' ');
}
function StatusDonut({ open, closed, loading }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
</div>
);
}
const total = open + closed;
if (total === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data click Sync to load</p>
</div>
);
}
const openDeg = (open / total) * 360;
const segments = [
{ label: 'Open', count: open, color: '#0EA5E9', start: 0, end: openDeg },
{ label: 'Closed', count: closed, color: '#475569', start: openDeg, end: 360 },
].filter((s) => s.count > 0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
{/* Gap ring behind slices */}
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.label}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
{/* Center total */}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{total.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
TOTAL
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{segments.map((seg) => (
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{seg.label}
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
{seg.count.toLocaleString()}
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
({((seg.count / total) * 100).toFixed(1)}%)
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
// ---------------------------------------------------------------------------
const ACTION_DEFS = [
{ key: 'fp', label: 'FP Request', color: '#0EA5E9' },
{ key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
{ key: 'pending', label: 'Pending', color: '#EF4444' },
];
function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
const counts = useMemo(() => {
const map = { fp: 0, archer: 0, pending: 0 };
findings.forEach((f) => { map[classifyFinding(f)]++; });
return map;
}, [findings]);
const total = findings.length;
if (total === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data click Sync to load</p>
</div>
);
}
let cursor = 0;
const segments = ACTION_DEFS.map((def) => {
const count = counts[def.key];
const start = cursor;
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
if (count > 0) cursor = end;
return { ...def, count, start, end };
});
const hasActive = !!activeSegment;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.filter((s) => s.count > 0).map((seg) => {
const isActive = activeSegment === seg.key;
return (
<path
key={seg.key}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={hasActive ? (isActive ? 1 : 0.25) : 0.88}
stroke={isActive ? 'rgba(255,255,255,0.6)' : 'none'}
strokeWidth={isActive ? 2 : 0}
style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
onClick={() => onSegmentClick(isActive ? null : seg.key)}
/>
);
})}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{total.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
TOTAL
</text>
</svg>
{/* Legend — always shows all 3 categories */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => {
const isActive = activeSegment === seg.key;
const dimmed = hasActive && !isActive;
return (
<div
key={seg.key}
onClick={() => onSegmentClick(isActive ? null : seg.key)}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }}
>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
</span>
</div>
</div>
);
})}
{hasActive && (
<button
onClick={() => onSegmentClick(null)}
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
>
clear filter
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SVG Donut Chart — FP Workflow Status distribution
// ---------------------------------------------------------------------------
const FP_WORKFLOW_DEFS = [
{ key: 'Actionable', label: 'Actionable', color: '#F59E0B' },
{ key: 'Requested', label: 'Requested', color: '#0EA5E9' },
{ key: 'Reworked', label: 'Reworked', color: '#A855F7' },
{ key: 'Approved', label: 'Approved', color: '#22C55E' },
{ key: 'Rejected', label: 'Rejected', color: '#EF4444' },
{ key: 'Expired', label: 'Expired', color: '#64748B' },
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
];
function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
if (total === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No FP workflows click Sync to load</p>
</div>
);
}
let cursor = 0;
const segments = FP_WORKFLOW_DEFS.map((def) => {
const count = counts[def.key] || 0;
const start = cursor;
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
if (count > 0) cursor = end;
return { ...def, count, start, end };
}).filter(s => s.count > 0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.key}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{total.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
{centerLabel}
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => (
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({((seg.count / total) * 100).toFixed(0)}%)
</span>
</div>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Atlas Donut Charts — Coverage, Plan Type, Plan Status
// ---------------------------------------------------------------------------
const PLAN_TYPE_DEFS = [
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
];
function getStatusColor(status) {
if (status === 'active') return '#10B981';
if (status === 'expired') return '#EF4444';
if (status === 'completed') return '#0EA5E9';
return '#64748B';
}
function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
if (totalHosts === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data run Atlas Sync</p>
</div>
);
}
const segments = [
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', start: 0, end: (hostsWithPlans / totalHosts) * 360 },
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', start: (hostsWithPlans / totalHosts) * 360, end: 360 },
].filter((s) => s.count > 0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.label}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{totalHosts.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
HOSTS
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{segments.map((seg) => (
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{seg.label}
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
{seg.count.toLocaleString()}
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
({((seg.count / totalHosts) * 100).toFixed(1)}%)
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
if (totalPlans === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans run Atlas Sync</p>
</div>
);
}
let cursor = 0;
const segments = PLAN_TYPE_DEFS.map((def) => {
const count = plansByType[def.key] || 0;
const start = cursor;
const end = count > 0 ? cursor + (count / totalPlans) * 360 : cursor;
if (count > 0) cursor = end;
return { ...def, count, start, end };
}).filter(s => s.count > 0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.key}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{totalPlans.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
PLANS
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => (
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({((seg.count / totalPlans) * 100).toFixed(0)}%)
</span>
</div>
</div>
))}
</div>
</div>
);
}
function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
const SIZE = 180;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 72;
const INNER = 48;
if (totalPlans === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans run Atlas Sync</p>
</div>
);
}
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
let cursor = 0;
const segments = entries.map(([status, count]) => {
const start = cursor;
const end = cursor + (count / totalPlans) * 360;
cursor = end;
return {
key: status,
label: status.charAt(0).toUpperCase() + status.slice(1),
color: getStatusColor(status),
count,
start,
end,
};
});
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.key}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{totalPlans.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
STATUS
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{segments.map((seg) => (
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{seg.label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
{seg.count}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
({((seg.count / totalPlans) * 100).toFixed(0)}%)
</span>
</div>
</div>
))}
</div>
</div>
);
}
function SortIcon({ colKey, sort }) {
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
return sort.dir === 'asc'
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
}
// ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden
// ---------------------------------------------------------------------------
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite, suffix }) {
const effective = initialOverride ?? originalValue ?? '';
const [value, setValue] = useState(effective);
const [isOverridden, setOverridden] = useState(!!initialOverride);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const lastSaved = useRef(effective);
const inputRef = useRef(null);
// Sync when the finding updates (e.g. after a full sync)
useEffect(() => {
const eff = initialOverride ?? originalValue ?? '';
setValue(eff);
setOverridden(!!initialOverride);
lastSaved.current = eff;
}, [initialOverride, originalValue]);
useEffect(() => {
if (editing && inputRef.current) inputRef.current.focus();
}, [editing]);
const persist = useCallback(async (newVal) => {
const trimmed = newVal.trim();
if (trimmed === lastSaved.current) return;
setSaving(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field, value: trimmed }),
});
if (res.ok) {
const data = await res.json();
const cleared = data.value === null;
const displayed = cleared ? (originalValue ?? '') : trimmed;
setValue(displayed);
setOverridden(!cleared);
lastSaved.current = displayed;
} else {
setValue(lastSaved.current); // revert on error
}
} catch {
setValue(lastSaved.current);
} finally {
setSaving(false);
}
}, [findingId, field, originalValue]);
const handleBlur = () => { setEditing(false); persist(value); };
const handleKeyDown = (e) => {
if (e.key === 'Enter') { e.target.blur(); }
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
};
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
if (editing) {
return (
<td style={{ padding: '0.3rem 0.5rem' }}>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={{
width: '100%', minWidth: '120px',
background: 'rgba(14,165,233,0.08)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '0.25rem',
padding: '0.2rem 0.4rem',
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
outline: 'none',
}}
/>
</td>
);
}
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span
onClick={canWrite ? () => setEditing(true) : undefined}
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
color: isOverridden ? '#E2E8F0' : '#94A3B8',
fontFamily: 'monospace', fontSize: '0.72rem',
cursor: canWrite ? 'text' : 'default',
}}
>
{isOverridden && (
<span title="Local override active" style={{ width: '5px', height: '5px', borderRadius: '50%', background: '#F59E0B', flexShrink: 0, marginRight: '1px' }} />
)}
{value || '—'}
{saving && <Loader style={{ width: '10px', height: '10px', color: '#475569', animation: 'spin 1s linear infinite', flexShrink: 0 }} />}
{isOverridden && canWrite && !saving && (
<button
onClick={handleRevert}
title="Revert to Ivanti value"
style={{ background: 'none', border: 'none', padding: '0 1px', cursor: 'pointer', color: '#475569', lineHeight: 1, flexShrink: 0, display: 'inline-flex', alignItems: 'center' }}
>
<RotateCcw style={{ width: '10px', height: '10px' }} />
</button>
)}
</span>
{suffix}
</td>
);
}
// ---------------------------------------------------------------------------
// NoteCell — inline editable, saves on blur
// ---------------------------------------------------------------------------
function NoteCell({ findingId, initialNote }) {
const [value, setValue] = useState(initialNote || '');
const [saving, setSaving] = useState(false);
const lastSaved = useRef(initialNote || '');
useEffect(() => {
setValue(initialNote || '');
lastSaved.current = initialNote || '';
}, [initialNote]);
const save = useCallback(async () => {
if (value === lastSaved.current) return;
setSaving(true);
try {
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ note: value })
});
lastSaved.current = value;
} catch (e) {
console.error('Failed to save note:', e);
} finally {
setSaving(false);
}
}, [findingId, value]);
return (
<div style={{ position: 'relative' }}>
<input
type="text"
value={value}
maxLength={255}
onChange={(e) => setValue(e.target.value)}
onBlur={save}
placeholder="Add note…"
style={{
width: '100%', minWidth: '160px',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '4px', padding: '4px 8px',
color: '#CBD5E1', fontSize: '0.75rem',
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
}}
onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
/>
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
</div>
);
}
// ---------------------------------------------------------------------------
// ColumnManager — popover with drag-to-reorder and show/hide toggles
// ---------------------------------------------------------------------------
function ColumnManager({ columnOrder, onChange }) {
const [open, setOpen] = useState(false);
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const panelRef = useRef(null);
const btnRef = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const toggleVisible = (key) => {
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
};
const handleDragStart = (idx) => setDragIdx(idx);
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
const handleDrop = (idx) => {
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
const updated = [...columnOrder];
const [moved] = updated.splice(dragIdx, 1);
updated.splice(idx, 0, moved);
onChange(updated);
setDragIdx(null);
setOverIdx(null);
};
const visibleCount = columnOrder.filter((c) => c.visible).length;
return (
<div style={{ position: 'relative' }}>
<button
ref={btnRef}
onClick={() => setOpen((p) => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Settings2 style={{ width: '13px', height: '13px' }} />
Columns
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
</button>
{open && (
<div
ref={panelRef}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: '220px', zIndex: 100,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '0.5rem'
}}
>
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
Drag to reorder · click to toggle
</div>
{columnOrder.map((col, idx) => {
const def = COLUMN_DEFS[col.key];
const isDragging = dragIdx === idx;
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
return (
<div
key={col.key}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={() => handleDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
opacity: isDragging ? 0.4 : 1,
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
transition: 'background 0.1s'
}}
>
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
{def?.label || col.key}
</span>
<button
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
>
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// FilterDropdown — portal-based so it escapes overflow:auto clipping
// ---------------------------------------------------------------------------
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [search, setSearch] = useState('');
const panelRef = useRef(null);
const inputRef = useRef(null);
// Compute fixed position from anchor button's viewport rect
useEffect(() => {
if (!anchorEl) return;
const r = anchorEl.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorEl]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) &&
!(anchorEl && anchorEl.contains(e.target))) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Unique values from the full (unfiltered) findings list.
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
// EMPTY_SENTINEL is prepended when any finding has a blank/null cell.
const allValues = useMemo(() => {
const def = COLUMN_DEFS[colKey];
const vals = new Set();
let hasEmpty = false;
findings.forEach((f) => {
if (def?.multiValue) {
const arr = f[colKey] || [];
if (arr.length === 0) { hasEmpty = true; return; }
arr.forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
} else {
const v = getFilterVal(f, colKey).trim();
if (v) vals.add(v); else hasEmpty = true;
}
});
const sorted = [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (hasEmpty) sorted.unshift(EMPTY_SENTINEL);
return sorted;
}, [findings, colKey]);
const displayed = search.trim()
? allValues.filter((v) => v === EMPTY_SENTINEL || v.toLowerCase().includes(search.toLowerCase()))
: allValues;
const isChecked = (val) => !activeFilter || activeFilter.has(val);
const activeCount = activeFilter ? activeFilter.size : allValues.length;
const toggle = (val) => {
let next;
if (!activeFilter) {
next = new Set(allValues);
next.delete(val);
} else {
next = new Set(activeFilter);
if (next.has(val)) next.delete(val); else next.add(val);
}
// If all values selected again, remove the filter entirely
onFilterChange(next.size >= allValues.length ? null : next);
};
return ReactDOM.createPortal(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '220px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.3)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.5rem',
}}
>
{/* Search */}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search values…"
style={{
width: '100%', marginBottom: '0.375rem',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
color: '#CBD5E1', fontSize: '0.72rem',
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
}}
/>
{/* Select All / Clear */}
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={() => onFilterChange(null)}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Select All
</button>
<button
onClick={() => onFilterChange(new Set())}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Clear
</button>
</div>
{/* Value checkboxes */}
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{displayed.length === 0 ? (
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
) : displayed.map((val) => (
<label
key={val}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<input
type="checkbox"
checked={isChecked(val)}
onChange={() => toggle(val)}
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
/>
{val === EMPTY_SENTINEL
? <span style={{ fontStyle: 'italic', color: '#64748B', whiteSpace: 'nowrap' }}> empty </span>
: <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
}
</label>
))}
</div>
{/* Status footer */}
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
{activeCount} / {allValues.length} selected
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
switch (colKey) {
case 'findingId':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#475569', fontFamily: 'monospace', fontSize: '0.68rem' }}>
{finding.id || '—'}
</td>
);
case 'severity': {
const sc = severityColor(finding.vrrGroup);
return (
<td 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' }}>
{finding.severity?.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
</span>
</td>
);
}
case 'title':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
{finding.title}
</span>
</td>
);
case 'cves': {
const cves = finding.cves || [];
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}></td>;
const shown = cves.slice(0, 2);
const rest = cves.length - shown.length;
return (
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{shown.map((cve) => (
<span
key={cve}
onMouseEnter={onCveMouseEnter ? (e) => onCveMouseEnter(cve, e) : undefined}
onMouseLeave={onCveMouseLeave || undefined}
style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}
>
{cve}
</span>
))}
{rest > 0 && (
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
+{rest} more
</span>
)}
</div>
</td>
);
}
case 'hostName':
return (
<OverrideCell
findingId={finding.id}
field="hostName"
originalValue={finding.hostName}
initialOverride={finding.overrides?.hostName ?? null}
canWrite={canWrite}
suffix={
<AtlasBadge
hostId={finding.hostId}
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
onClick={onAtlasBadgeClick}
/>
}
/>
);
case 'ipAddress':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.ipAddress || '—'}
</td>
);
case 'dns':
return (
<OverrideCell
findingId={finding.id}
field="dns"
originalValue={finding.dns}
initialOverride={finding.overrides?.dns ?? null}
canWrite={canWrite}
/>
);
case 'dueDate': {
const color = dueDateColor(finding.dueDate);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
{finding.dueDate || '—'}
</td>
);
}
case 'slaStatus':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
{finding.slaStatus || '—'}
</td>
);
case 'buOwnership': {
const bu = finding.buOwnership || '';
const isSteam = bu.toUpperCase().includes('STEAM');
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
{bu ? (
<span
title={bu}
style={{
display: 'inline-block', padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: isSteam ? '#0EA5E9' : '#F59E0B',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
}}
>
{bu.replace('NTS-AEO-', '')}
</span>
) : (
<span style={{ color: '#475569' }}></span>
)}
</td>
);
}
case 'workflow': {
const wf = finding.workflow;
if (!wf || !wf.id) return <td style={{ padding: '0.45rem 0.75rem', color: '#334155' }}></td>;
const ws = workflowStyle(wf.state);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
background: ws.bg, border: `1px solid ${ws.border}`,
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: ws.text, cursor: 'default',
}}
>
{wf.id}
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{wf.state}
</span>
</span>
</td>
);
}
case 'lastFoundOn':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.lastFoundOn || '—'}
</td>
);
case 'note':
return (
<td style={{ padding: '0.45rem 0.75rem' }}>
<NoteCell findingId={finding.id} initialNote={finding.note} />
</td>
);
default:
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}></td>;
}
}
// ---------------------------------------------------------------------------
// AddToQueuePopover — portal-based popover for adding a finding to the queue
// ---------------------------------------------------------------------------
function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) {
const panelRef = useRef(null);
const inputRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!anchorRect) return;
const PANEL_W = 260;
const PANEL_H = 360; // conservative estimate (3 workflow buttons)
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
const top = spaceBelow >= PANEL_H
? anchorRect.bottom + 6
: Math.max(8, anchorRect.top - PANEL_H - 6);
const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8);
setPos({ top, left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorRect]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target)) onCancel();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onCancel]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onCancel(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onCancel]);
const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE' || queueForm.workflowType === 'DECOM';
const canSubmit = isCard || queueForm.vendor.trim().length > 0;
return ReactDOM.createPortal(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '260px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.35)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.875rem',
}}
>
{/* Header */}
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
Add to Ivanti Queue
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', marginBottom: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.id}>
{finding.id}
</div>
{/* Vendor input — hidden for CARD */}
{isCard ? (
<div style={{
marginBottom: '0.625rem', padding: '0.4rem 0.5rem',
background: 'rgba(16,185,129,0.06)',
border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
}}>
No vendor required disposition handled in CARD
</div>
) : (
<label style={{ display: 'block', marginBottom: '0.625rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Vendor / Platform
</span>
<input
ref={inputRef}
type="text"
value={queueForm.vendor}
onChange={(e) => setQueueForm((f) => ({ ...f, vendor: e.target.value }))}
placeholder="Juniper, Cisco, ADTRAN…"
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.35rem 0.5rem',
color: '#CBD5E1', fontSize: '0.78rem',
fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onAdd(); }}
/>
</label>
)}
{/* Workflow type toggle */}
<div style={{ marginBottom: '0.875rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Workflow Type
</span>
<div style={{ display: 'flex', gap: '0.375rem' }}>
{[
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
].map(({ key, col, rgb }) => {
const active = queueForm.workflowType === key;
return (
<button
key={key}
onClick={() => setQueueForm((f) => ({ ...f, workflowType: key }))}
style={{
flex: 1, padding: '0.3rem',
background: active ? `rgba(${rgb},0.15)` : 'transparent',
border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.25rem',
color: active ? col : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: 'pointer', transition: 'all 0.12s',
}}
>
{key}
</button>
);
})}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={onAdd}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.4rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.05)',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.4)' : 'rgba(14,165,233,0.1)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
Add to Queue
</button>
<button
onClick={onCancel}
style={{
padding: '0.4rem 0.625rem',
background: 'none', border: 'none',
color: '#475569', fontFamily: 'monospace', fontSize: '0.72rem',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, onDismissSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
const [selectedIds, setSelectedIds] = useState(new Set());
const [redirectItem, setRedirectItem] = useState(null);
const [redirectSuccess, setRedirectSuccess] = useState(null);
// CARD action state — tracks which item has an active action form
const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' }
const [cardFormTeam, setCardFormTeam] = useState('');
const [cardFormComment, setCardFormComment] = useState('');
const [cardFormFromTeam, setCardFormFromTeam] = useState('');
const [cardFormToTeam, setCardFormToTeam] = useState('');
const [cardActionLoading, setCardActionLoading] = useState(false);
const [cardActionError, setCardActionError] = useState(null);
// CARD Asset Search state
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
const [assetSearchTeam, setAssetSearchTeam] = useState('');
const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed');
const [assetSearchResults, setAssetSearchResults] = useState(null);
const [assetSearchLoading, setAssetSearchLoading] = useState(false);
const [assetSearchError, setAssetSearchError] = useState(null);
const [assetSearchPage, setAssetSearchPage] = useState(1);
// Drop any selected IDs that no longer exist in items
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const valid = new Set(items.map((i) => i.id));
const next = new Set([...prev].filter((id) => valid.has(id)));
return next.size === prev.size ? prev : next;
});
}, [items]);
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleDeleteSelected = () => {
onDeleteMany([...selectedIds]);
setSelectedIds(new Set());
};
// Submissions section — collapsible state (Task 6)
const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true');
const [dismissError, setDismissError] = useState(null);
const toggleSubmissionsCollapsed = () => {
setSubmissionsCollapsed(prev => {
const next = !prev;
try { localStorage.setItem('steam_submissions_collapsed', String(next)); } catch { /* ignore */ }
return next;
});
};
// Dismiss handler (Task 5)
const handleDismiss = async (e, submissionId) => {
e.stopPropagation();
setDismissError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submissionId}/dismiss`, {
method: 'PATCH',
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setDismissError(data.error || 'Failed to dismiss submission');
return;
}
if (onDismissSubmission) onDismissSubmission(submissionId);
} catch (err) {
setDismissError('Network error — could not dismiss submission');
}
};
const handleRedirectSuccess = (newItem) => {
if (onRedirectComplete) onRedirectComplete(newItem);
setRedirectItem(null);
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
setTimeout(() => setRedirectSuccess(null), 3000);
};
// CARD action handlers
const openCardAction = (itemId, type) => {
setCardAction({ itemId, type });
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
};
const closeCardAction = () => {
setCardAction(null);
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
setCardActionLoading(false);
};
const handleCardConfirmDecline = async (item, actionType) => {
if (!cardFormTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamName: cardFormTeam,
assetId: item.ip_address,
comment: cardFormComment || '',
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || `${actionType} failed.`);
setCardActionLoading(false);
return;
}
// Update local state to complete without full refresh
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
const handleCardRedirect = async (item) => {
if (!cardFormFromTeam || !cardFormToTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fromTeam: cardFormFromTeam,
toTeam: cardFormToTeam,
assetId: item.ip_address,
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || 'Redirect failed.');
setCardActionLoading(false);
return;
}
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
// CARD Asset Search handler
const handleAssetSearch = async (page = 1) => {
if (!assetSearchTeam || !assetSearchDisposition) return;
setAssetSearchLoading(true);
setAssetSearchError(null);
setAssetSearchPage(page);
try {
const res = await fetch(
`${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`,
{ credentials: 'include' }
);
const data = await res.json();
if (!res.ok) {
setAssetSearchError(data.error || 'Search failed.');
setAssetSearchLoading(false);
return;
}
setAssetSearchResults(data);
} catch (err) {
setAssetSearchError(err.message || 'Network error.');
} finally {
setAssetSearchLoading(false);
}
};
// Render a single queue item row
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
: item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }
: item.workflow_type === 'DECOM' ? { col: '#EF4444', rgb: '239,68,68' }
: { col: '#10B981', rgb: '16,185,129' };
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
: '—';
const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE' || item.workflow_type === 'DECOM';
return (
<div
key={item.id}
style={{
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
opacity: done ? 0.55 : 1,
transition: 'opacity 0.15s',
}}
>
{/* Selection checkbox — for bulk delete */}
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelect(item.id)}
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
title="Select for deletion"
/>
{/* Complete checkbox */}
<input
type="checkbox"
checked={done}
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
/>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
color: done ? '#475569' : '#CBD5E1',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={item.finding_id}>
{item.finding_id}
</div>
{isInventoryItem ? (
<>
{item.hostname && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#94A3B8',
textDecoration: done ? 'line-through' : 'none',
marginTop: '2px',
}}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '2px',
}}>
{item.ip_address}
</div>
)}
{cves.length > 0 && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#64748B',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
marginTop: '2px',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</>
) : (
<>
{cves.length > 0 && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#64748B',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
marginTop: '1px',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
{item.hostname && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#94A3B8',
textDecoration: done ? 'line-through' : 'none',
marginTop: '1px',
}}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '1px',
}}>
{item.ip_address}
</div>
)}
</>
)}
</div>
{/* Workflow type badge */}
<span style={{
flexShrink: 0,
padding: '0.1rem 0.35rem',
borderRadius: '0.2rem',
background: `rgba(${wfColor.rgb},0.12)`,
border: `1px solid rgba(${wfColor.rgb},0.3)`,
color: wfColor.col,
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
}}>
{item.workflow_type}
</span>
{/* CARD action buttons — pending CARD items only */}
{item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
<button
onClick={() => openCardAction(item.id, 'confirm')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Confirm asset'}
style={{
background: cardConfigured ? 'rgba(16,185,129,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#10B981' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Confirm
</button>
<button
onClick={() => openCardAction(item.id, 'decline')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Decline asset'}
style={{
background: cardConfigured ? 'rgba(239,68,68,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#EF4444' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Decline
</button>
<button
onClick={() => openCardAction(item.id, 'redirect')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Redirect asset'}
style={{
background: cardConfigured ? 'rgba(14,165,233,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(14,165,233,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#0EA5E9' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Redirect
</button>
</div>
)}
{/* Redirect button — completed items only */}
{canWrite && done && (
<button
onClick={() => setRedirectItem(item)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
title="Redirect to another workflow"
>
<CornerUpRight style={{ width: '13px', height: '13px' }} />
</button>
)}
{/* Delete button */}
<button
onClick={() => onDelete(item.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
title="Remove from queue"
>
<Trash2 style={{ width: '13px', height: '13px' }} />
</button>
</div>
);
};
// Render CARD action inline form below a queue item
const renderCardActionForm = (item) => {
if (!cardAction || cardAction.itemId !== item.id) return null;
const { type } = cardAction;
if (type === 'confirm' || type === 'decline') {
const accentColor = type === 'confirm' ? '#10B981' : '#EF4444';
const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68';
const canSubmit = !cardActionLoading && cardFormTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: `rgba(${accentRgb},0.04)`,
border: `1px solid rgba(${accentRgb},0.15)`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: accentColor, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
</span>
</div>
<select
value={cardFormTeam}
onChange={(e) => setCardFormTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.25)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={cardFormComment}
onChange={(e) => setCardFormComment(e.target.value)}
disabled={cardActionLoading}
placeholder="Comment (optional)"
maxLength={500}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.15)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
/>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardConfirmDecline(item, type)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? `rgba(${accentRgb},0.12)` : 'transparent',
border: `1px solid ${canSubmit ? `rgba(${accentRgb},0.35)` : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? accentColor : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Submitting…' : type === 'confirm' ? 'Confirm' : 'Decline'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
if (type === 'redirect') {
const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Redirect Asset
</span>
</div>
<select
value={cardFormFromTeam}
onChange={(e) => setCardFormFromTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">From team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={cardFormToTeam}
onChange={(e) => setCardFormToTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">To team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardRedirect(item)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Redirecting…' : 'Redirect'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
return null;
};
// Inventory items (CARD + GRANITE + DECOM) are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE' || i.workflow_type === 'DECOM');
const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD');
const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE');
const decomItems = inventoryItems.filter((i) => i.workflow_type === 'DECOM');
const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE' && i.workflow_type !== 'DECOM');
const map = {};
otherItems.forEach((item) => {
const v = item.vendor || 'Unknown';
if (!map[v]) map[v] = [];
map[v].push(item);
});
const vendorGroups = Object.keys(map).sort().map((vendor) => ({
key: vendor, label: vendor, items: map[vendor], isInventory: false,
}));
return inventoryItems.length > 0
? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, decomItems, items: inventoryItems, isInventory: true }, ...vendorGroups]
: vendorGroups;
}, [items]);
return (
<>
{/* Backdrop */}
{open && (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 9998,
}}
/>
)}
{/* Panel */}
<div
style={{
position: 'fixed', top: 0, right: 0,
height: '100vh', width: '420px',
zIndex: 9999,
display: 'flex', flexDirection: 'column',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
borderLeft: '1px solid rgba(14,165,233,0.2)',
boxShadow: '-8px 0 40px rgba(0,0,0,0.7)',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)',
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(14,165,233,0.15)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<ListTodo style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Ivanti Queue
</span>
{pendingCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '20px', height: '20px', padding: '0 5px',
background: 'rgba(14,165,233,0.2)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#0EA5E9',
}}>
{pendingCount}
</span>
)}
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 1.25rem' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155' }}>
No items in queue.<br />
<span style={{ fontSize: '0.68rem', color: '#1E293B', marginTop: '0.5rem', display: 'block' }}>
Check a row in the findings table to add it.
</span>
</div>
) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems, decomItems }) => (
<div key={key} style={{ marginBottom: '1.25rem' }}>
{/* Group header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isInventory ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{groupItems.length}
</span>
</div>
{/* Items — Inventory section renders CARD then GRANITE then DECOM with optional sub-dividers */}
{isInventory ? (
<>
{cardItems.map((item) => (
<React.Fragment key={item.id}>
{renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })}
{renderCardActionForm(item)}
</React.Fragment>
))}
{cardItems.length > 0 && graniteItems.length > 0 && (
<div style={{
height: '1px',
background: 'rgba(161,136,127,0.18)',
margin: '0.5rem 0.625rem',
}} />
)}
{graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
{(cardItems.length > 0 || graniteItems.length > 0) && decomItems.length > 0 && (
<div style={{
height: '1px',
background: 'rgba(239,68,68,0.18)',
margin: '0.5rem 0.625rem',
}} />
)}
{decomItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
</>
) : (
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
)}
</div>
))}
</div>
{/* CARD Asset Search section */}
{cardConfigured && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div
onClick={() => setAssetSearchOpen(!assetSearchOpen)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(14,165,233,0.2)',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Database style={{ width: '12px', height: '12px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
CARD Asset Search
</span>
</div>
{assetSearchOpen
? <ChevronUp style={{ width: '14px', height: '14px', color: '#475569' }} />
: <ChevronDown style={{ width: '14px', height: '14px', color: '#475569' }} />
}
</div>
{assetSearchOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<select
value={assetSearchTeam}
onChange={(e) => { setAssetSearchTeam(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={assetSearchDisposition}
onChange={(e) => { setAssetSearchDisposition(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="confirmed">Confirmed</option>
<option value="unconfirmed">Unconfirmed</option>
<option value="declined">Declined</option>
<option value="candidate">Candidate</option>
</select>
<button
onClick={() => handleAssetSearch(1)}
disabled={!assetSearchTeam || assetSearchLoading}
style={{
padding: '0.35rem',
background: assetSearchTeam ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${assetSearchTeam ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: assetSearchTeam ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
cursor: assetSearchTeam ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
}}
>
{assetSearchLoading
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite' }} />
: <Search style={{ width: '12px', height: '12px' }} />
}
{assetSearchLoading ? 'Searching…' : 'Search'}
</button>
{/* Error */}
{assetSearchError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{assetSearchError}</span>
</div>
)}
{/* Results */}
{assetSearchResults && (
<div>
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.25rem',
}}>
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
</div>
{/* Results table */}
{Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
<div style={{
maxHeight: '200px', overflowY: 'auto',
border: '1px solid rgba(14,165,233,0.12)',
borderRadius: '0.25rem',
}}>
<table style={{
width: '100%', borderCollapse: 'collapse',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.62rem',
}}>
<thead>
<tr style={{ background: 'rgba(14,165,233,0.06)' }}>
<th style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
Asset ID
</th>
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => (
<th key={k} style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
{k.replace(/_/g, ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{assetSearchResults.assets.map((asset, idx) => {
const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3);
return (
<tr key={asset.asset_id || asset._id || idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '0.25rem 0.5rem', color: '#CBD5E1', fontWeight: '600' }}>
{asset.asset_id || asset._id || '—'}
</td>
{extraKeys.map(k => (
<td key={k} style={{ padding: '0.25rem 0.5rem', color: '#94A3B8' }}>
{typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', padding: '0.5rem 0' }}>
No assets found.
</div>
)}
{/* Pagination */}
{assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
marginTop: '0.375rem',
}}>
<button
onClick={() => handleAssetSearch(assetSearchPage - 1)}
disabled={assetSearchPage <= 1 || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage > 1 ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage > 1 ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage > 1 ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage > 1 ? 'pointer' : 'not-allowed',
}}
>
Prev
</button>
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B' }}>
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
</span>
<button
onClick={() => handleAssetSearch(assetSearchPage + 1)}
disabled={assetSearchPage >= Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'pointer' : 'not-allowed',
}}
>
Next
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Submissions section */}
{fpSubmissions && fpSubmissions.length > 0 && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div
onClick={toggleSubmissionsCollapsed}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
cursor: 'pointer', userSelect: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{submissionsCollapsed
? <ChevronUp style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
: <ChevronDown style={{ width: '12px', height: '12px', color: '#F59E0B' }} />
}
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Submissions
</span>
</div>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{fpSubmissions.length}
</span>
</div>
{dismissError && (
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#EF4444', marginBottom: '0.375rem', padding: '0.25rem 0.5rem', background: 'rgba(239,68,68,0.08)', borderRadius: '0.25rem' }}>
{dismissError}
</div>
)}
{!submissionsCollapsed && fpSubmissions.map((sub) => {
const lsBadge = lifecycleStatusBadge(sub.lifecycle_status);
const findingCount = (() => {
try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; }
})();
const clickable = canWrite && onEditSubmission;
const showDismiss = sub.lifecycle_status === 'rejected' && !sub.dismissed_at;
return (
<div
key={sub.id}
onClick={clickable ? () => onEditSubmission(sub) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.45rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(245,158,11,0.04)',
border: '1px solid rgba(245,158,11,0.1)',
cursor: clickable ? 'pointer' : 'default',
transition: 'all 0.15s',
}}
onMouseEnter={clickable ? (e) => {
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.3)';
e.currentTarget.style.background = 'rgba(245,158,11,0.08)';
} : undefined}
onMouseLeave={clickable ? (e) => {
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.1)';
e.currentTarget.style.background = 'rgba(245,158,11,0.04)';
} : undefined}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
color: '#CBD5E1',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={sub.workflow_name}>
{sub.workflow_name || `Batch ${sub.ivanti_workflow_batch_id}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '2px' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>
#{sub.ivanti_workflow_batch_id}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
{findingCount} finding{findingCount !== 1 ? 's' : ''}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{sub.created_at ? new Date(sub.created_at).toLocaleDateString() : ''}
</span>
</div>
</div>
<span style={{
flexShrink: 0,
padding: '0.1rem 0.35rem',
borderRadius: '0.2rem',
background: lsBadge.bg,
border: `1px solid ${lsBadge.border}`,
color: lsBadge.text,
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{sub.lifecycle_status || 'submitted'}
</span>
{showDismiss && (
<button
onClick={(e) => handleDismiss(e, sub.id)}
title="Dismiss rejected submission"
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '20px', height: '20px',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
borderRadius: '0.2rem',
cursor: 'pointer',
padding: 0,
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239,68,68,0.2)';
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239,68,68,0.08)';
e.currentTarget.style.borderColor = 'rgba(239,68,68,0.2)';
}}
>
<X style={{ width: '12px', height: '12px', color: '#EF4444' }} />
</button>
)}
</div>
);
})}
</div>
)}
{/* Footer */}
<div style={{
padding: '0.75rem 1.25rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
display: 'flex', gap: '0.5rem',
}}>
{/* Create FP Workflow — visible for editor/admin only */}
{canWrite && (() => {
const fpEnabled = isCreateFpButtonEnabled(items, selectedIds);
return (
<button
onClick={() => onCreateFpWorkflow([...selectedIds])}
disabled={!fpEnabled}
title={!fpEnabled ? 'Select pending FP items to create a workflow' : ''}
style={{
flex: 1, padding: '0.45rem',
background: fpEnabled ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${fpEnabled ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: fpEnabled ? '#F59E0B' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: fpEnabled ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Create FP Workflow
</button>
);
})()}
{/* Delete selected — only shown when items are selected */}
{selectedIds.size > 0 && (
<button
onClick={handleDeleteSelected}
style={{
flex: 1, padding: '0.45rem',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)',
borderRadius: '0.375rem',
color: '#EF4444',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Delete ({selectedIds.size})
</button>
)}
<button
onClick={onClearCompleted}
disabled={completedCount === 0}
style={{
flex: 1, padding: '0.45rem',
background: completedCount > 0 ? 'rgba(16,185,129,0.08)' : 'transparent',
border: `1px solid ${completedCount > 0 ? 'rgba(16,185,129,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: completedCount > 0 ? '#10B981' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: completedCount > 0 ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Clear Completed {completedCount > 0 ? `(${completedCount})` : ''}
</button>
</div>
</div>
{/* Redirect success notification */}
{redirectSuccess && (
<div style={{
position: 'fixed', top: '1rem', right: '440px',
zIndex: 10001,
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 1rem',
background: 'rgba(16, 185, 129, 0.15)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '0.375rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.75rem', fontWeight: '600',
color: '#10B981',
}}>
<Check style={{ width: '14px', height: '14px' }} />
{redirectSuccess}
</div>
)}
{/* Redirect modal */}
{redirectItem && (
<RedirectModal
item={redirectItem}
onClose={() => setRedirectItem(null)}
onRedirect={handleRedirectSuccess}
/>
)}
</>
);
}
// ---------------------------------------------------------------------------
// FP Workflow helpers (pure functions, exported for testing)
// ---------------------------------------------------------------------------
function isCreateFpButtonEnabled(items, selectedIds) {
return items.some(item =>
selectedIds.has(item.id) &&
item.workflow_type === 'FP' &&
item.status === 'pending'
);
}
function filterFpItems(items) {
return items.filter(item => item.workflow_type === 'FP');
}
// ---------------------------------------------------------------------------
// FpWorkflowModal — submit FP workflows to Ivanti API
// ---------------------------------------------------------------------------
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
// ---------------------------------------------------------------------------
// AttachmentSourcePicker — shared component for local + library attachments
// ---------------------------------------------------------------------------
function AttachmentSourcePicker({ files, onFilesChange, libraryDocs, onLibraryDocsChange, disabled }) {
const [mode, setMode] = useState('local');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState(null);
const [fileErrors, setFileErrors] = useState(null);
const fileInputRef = useRef(null);
const dropRef = useRef(null);
const debounceRef = useRef(null);
// Format file size helper
const formatSize = (bytes) => {
const n = Number(bytes);
if (isNaN(n) || n < 0) return '0 B';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
};
// File validation
const isAllowedExtension = (filename) => {
const ext = '.' + filename.split('.').pop().toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
};
const addFiles = (newFiles) => {
if (disabled) return;
const errors = [];
const valid = [];
Array.from(newFiles).forEach(f => {
if (!isAllowedExtension(f.name)) {
errors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
} else if (f.size > MAX_FILE_SIZE) {
errors.push(`"${f.name}" — exceeds 10 MB limit`);
} else {
valid.push(f);
}
});
if (errors.length) {
setFileErrors(errors.join('; '));
} else {
setFileErrors(null);
}
if (valid.length) onFilesChange([...files, ...valid]);
};
const removeFile = (idx) => {
if (disabled) return;
onFilesChange(files.filter((_, i) => i !== idx));
setFileErrors(null);
};
const removeLibraryDoc = (docId) => {
if (disabled) return;
onLibraryDocsChange(libraryDocs.filter(d => d.id !== docId));
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
// Library search with debounce
useEffect(() => {
if (mode !== 'library') return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSearching(true);
setSearchError(null);
try {
const url = searchQuery.trim()
? `${API_BASE}/ivanti/fp-workflow/documents/search?q=${encodeURIComponent(searchQuery.trim())}`
: `${API_BASE}/ivanti/fp-workflow/documents/search`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`Search failed (${res.status})`);
const data = await res.json();
setSearchResults(data);
} catch (err) {
setSearchError(err.message || 'Failed to search documents');
setSearchResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchQuery, mode]);
const selectLibraryDoc = (doc) => {
if (disabled) return;
if (libraryDocs.some(d => d.id === doc.id)) return;
onLibraryDocsChange([...libraryDocs, {
id: doc.id,
cve_id: doc.cve_id,
vendor: doc.vendor,
name: doc.name,
file_size: doc.file_size,
mime_type: doc.mime_type,
}]);
};
const selectedIds = new Set(libraryDocs.map(d => d.id));
// ---- Styles ----
const tabBtnStyle = (active) => ({
flex: 1,
padding: '0.45rem 0.5rem',
background: 'none',
border: 'none',
borderBottom: active ? '2px solid #0EA5E9' : '2px solid transparent',
color: active ? '#0EA5E9' : '#475569',
fontFamily: 'monospace',
fontSize: '0.72rem',
fontWeight: '600',
cursor: disabled ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em',
transition: 'all 0.12s',
});
const dropZoneStyle = {
border: '1px dashed rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
padding: '1rem',
textAlign: 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
background: 'rgba(14,165,233,0.03)',
transition: 'border-color 0.15s',
};
const searchInputStyle = {
width: '100%',
boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem',
padding: '0.45rem 0.6rem 0.45rem 2rem',
color: '#CBD5E1',
fontSize: '0.78rem',
fontFamily: 'monospace',
outline: 'none',
};
const resultItemStyle = (isSelected) => ({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.5rem',
borderBottom: '1px solid rgba(255,255,255,0.03)',
cursor: disabled || isSelected ? 'default' : 'pointer',
opacity: isSelected ? 0.5 : 1,
background: isSelected ? 'rgba(14,165,233,0.04)' : 'transparent',
transition: 'background 0.1s',
});
const badgeStyle = (type) => ({
display: 'inline-block',
padding: '0.1rem 0.3rem',
borderRadius: '0.15rem',
fontFamily: 'monospace',
fontSize: '0.58rem',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.04em',
...(type === 'local'
? { background: 'rgba(14,165,233,0.15)', color: '#0EA5E9', border: '1px solid rgba(14,165,233,0.3)' }
: { background: 'rgba(245,158,11,0.15)', color: '#F59E0B', border: '1px solid rgba(245,158,11,0.3)' }
),
});
const totalAttachments = files.length + libraryDocs.length;
return (
<div>
{/* Mode toggle tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', marginBottom: '0.625rem' }}>
<button
style={tabBtnStyle(mode === 'local')}
onClick={() => !disabled && setMode('local')}
disabled={disabled}
>
Local Upload
</button>
<button
style={tabBtnStyle(mode === 'library')}
onClick={() => !disabled && setMode('library')}
disabled={disabled}
>
Library
</button>
</div>
{/* Local Upload mode */}
{mode === 'local' && (
<div>
<div
ref={dropRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => !disabled && fileInputRef.current?.click()}
style={dropZoneStyle}
>
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
Drop files here or click to browse
</div>
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
accept={ALLOWED_EXTENSIONS.join(',')}
disabled={disabled}
/>
{fileErrors && (
<div style={{ fontSize: '0.68rem', color: '#EF4444', marginTop: '0.3rem' }}>{fileErrors}</div>
)}
</div>
)}
{/* Library mode */}
{mode === 'library' && (
<div>
{/* Search input */}
<div style={{ position: 'relative', marginBottom: '0.5rem' }}>
<Search size={14} style={{ position: 'absolute', left: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search documents by name, CVE, or vendor..."
disabled={disabled}
style={searchInputStyle}
/>
{searching && (
<Loader size={14} style={{ position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
)}
</div>
{/* Search results */}
<div style={{
maxHeight: '200px',
overflowY: 'auto',
border: '1px solid rgba(14,165,233,0.1)',
borderRadius: '0.25rem',
background: 'rgba(15,23,42,0.5)',
}}>
{searchError && (
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#EF4444', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
<AlertCircle size={13} />
{searchError}
</div>
)}
{!searchError && !searching && searchResults.length === 0 && (
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#475569' }}>
No documents found
</div>
)}
{!searchError && searching && searchResults.length === 0 && (
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#64748B', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
<Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />
Searching...
</div>
)}
{!searchError && searchResults.map(doc => {
const isSelected = selectedIds.has(doc.id);
return (
<div
key={doc.id}
style={resultItemStyle(isSelected)}
onClick={() => !isSelected && selectLibraryDoc(doc)}
>
{isSelected ? (
<Check size={13} style={{ color: '#10B981', flexShrink: 0 }} />
) : (
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.72rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
{doc.name}
</div>
<div style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem', marginTop: '0.1rem' }}>
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
{doc.vendor && <span>{doc.vendor}</span>}
<span>{formatSize(doc.file_size)}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Unified attachment list */}
{totalAttachments > 0 && (
<div style={{ marginTop: '0.625rem' }}>
<div style={{
fontSize: '0.68rem',
fontWeight: '600',
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '0.35rem',
fontFamily: 'monospace',
}}>
Attachments ({totalAttachments})
</div>
{/* Local files */}
{files.map((f, i) => (
<div key={`local-${i}`} style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.3rem 0.25rem',
borderBottom: '1px solid rgba(255,255,255,0.03)',
}}>
<span style={badgeStyle('local')}>LOCAL</span>
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
<span style={{
flex: 1,
fontSize: '0.72rem',
color: '#CBD5E1',
fontFamily: 'monospace',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{f.name}
</span>
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
{formatSize(f.size)}
</span>
<button
onClick={() => removeFile(i)}
disabled={disabled}
style={{
background: 'none',
border: 'none',
color: '#64748B',
cursor: disabled ? 'not-allowed' : 'pointer',
padding: '0.15rem',
lineHeight: 1,
}}
>
<Trash2 size={12} />
</button>
</div>
))}
{/* Library docs */}
{libraryDocs.map(doc => (
<div key={`lib-${doc.id}`} style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.3rem 0.25rem',
borderBottom: '1px solid rgba(255,255,255,0.03)',
}}>
<span style={badgeStyle('library')}>LIBRARY</span>
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.72rem',
color: '#CBD5E1',
fontFamily: 'monospace',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{doc.name}
</div>
<div style={{ fontSize: '0.6rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem' }}>
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
{doc.vendor && <span>{doc.vendor}</span>}
</div>
</div>
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
{formatSize(doc.file_size)}
</span>
<button
onClick={() => removeLibraryDoc(doc.id)}
disabled={disabled}
style={{
background: 'none',
border: 'none',
color: '#64748B',
cursor: disabled ? 'not-allowed' : 'pointer',
padding: '0.15rem',
lineHeight: 1,
}}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
);
}
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
const [name, setName] = useState('');
const [reason, setReason] = useState('');
const [description, setDescription] = useState('');
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('Authorized');
const [files, setFiles] = useState([]);
const [libraryDocs, setLibraryDocs] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
// Reset form when modal opens
useEffect(() => {
if (open) {
setName('');
setReason('');
setDescription('');
setExpirationDate('');
setScopeOverride('Authorized');
setFiles([]);
setLibraryDocs([]);
setSubmitting(false);
setProgress({ step: '', current: 0, total: 0 });
setErrors({});
setResult(null);
}
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === 'Escape' && !submitting) onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, submitting, onClose]);
const validate = () => {
const errs = {};
if (!name.trim()) errs.name = 'Workflow name is required';
else if (name.trim().length > 255) errs.name = 'Name must be 255 characters or fewer';
if (!reason.trim()) errs.reason = 'Reason is required';
if (description.length > 2000) errs.description = 'Description must be 2000 characters or fewer';
if (!expirationDate) errs.expirationDate = 'Expiration date is required';
else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const exp = new Date(expirationDate + 'T00:00:00');
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
else {
const maxDate = new Date(today);
maxDate.setDate(maxDate.getDate() + 120);
if (exp > maxDate) errs.expirationDate = 'Expiration date cannot be more than 120 days from today';
}
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
setProgress({ step: 'Creating workflow...', current: 0, total: 0 });
setResult(null);
try {
const formData = new FormData();
formData.append('name', name.trim());
formData.append('reason', reason.trim());
if (description.trim()) formData.append('description', description.trim());
formData.append('expirationDate', expirationDate);
formData.append('scopeOverride', scopeOverride);
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
files.forEach(f => formData.append('attachments', f));
if (libraryDocs.length > 0) {
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
}
const totalAttachments = files.length + libraryDocs.length;
if (totalAttachments > 0) {
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: totalAttachments });
}
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (res.ok && data.success) {
setResult({
success: true,
workflowBatchId: data.workflowBatchId,
generatedId: data.generatedId,
attachmentResults: data.attachmentResults || [],
status: data.status || 'success',
});
onSuccess();
} else {
let errorMsg = data.error || 'Workflow creation failed';
if (res.status === 401) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.';
else if (res.status === 429) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.';
setResult({
success: false,
error: errorMsg,
workflowBatchId: data.workflowBatchId || null,
generatedId: data.generatedId || null,
attachmentResults: data.attachmentResults || [],
status: data.status || 'failed',
});
}
} catch (err) {
setResult({
success: false,
error: err.message || 'Network error — could not reach the server',
status: 'failed',
});
} finally {
setSubmitting(false);
}
};
if (!open) return null;
// ---- Styles ----
const overlayStyle = {
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const modalStyle = {
width: '640px', maxHeight: '90vh', overflow: 'auto',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.75rem',
boxShadow: '0 12px 48px rgba(0,0,0,0.8)',
fontFamily: 'monospace',
};
const headerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
};
const sectionStyle = {
padding: '0.875rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
};
const labelStyle = {
display: 'block', fontSize: '0.68rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.35rem',
};
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.45rem 0.6rem',
color: '#CBD5E1', fontSize: '0.82rem', fontFamily: 'monospace',
outline: 'none',
};
const inputErrorStyle = { ...inputStyle, borderColor: '#EF4444' };
const textareaStyle = { ...inputStyle, minHeight: '60px', resize: 'vertical' };
const textareaErrorStyle = { ...textareaStyle, borderColor: '#EF4444' };
const errorTextStyle = { fontSize: '0.68rem', color: '#EF4444', marginTop: '0.2rem' };
const footerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.625rem',
padding: '0.875rem 1.25rem',
};
// ---- Result views ----
if (result) {
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: result.success ? '#10B981' : '#EF4444' }}>
{result.success ? 'Workflow Created' : 'Submission Failed'}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
{result.success ? (
<>
<div style={{ marginBottom: '1rem' }}>
<Check size={36} style={{ color: '#10B981' }} />
</div>
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
{result.generatedId || `Batch #${result.workflowBatchId}`}
</div>
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
</div>
{result.attachmentResults.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachments</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span style={{
display: 'inline-block',
fontSize: '0.6rem',
fontWeight: '600',
padding: '0.1rem 0.3rem',
borderRadius: '0.2rem',
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
textTransform: 'uppercase',
letterSpacing: '0.04em',
flexShrink: 0,
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
</div>
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
{result.error}
</div>
{result.generatedId && (
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
Workflow was created: {result.generatedId}
</div>
)}
{result.attachmentResults?.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachment Results</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span style={{
display: 'inline-block',
fontSize: '0.6rem',
fontWeight: '600',
padding: '0.1rem 0.3rem',
borderRadius: '0.2rem',
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
textTransform: 'uppercase',
letterSpacing: '0.04em',
flexShrink: 0,
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
)}
</div>
<div style={footerStyle}>
{!result.success && (
<button
onClick={() => setResult(null)}
style={{
padding: '0.45rem 1rem',
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Retry
</button>
)}
<button
onClick={onClose}
style={{
padding: '0.45rem 1rem',
background: result.success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${result.success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.375rem',
color: result.success ? '#10B981' : '#94A3B8',
fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Done
</button>
</div>
</div>
</div>,
document.body
);
}
// ---- Form view ----
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: '#F59E0B' }}>
Create FP Workflow
</span>
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
{/* Selected findings summary */}
<div style={sectionStyle}>
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
<div style={{ maxHeight: '120px', overflow: 'auto' }}>
{selectedItems.map((item, i) => (
<div key={item.id || i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.3rem' }}>
<span style={{ color: '#F59E0B', fontWeight: '600', flexShrink: 0 }}>{item.finding_id}</span>
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
{item.cves_json && (() => {
try {
const cves = JSON.parse(item.cves_json);
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
} catch { return null; }
})()}
</div>
))}
</div>
</div>
{/* Form fields */}
<div style={sectionStyle}>
{/* Name */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="FP — CVE-2024-XXXX — Vendor"
disabled={submitting}
maxLength={255}
style={errors.name ? inputErrorStyle : inputStyle}
/>
{errors.name && <div style={errorTextStyle}>{errors.name}</div>}
</label>
{/* Reason */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Explain why these findings are false positives..."
disabled={submitting}
style={errors.reason ? textareaErrorStyle : textareaStyle}
/>
{errors.reason && <div style={errorTextStyle}>{errors.reason}</div>}
</label>
{/* Description */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Description (optional)</span>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Additional context or details..."
disabled={submitting}
maxLength={2000}
style={errors.description ? textareaErrorStyle : textareaStyle}
/>
{errors.description && <div style={errorTextStyle}>{errors.description}</div>}
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
</label>
{/* Expiration date */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="date"
value={expirationDate}
onChange={e => setExpirationDate(e.target.value)}
min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()}
max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()}
disabled={submitting}
style={errors.expirationDate ? inputErrorStyle : inputStyle}
/>
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
</label>
{/* Scope override toggle */}
<div style={{ marginBottom: '0.25rem' }}>
<span style={labelStyle}>Scope Override Authorization</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{['Authorized', 'None'].map(val => {
const active = scopeOverride === val;
return (
<button
key={val}
onClick={() => setScopeOverride(val)}
disabled={submitting}
style={{
flex: 1, padding: '0.35rem',
background: active ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)'}`,
borderRadius: '0.25rem',
color: active ? '#F59E0B' : '#475569',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
transition: 'all 0.12s',
}}
>
{val}
</button>
);
})}
</div>
</div>
</div>
{/* Attachments */}
<div style={sectionStyle}>
<div style={labelStyle}>Attachments</div>
<AttachmentSourcePicker
files={files}
onFilesChange={setFiles}
libraryDocs={libraryDocs}
onLibraryDocsChange={setLibraryDocs}
disabled={submitting}
/>
</div>
{/* Footer */}
<div style={footerStyle}>
{submitting && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: '#F59E0B' }}>
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
<span>{progress.step}</span>
</div>
)}
<button
onClick={() => { if (!submitting) onClose(); }}
disabled={submitting}
style={{
padding: '0.45rem 1rem',
background: 'none',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.375rem',
color: '#64748B', fontSize: '0.78rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{
padding: '0.45rem 1.25rem',
background: submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem',
color: submitting ? '#92700C' : '#F59E0B',
fontSize: '0.78rem', fontWeight: '700',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// FpEditModal — edit existing FP submissions (tabbed modal)
// ---------------------------------------------------------------------------
function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
const [activeTab, setActiveTab] = useState('details');
const [name, setName] = useState('');
const [reason, setReason] = useState('');
const [description, setDescription] = useState('');
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('');
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const [files, setFiles] = useState([]);
const [libraryDocs, setLibraryDocs] = useState([]);
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
const [statusValue, setStatusValue] = useState('');
// Reset form when submission changes
useEffect(() => {
if (submission) {
setName(submission.workflow_name || '');
setReason(submission.reason || '');
setDescription(submission.description || '');
setExpirationDate(submission.expiration_date || '');
setScopeOverride(submission.scope_override || '');
setStatusValue(submission.lifecycle_status || 'submitted');
setActiveTab('details');
setErrors({});
setResult(null);
setFiles([]);
setLibraryDocs([]);
setAdditionalFindingIds(new Set());
}
}, [submission]);
if (!open || !submission) return null;
const isApproved = (submission.lifecycle_status || '').toLowerCase() === 'approved';
const currentFindings = (() => {
try { return JSON.parse(submission.finding_ids_json || '[]'); } catch { return []; }
})();
const existingAttachments = (() => {
try { return JSON.parse(submission.attachment_results_json || '[]'); } catch { return []; }
})();
const history = submission.history || [];
const pendingFpQueue = (queueItems || []).filter(i =>
i.workflow_type === 'FP' && i.status === 'pending' && !currentFindings.includes(String(i.finding_id))
);
const handleSaveDetails = async () => {
setSaving(true); setErrors({}); setResult(null);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, reason, description, expirationDate, scopeOverride }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: 'Details saved successfully.' });
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to save details.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error saving details.' });
} finally { setSaving(false); }
};
const handleAddFindings = async () => {
if (additionalFindingIds.size === 0) return;
setSaving(true); setResult(null);
const selectedItems = pendingFpQueue.filter(i => additionalFindingIds.has(i.id));
const findingIds = selectedItems.map(i => String(i.finding_id));
const queueItemIds = selectedItems.map(i => i.id);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/findings`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ findingIds, queueItemIds }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: `Added ${findingIds.length} finding(s).` });
setAdditionalFindingIds(new Set());
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to add findings.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error adding findings.' });
} finally { setSaving(false); }
};
const handleUploadAttachments = async () => {
if (files.length === 0 && libraryDocs.length === 0) return;
setSaving(true); setResult(null);
const formData = new FormData();
files.forEach(f => formData.append('attachments', f));
if (libraryDocs.length > 0) {
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
}
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
method: 'POST', credentials: 'include', body: formData,
});
const data = await res.json();
if (res.ok) {
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
setFiles([]);
setLibraryDocs([]);
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error uploading attachments.' });
} finally { setSaving(false); }
};
const handleStatusChange = async (newStatus) => {
setSaving(true); setResult(null);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/status`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lifecycle_status: newStatus }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: `Status changed to ${newStatus}.` });
setStatusValue(newStatus);
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to change status.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error changing status.' });
} finally { setSaving(false); }
};
const lsBadge = lifecycleStatusBadge(statusValue);
const tabs = ['details', 'findings', 'attachments', 'history'];
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: isApproved ? 'rgba(100,116,139,0.06)' : 'rgba(14,165,233,0.05)',
border: `1px solid ${isApproved ? 'rgba(100,116,139,0.15)' : 'rgba(14,165,233,0.2)'}`,
borderRadius: '0.25rem', padding: '0.4rem 0.5rem',
color: isApproved ? '#64748B' : '#CBD5E1',
fontSize: '0.78rem', fontFamily: 'monospace', outline: 'none',
};
const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' };
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.5rem',
boxShadow: '0 20px 60px rgba(0,0,0,0.8)',
}}>
{/* Header */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(14,165,233,0.15)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Edit3 style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>
Edit FP Workflow
</span>
<span style={{
padding: '0.1rem 0.4rem', borderRadius: '0.2rem',
background: lsBadge.bg, border: `1px solid ${lsBadge.border}`,
color: lsBadge.text, fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
textTransform: 'uppercase',
}}>
{statusValue}
</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B', marginTop: '0.25rem' }}>
{submission.workflow_name || `Batch #${submission.ivanti_workflow_batch_id}`}
</div>
{isApproved && (
<div style={{ marginTop: '0.5rem', padding: '0.35rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981' }}>
This submission is finalized and cannot be edited.
</div>
)}
</div>
{/* Tab bar */}
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
{tabs.map(tab => (
<button key={tab} onClick={() => { setActiveTab(tab); setResult(null); }} style={{
flex: 1, padding: '0.5rem', background: 'none',
border: 'none', borderBottom: activeTab === tab ? '2px solid #0EA5E9' : '2px solid transparent',
color: activeTab === tab ? '#0EA5E9' : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}>
{tab}
</button>
))}
</div>
{/* Status change row */}
{!isApproved && (
<div style={{ padding: '0.5rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B' }}>Status:</span>
<select
value={statusValue}
onChange={(e) => handleStatusChange(e.target.value)}
disabled={saving}
style={{
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.25rem 0.4rem',
color: '#CBD5E1', fontSize: '0.72rem', fontFamily: 'monospace', outline: 'none',
cursor: saving ? 'not-allowed' : 'pointer',
}}
>
{['submitted', 'approved', 'rejected', 'rework', 'resubmitted'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
)}
{/* Result banner */}
{result && (
<div style={{
margin: '0.5rem 1.25rem 0', padding: '0.35rem 0.5rem', borderRadius: '0.25rem',
background: result.type === 'success' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)',
border: `1px solid ${result.type === 'success' ? 'rgba(16,185,129,0.3)' : 'rgba(239,68,68,0.3)'}`,
fontFamily: 'monospace', fontSize: '0.72rem',
color: result.type === 'success' ? '#10B981' : '#EF4444',
display: 'flex', alignItems: 'center', gap: '0.375rem',
}}>
{result.type === 'success' ? <Check style={{ width: '12px', height: '12px' }} /> : <AlertCircle style={{ width: '12px', height: '12px' }} />}
{result.message}
</div>
)}
{/* Tab content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem 1.25rem' }}>
{/* Details tab */}
{activeTab === 'details' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Workflow Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} disabled={isApproved} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Reason</label>
<select value={reason} onChange={(e) => setReason(e.target.value)} disabled={isApproved} style={inputStyle}>
<option value="">Select reason</option>
<option value="Scanner false positive">Scanner false positive</option>
<option value="Compensating control">Compensating control</option>
<option value="Risk accepted">Risk accepted</option>
<option value="Not applicable">Not applicable</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label style={labelStyle}>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} disabled={isApproved} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Expiration Date</label>
<input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} disabled={isApproved} min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()} max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()} style={inputStyle} />
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Scope Override</label>
<select value={scopeOverride} onChange={(e) => setScopeOverride(e.target.value)} disabled={isApproved} style={inputStyle}>
<option value="">Default</option>
<option value="Authorized">Authorized</option>
<option value="Unauthorized">Unauthorized</option>
</select>
</div>
</div>
{!isApproved && (
<button onClick={handleSaveDetails} disabled={saving} style={{
alignSelf: 'flex-end', padding: '0.4rem 1rem',
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem', color: saving ? '#92700C' : '#F59E0B',
fontSize: '0.75rem', fontWeight: '700', cursor: saving ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{saving ? 'Saving…' : 'Save Details'}
</button>
)}
</div>
)}
{/* Findings tab */}
{activeTab === 'findings' && (
<div>
<div style={{ marginBottom: '0.75rem' }}>
<span style={labelStyle}>Current Findings ({currentFindings.length})</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{currentFindings.length === 0 ? (
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No findings mapped.</span>
) : currentFindings.map(fid => (
<span key={fid} style={{
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)',
fontFamily: 'monospace', fontSize: '0.65rem', color: '#0EA5E9',
}}>
{fid}
</span>
))}
</div>
</div>
{!isApproved && pendingFpQueue.length > 0 && (
<div>
<span style={labelStyle}>Add Pending FP Queue Items</span>
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '0.25rem' }}>
{pendingFpQueue.map(item => (
<label key={item.id} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.35rem 0.5rem', marginBottom: '0.15rem',
borderRadius: '0.25rem',
background: additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.08)' : 'transparent',
border: `1px solid ${additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.2)' : 'rgba(255,255,255,0.04)'}`,
cursor: 'pointer',
}}>
<input type="checkbox" checked={additionalFindingIds.has(item.id)}
onChange={() => setAdditionalFindingIds(prev => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}
style={{ accentColor: '#F59E0B', width: '13px', height: '13px' }}
/>
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1' }}>{item.finding_id}</span>
{item.hostname && <span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>{item.hostname}</span>}
</label>
))}
</div>
<button onClick={handleAddFindings} disabled={saving || additionalFindingIds.size === 0} style={{
marginTop: '0.5rem', padding: '0.4rem 1rem',
background: additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.15)' : 'transparent',
border: `1px solid ${additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: additionalFindingIds.size > 0 ? '#F59E0B' : '#334155',
fontSize: '0.75rem', fontWeight: '700', cursor: additionalFindingIds.size > 0 ? 'pointer' : 'not-allowed',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{saving ? 'Adding…' : `Add ${additionalFindingIds.size} Finding(s)`}
</button>
</div>
)}
{!isApproved && pendingFpQueue.length === 0 && (
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No pending FP queue items available to add.</span>
)}
</div>
)}
{/* Attachments tab */}
{activeTab === 'attachments' && (
<div>
<div style={{ marginBottom: '0.75rem' }}>
<span style={labelStyle}>Attachments from Initial Submission ({existingAttachments.length})</span>
{existingAttachments.length === 0 ? (
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', marginTop: '0.25rem' }}>No attachments were included in the original submission.</div>
) : (
<div style={{ marginTop: '0.25rem' }}>
{existingAttachments.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.3rem 0.5rem', marginBottom: '0.15rem',
borderRadius: '0.25rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.1)',
}}>
<FileText style={{ width: '12px', height: '12px', color: '#0EA5E9', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1', flex: 1 }}>{att.filename}</span>
<span style={{
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
color: att.success ? '#10B981' : '#EF4444',
}}>
{att.success ? 'OK' : 'FAILED'}
</span>
</div>
))}
</div>
)}
</div>
{!isApproved && (
<div style={{ marginTop: '0.75rem' }}>
<AttachmentSourcePicker
files={files}
onFilesChange={setFiles}
libraryDocs={libraryDocs}
onLibraryDocsChange={setLibraryDocs}
disabled={isApproved}
/>
{(files.length > 0 || libraryDocs.length > 0) && (
<button
onClick={handleUploadAttachments}
disabled={saving}
style={{
marginTop: '0.5rem',
padding: '0.4rem 1rem',
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem',
color: saving ? '#92700C' : '#F59E0B',
fontSize: '0.75rem',
fontWeight: '700',
cursor: saving ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{saving ? 'Uploading…' : `Upload ${files.length + libraryDocs.length} Attachment(s)`}
</button>
)}
</div>
)}
</div>
)}
{/* History tab */}
{activeTab === 'history' && (
<div>
{/* Ivanti reviewer notes (rework/approval/previous state feedback) */}
{(() => {
const safeText = (val) => {
if (!val) return null;
if (typeof val === 'string') return val;
try { return JSON.stringify(val); } catch { return String(val); }
};
const notes = [
safeText(submission.ivanti_rework_note) && { label: 'Rework Note', text: safeText(submission.ivanti_rework_note) },
safeText(submission.ivanti_approval_note) && { label: 'Approval Note', text: safeText(submission.ivanti_approval_note) },
safeText(submission.ivanti_current_state_notes) && { label: 'Current State Notes', text: safeText(submission.ivanti_current_state_notes) },
safeText(submission.ivanti_previous_state_notes) && { label: 'Previous State Notes', text: safeText(submission.ivanti_previous_state_notes) },
].filter(Boolean);
return notes.length > 0 ? notes.map((note, idx) => (
<div key={idx} style={{
padding: '0.625rem 0.75rem', marginBottom: '0.5rem',
borderRadius: '0.375rem',
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.2)',
}}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.35rem' }}>
{note.label}
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{note.text}
</div>
</div>
)) : null;
})()}
{history.length === 0 ? (
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B', textAlign: 'center', padding: '2rem 0' }}>
{/* ⚠️ CONVENTION: Use lucide-react icons instead of raw HTML entities/emoji */}
<div style={{ fontSize: '1.5rem', marginBottom: '0.5rem', opacity: 0.4 }}>&#128203;</div>
No history entries yet.
<div style={{ fontSize: '0.65rem', color: '#475569', marginTop: '0.35rem' }}>
Changes to this submission will appear here.
</div>
</div>
) : history.map((entry, idx) => {
const details = (() => {
try { return JSON.parse(entry.change_details_json || '{}'); } catch { return {}; }
})();
return (
<div key={entry.id || idx} style={{
padding: '0.5rem 0.625rem', marginBottom: '0.35rem',
borderRadius: '0.25rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.08)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: '#0EA5E9', textTransform: 'uppercase',
}}>
{(entry.change_type || '').replace(/_/g, ' ')}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}
</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginTop: '0.2rem' }}>
by {entry.username || 'unknown'}
</div>
{entry.change_type === 'status_changed' && details.from && (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
{details.from} {details.to}
</div>
)}
{entry.change_type === 'findings_added' && details.addedFindingIds && (
<div style={{ marginTop: '0.15rem' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8' }}>
+{details.addedFindingIds.length} finding(s):
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem', marginTop: '0.2rem' }}>
{details.addedFindingIds.map(fid => (
<span key={fid} style={{
padding: '0.05rem 0.3rem', borderRadius: '0.15rem',
background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)',
fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9',
}}>
{fid}
</span>
))}
</div>
</div>
)}
{entry.change_type === 'attachments_added' && details.files && (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
{details.files.filter(f => f.success).length} of {details.files.length} file(s) uploaded
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// SelectionToolbar — batch action bar for multi-selected findings
// ---------------------------------------------------------------------------
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
const isCard = workflowType === 'CARD' || workflowType === 'GRANITE' || workflowType === 'DECOM';
const canSubmit = !submitting && (isCard || vendor.trim().length > 0);
return (
<div style={{
position: 'sticky', top: 0, zIndex: 20,
display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap',
padding: '0.625rem 1rem',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
marginBottom: '0.5rem',
}}>
{/* Count badge */}
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '22px', height: '22px', padding: '0 6px',
background: 'rgba(14,165,233,0.2)', border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px', fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '700', color: '#0EA5E9',
}}>
{count}
</span>
selected
</span>
{/* Workflow type toggles */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{[
{ type: 'FP', color: '#F59E0B', rgb: '245,158,11' },
{ type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' },
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' },
{ type: 'DECOM', color: '#EF4444', rgb: '239,68,68' },
].map(({ type, color, rgb }) => {
const active = workflowType === type;
return (
<button
key={type}
onClick={() => onWorkflowChange(type)}
style={{
padding: '0.25rem 0.5rem',
background: active ? `rgba(${rgb},0.2)` : 'transparent',
border: `1px solid rgba(${rgb},${active ? '0.5' : '0.15'})`,
borderRadius: '0.25rem',
color: active ? color : '#475569',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{type}
</button>
);
})}
</div>
{/* Vendor input or CARD indicator */}
{isCard ? (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
padding: '0.25rem 0.5rem',
background: 'rgba(16,185,129,0.06)', border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
}}>
No vendor required
</span>
) : (
<input
type="text"
value={vendor}
onChange={(e) => onVendorChange(e.target.value)}
placeholder="Vendor / Platform"
style={{
width: '160px', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
color: '#CBD5E1', fontSize: '0.75rem', fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onSubmit(); }}
/>
)}
{/* Add to Queue button */}
<button
onClick={onSubmit}
disabled={!canSubmit}
style={{
padding: '0.3rem 0.75rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'transparent',
border: `1px solid rgba(14,165,233,${canSubmit ? '0.4' : '0.1'})`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{submitting ? 'Adding…' : 'Add to Queue'}
</button>
{/* Clear selection */}
<button
onClick={onClear}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#475569', padding: '4px', lineHeight: 1,
}}
title="Clear selection"
>
<X style={{ width: '16px', height: '16px' }} />
</button>
{/* Error message */}
{error && (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#EF4444',
display: 'flex', alignItems: 'center', gap: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px' }} />
{error}
</span>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// RowVisibilityManager — popover for viewing and restoring hidden rows
// ---------------------------------------------------------------------------
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
const [open, setOpen] = useState(false);
const panelRef = useRef(null);
const btnRef = useRef(null);
// Close on outside click (same pattern as ColumnManager)
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const hiddenCount = hiddenRowIds.size;
// Build list of hidden findings with title lookup
const hiddenEntries = useMemo(() => {
const ids = [...hiddenRowIds];
return ids.map(id => {
const finding = findings.find(f => String(f.id) === String(id));
return { id, title: finding ? finding.title : null };
});
}, [hiddenRowIds, findings]);
return (
<div style={{ position: 'relative' }}>
<button
ref={btnRef}
onClick={() => setOpen(p => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.35rem 0.75rem',
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.2'})`,
borderRadius: '0.375rem',
color: '#94a3b8', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<EyeOff style={{ width: '13px', height: '13px' }} />
Hidden ({hiddenCount})
</button>
{open && (
<div
ref={panelRef}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: '300px', zIndex: 100,
background: 'linear-gradient(135deg, rgba(15,23,42,0.98), rgba(30,41,59,0.98))',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
padding: '0.5rem',
maxHeight: '320px',
overflowY: 'auto',
}}
>
{/* Header */}
<div style={{
fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace',
textTransform: 'uppercase', letterSpacing: '0.1em',
padding: '0.25rem 0.5rem 0.5rem',
borderBottom: '1px solid rgba(255,255,255,0.05)',
marginBottom: '0.375rem',
}}>
Hidden Rows
</div>
{hiddenCount === 0 ? (
<div style={{
padding: '1rem 0.5rem',
textAlign: 'center',
fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569',
}}>
No rows hidden
</div>
) : (
<>
{hiddenEntries.map(entry => (
<div
key={entry.id}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.5rem', borderRadius: '0.25rem',
transition: 'background 0.1s',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
color: '#CBD5E1', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{entry.id}
</div>
{entry.title && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
marginTop: '1px',
}}>
{entry.title}
</div>
)}
</div>
<button
onClick={() => onRestore(entry.id)}
title="Restore row"
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px', color: '#334155', lineHeight: 1,
transition: 'color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
>
<Eye style={{ width: '14px', height: '14px' }} />
</button>
</div>
))}
{/* Restore All button */}
<div style={{
borderTop: '1px solid rgba(255,255,255,0.05)',
marginTop: '0.375rem',
paddingTop: '0.375rem',
}}>
<button
onClick={onRestoreAll}
style={{
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
padding: '0.4rem 0.5rem',
background: 'rgba(14,165,233,0.08)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.15s',
}}
>
<RotateCcw style={{ width: '12px', height: '12px' }} />
Restore All
</button>
</div>
</>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// BulkHideToolbar — appears when rows are selected for bulk hiding
// ---------------------------------------------------------------------------
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',
border: '1px solid rgba(14,165,233,0.3)',
borderRadius: '6px',
marginBottom: '0.5rem',
}}>
{/* Count label */}
<span style={{
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#e2e8f0',
}}>
{count} row{count !== 1 ? 's' : ''} selected
</span>
{/* Hide Selected button */}
<button
onClick={onHide}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.625rem',
background: 'rgba(14,165,233,0.12)',
border: '1px solid rgba(79,195,247,0.35)',
borderRadius: '0.25rem',
color: '#4fc3f7',
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<EyeOff style={{ width: '12px', height: '12px' }} />
Hide Selected
</button>
{/* Bulk Atlas Action Plan button */}
{canWrite && onAtlasBulk && (
<button
onClick={onAtlasBulk}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.625rem',
background: 'rgba(14,165,233,0.12)',
border: '1px solid rgba(79,195,247,0.35)',
borderRadius: '0.25rem',
color: '#4fc3f7',
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<Database style={{ width: '12px', height: '12px' }} />
Atlas Action Plan
</button>
)}
{/* Clear button */}
<button
onClick={onClear}
style={{
background: 'none', border: 'none',
fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B',
cursor: 'pointer', padding: '0.3rem 0.375rem',
transition: 'color 0.15s',
}}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// BulkAtlasModal — modal for creating action plans on multiple hosts at once
// ---------------------------------------------------------------------------
const BULK_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
const BULK_PLAN_TYPE_COLORS = {
remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B',
risk_acceptance: '#A855F7', scan_exclusion: '#64748B',
};
const NEEDS_QUALYS = new Set(['remediation', 'false_positive', 'risk_acceptance']);
function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const [planType, setPlanType] = useState('risk_acceptance');
const [commitDate, setCommitDate] = useState('');
const [jiraVnr, setJiraVnr] = useState('');
const [archerExc, setArcherExc] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
const [typeOpen, setTypeOpen] = useState(false);
const typeRef = useRef(null);
// Vulnerability loading state
const [vulnsLoading, setVulnsLoading] = useState(false);
const [vulnsError, setVulnsError] = useState(null);
const [availableQualys, setAvailableQualys] = useState([]);
const [selectedQualys, setSelectedQualys] = useState(new Set());
// Close type dropdown on outside click
useEffect(() => {
if (!typeOpen) return;
const handler = (e) => { if (typeRef.current && !typeRef.current.contains(e.target)) setTypeOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [typeOpen]);
// Deduplicate host IDs from selected findings
const hostEntries = useMemo(() => {
const seen = new Map();
for (const f of selectedFindings) {
if (f.hostId && !seen.has(f.hostId)) {
seen.set(f.hostId, {
hostId: f.hostId,
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
findingId: f.id ? Number(f.id) : null,
});
}
}
return [...seen.values()];
}, [selectedFindings]);
const hostIds = useMemo(() => hostEntries.map(h => h.hostId), [hostEntries]);
// Fetch vulnerabilities from Atlas when modal opens
useEffect(() => {
if (hostIds.length === 0) return;
let cancelled = false;
const fetchVulns = async () => {
setVulnsLoading(true);
setVulnsError(null);
try {
const res = await fetch(`${API_BASE}/atlas/hosts/vulnerabilities`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host_ids: hostIds }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Failed to fetch vulnerabilities (${res.status})`);
}
const data = await res.json();
if (cancelled) return;
// Parse response — Atlas returns { "host_id": [ { qualys_id, title, ... }, ... ], ... }
const qualysMap = new Map();
if (data && typeof data === 'object' && !Array.isArray(data)) {
for (const [, vulnList] of Object.entries(data)) {
if (!Array.isArray(vulnList)) continue;
for (const vuln of vulnList) {
const qid = vuln.qualys_id || vuln.sourceId;
if (!qid) continue;
if (!qualysMap.has(qid)) {
qualysMap.set(qid, {
qualys_id: qid,
title: vuln.title || qid,
count: 1,
});
} else {
qualysMap.get(qid).count++;
}
}
}
}
const sorted = [...qualysMap.values()].sort((a, b) => b.count - a.count);
setAvailableQualys(sorted);
setSelectedQualys(new Set(sorted.map(q => q.qualys_id)));
} catch (err) {
if (!cancelled) setVulnsError(err.message);
} finally {
if (!cancelled) setVulnsLoading(false);
}
};
fetchVulns();
return () => { cancelled = true; };
}, [hostIds]);
const toggleQualys = (qid) => {
setSelectedQualys(prev => {
const next = new Set(prev);
if (next.has(qid)) next.delete(qid); else next.add(qid);
return next;
});
};
const toggleAllQualys = () => {
if (selectedQualys.size === availableQualys.length) {
setSelectedQualys(new Set());
} else {
setSelectedQualys(new Set(availableQualys.map(q => q.qualys_id)));
}
};
const handleSubmit = async () => {
if (!commitDate) { setError('Commit date is required'); return; }
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
const needsQualys = NEEDS_QUALYS.has(planType);
if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
return;
}
setSubmitting(true);
setError(null);
try {
// If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
const results = [];
for (const qid of qualysIds) {
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
if (qid) body.qualys_id = qid;
// When no qualys_id is available, include the first finding ID per host
// so Atlas can associate the plan with a specific vulnerability
if (!qid && needsQualys) {
const firstWithFinding = hostEntries.find(h => h.findingId);
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
}
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
if (archerExc.trim()) body.archer_exc = archerExc.trim();
const res = await fetch(`${API_BASE}/atlas/hosts/bulk-action-plans`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
results.push({ qualys_id: qid, success: false, error: data.error || data.detail || `Failed (${res.status})` });
} else {
results.push({ qualys_id: qid, success: true, data });
}
}
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
if (failed.length > 0 && succeeded === 0) {
throw new Error(failed[0].error);
}
setResult({ succeeded, failed: failed.length, total: results.length, details: results });
if (onSuccess) onSuccess();
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
};
const inputSt = {
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.06)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.375rem', color: '#E2E8F0', padding: '0.5rem 0.625rem',
fontSize: '0.78rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none',
};
const labelSt = {
display: 'block', fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
color: '#94A3B8', marginBottom: '0.3rem', textTransform: 'uppercase', letterSpacing: '0.05em',
};
return ReactDOM.createPortal(
<>
{/* Backdrop */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 60 }} />
{/* Modal */}
<div style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
width: '520px', maxHeight: '80vh', overflowY: 'auto',
background: '#0A1220',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem',
boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
zIndex: 61,
fontFamily: "'JetBrains Mono', monospace",
}}>
{/* Header */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Database style={{ width: 16, height: 16, color: '#0EA5E9' }} />
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>
Bulk Atlas Action Plan
</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}>
<X style={{ width: 18, height: 18 }} />
</button>
</div>
{/* Success state */}
{result ? (
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
<Check style={{ width: 32, height: 32, color: '#10B981', margin: '0 auto 0.75rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}>
Action plans created
</div>
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
{hostIds.length} host{hostIds.length !== 1 ? 's' : ''} {planType.replace(/_/g, ' ')}
</div>
{result.total > 1 && (
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
{result.succeeded} of {result.total} Qualys ID{result.total !== 1 ? 's' : ''} succeeded
{result.failed > 0 && <span style={{ color: '#F87171' }}> {result.failed} failed</span>}
</div>
)}
{result.details?.filter(d => !d.success).map((d, i) => (
<div key={i} style={{ fontSize: '0.68rem', color: '#F87171', marginTop: '0.25rem' }}>
{d.qualys_id}: {d.error}
</div>
))}
<button onClick={onClose} style={{
marginTop: '1rem',
padding: '0.5rem 1.25rem',
background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9',
borderRadius: '0.375rem', color: '#38BDF8',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
}}>
Close
</button>
</div>
) : (
<div style={{ padding: '1rem 1.25rem' }}>
{/* Host summary */}
<div style={{
marginBottom: '1rem', padding: '0.625rem 0.75rem',
background: 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.375rem',
}}>
<div style={{ fontSize: '0.68rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
{hostEntries.length} unique host{hostEntries.length !== 1 ? 's' : ''} from {selectedFindings.length} selected finding{selectedFindings.length !== 1 ? 's' : ''}
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto', fontSize: '0.72rem', color: '#CBD5E1', lineHeight: 1.6 }}>
{hostEntries.map(h => (
<div key={h.hostId} style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{h.hostName}</span>
<span style={{ color: '#475569', flexShrink: 0 }}>{h.hostId}</span>
</div>
))}
</div>
</div>
{/* Plan type dropdown */}
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>Plan Type</label>
<div ref={typeRef} style={{ position: 'relative' }}>
<button type="button" onClick={() => setTypeOpen(!typeOpen)} style={{
...inputSt, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', textAlign: 'left',
borderColor: typeOpen ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
}}>
<span style={{ color: BULK_PLAN_TYPE_COLORS[planType], fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{planType.replace(/_/g, ' ')}
</span>
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: typeOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{typeOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: '4px',
background: '#0F1A2E', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.375rem', boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
zIndex: 65, overflow: 'hidden',
}}>
{BULK_PLAN_TYPES.map(t => (
<div key={t} onClick={() => { setPlanType(t); setTypeOpen(false); }} style={{
padding: '0.5rem 0.625rem', cursor: 'pointer',
background: t === planType ? 'rgba(14,165,233,0.12)' : 'transparent',
color: BULK_PLAN_TYPE_COLORS[t], fontSize: '0.78rem',
fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em',
}}
onMouseEnter={e => { if (t !== planType) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
onMouseLeave={e => { if (t !== planType) e.currentTarget.style.background = 'transparent'; }}
>
{t.replace(/_/g, ' ')}
</div>
))}
</div>
)}
</div>
</div>
{/* Commit date */}
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>Commit Date</label>
<input type="date" value={commitDate} onChange={e => setCommitDate(e.target.value)}
style={{ ...inputSt, colorScheme: 'dark' }} />
</div>
{/* Optional fields — shown based on plan type */}
{/* Qualys ID multi-select — shown for plan types that require it */}
{NEEDS_QUALYS.has(planType) && (
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>
Qualys IDs
<span style={{ color: '#475569', textTransform: 'none', marginLeft: '0.3rem' }}>
({selectedQualys.size} of {availableQualys.length} selected)
</span>
</label>
{vulnsLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0', color: '#475569', fontSize: '0.72rem' }}>
<Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} />
Loading vulnerabilities from Atlas...
</div>
)}
{vulnsError && (
<div style={{
padding: '0.5rem 0.75rem',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.72rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{vulnsError}
</div>
)}
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
No vulnerabilities found in Atlas for these hosts Qualys ID will be omitted
</div>
)}
{!vulnsLoading && availableQualys.length > 0 && (
<div style={{
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.375rem',
maxHeight: '180px', overflowY: 'auto',
}}>
<div
onClick={toggleAllQualys}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.45rem 0.625rem',
borderBottom: '1px solid rgba(14,165,233,0.1)',
cursor: 'pointer', fontSize: '0.72rem', color: '#94A3B8',
}}
>
<input type="checkbox" readOnly
checked={selectedQualys.size === availableQualys.length && availableQualys.length > 0}
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
/>
<span style={{ fontWeight: 600 }}>Select All</span>
</div>
{availableQualys.map(q => (
<div
key={q.qualys_id}
onClick={() => toggleQualys(q.qualys_id)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.625rem',
cursor: 'pointer', fontSize: '0.72rem',
background: selectedQualys.has(q.qualys_id) ? 'rgba(14,165,233,0.08)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'rgba(14,165,233,0.04)'; }}
onMouseLeave={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'transparent'; }}
>
<input type="checkbox" readOnly
checked={selectedQualys.has(q.qualys_id)}
style={{ accentColor: '#0EA5E9', cursor: 'pointer', flexShrink: 0 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<span style={{ color: '#E2E8F0', fontWeight: 600 }}>{q.qualys_id}</span>
<span style={{ color: '#475569', marginLeft: '0.4rem' }}>
({q.count} host{q.count !== 1 ? 's' : ''})
</span>
<div style={{ color: '#64748B', fontSize: '0.65rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{q.title}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{planType === 'false_positive' && (
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>Jira VNR <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
<input value={jiraVnr} onChange={e => setJiraVnr(e.target.value)}
placeholder="VNR-67890" style={inputSt} />
</div>
)}
{(planType === 'risk_acceptance' || planType === 'scan_exclusion') && (
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>Archer EXC <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
<input value={archerExc} onChange={e => setArcherExc(e.target.value)}
placeholder="EXC-54321" style={inputSt} />
</div>
)}
{/* Error */}
{error && (
<div style={{
marginBottom: '0.75rem', padding: '0.5rem 0.75rem',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.75rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<AlertCircle style={{ width: 14, height: 14, flexShrink: 0 }} />{error}
</div>
)}
{/* Submit */}
<button onClick={handleSubmit} disabled={submitting || vulnsLoading} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
padding: '0.6rem 1rem',
background: (submitting || vulnsLoading) ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)',
border: '1px solid #0EA5E9', borderRadius: '0.375rem',
color: (submitting || vulnsLoading) ? '#475569' : '#38BDF8',
fontSize: '0.78rem', fontWeight: 600, cursor: (submitting || vulnsLoading) ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{submitting ? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> : <Database style={{ width: 14, height: 14 }} />}
{submitting ? 'Creating...' : `Create Plans for ${hostEntries.length} Host${hostEntries.length !== 1 ? 's' : ''}${NEEDS_QUALYS.has(planType) && selectedQualys.size > 0 ? ` × ${selectedQualys.size} QID${selectedQualys.size !== 1 ? 's' : ''}` : ''}`}
</button>
</div>
)}
</div>
</>,
document.body
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = useAuth();
const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null);
const [syncStatus, setSyncStatus] = useState(null);
const [syncError, setSyncError] = useState(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
const [countsLoading, setCountsLoading] = useState(true);
const [fpCounts, setFPCounts] = useState({ findingCounts: {}, findingTotal: 0, idCounts: {}, idTotal: 0 });
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
const [columnFilters, setColumnFilters] = useState(() =>
filterDate ? { dueDate: new Set([filterDate]) } : {}
);
const [openFilter, setOpenFilter] = useState(null);
const filterBtnRefs = useRef({});
const [actionFilter, setActionFilter] = useState(null);
const [excFilter, setExcFilter] = useState(filterEXC || null);
const [selectedIds, setSelectedIds] = useState(new Set());
const [lastClickedId, setLastClickedId] = useState(null);
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchError, setBatchError] = useState(null);
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState('');
// CVE tooltip state & refs
const [tooltipCveId, setTooltipCveId] = useState(null);
const [tooltipAnchorRect, setTooltipAnchorRect] = useState(null);
const tooltipCacheRef = useRef(new Map());
const hoverTimerRef = useRef(null);
// Atlas action plan state
const [metricsTab, setMetricsTab] = useState('ivanti');
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
const [atlasSyncing, setAtlasSyncing] = useState(false);
const [atlasError, setAtlasError] = useState(null);
const [atlasPanelOpen, setAtlasPanelOpen] = useState(false);
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
const [bulkAtlasOpen, setBulkAtlasOpen] = useState(false);
// Atlas metrics state (for Atlas Coverage tab donut charts)
const [atlasMetrics, setAtlasMetrics] = useState(null);
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
// CARD API state — session-level caching for teams list
const [cardConfigured, setCardConfigured] = useState(false);
const [cardTeams, setCardTeams] = useState([]);
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
}, []);
// Hidden row state (row visibility feature)
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
const hideRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.add(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
const restoreRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.delete(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
const restoreAllRows = useCallback(() => {
setHiddenRowIds(new Set());
saveHiddenRows(new Set());
}, []);
// Selection state (row visibility feature — bulk hide)
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
const toggleRowSelection = useCallback((findingId) => {
setSelectedRowIds(prev => {
const next = new Set(prev);
const id = String(findingId);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const hideSelectedRows = useCallback(() => {
setHiddenRowIds(prev => {
const next = new Set(prev);
selectedRowIds.forEach(id => next.add(String(id)));
saveHiddenRows(next);
return next;
});
setSelectedRowIds(new Set());
}, [selectedRowIds]);
// CVE tooltip hover handlers
const handleCveMouseEnter = useCallback((cveId, e) => {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = setTimeout(() => {
setTooltipCveId(cveId);
setTooltipAnchorRect(e.target.getBoundingClientRect());
}, 300);
}, []);
const handleCveMouseLeave = useCallback(() => {
clearTimeout(hoverTimerRef.current);
setTooltipCveId(null);
setTooltipAnchorRect(null);
}, []);
const applyState = (data) => {
setTotal(data.total ?? 0);
setFindings(data.findings || []);
setSyncedAt(data.synced_at || null);
setSyncStatus(data.sync_status || null);
setSyncError(data.error_message || null);
};
const fetchCounts = async () => {
setCountsLoading(true);
try {
// Fetch counts from server — Postgres provides per-BU open+closed counts
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts`;
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
} catch (e) {
console.error('Error loading status counts:', e);
} finally {
setCountsLoading(false);
}
};
const fetchFPWorkflowCounts = async () => {
try {
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/fp-workflow-counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/fp-workflow-counts`;
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (res.ok) setFPCounts({
findingCounts: data.findingCounts || {},
findingTotal: data.findingTotal || 0,
idCounts: data.idCounts || {},
idTotal: data.idTotal || 0,
});
} catch (e) {
console.error('Error loading FP workflow counts:', e);
}
};
const fetchAtlasStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const map = new Map();
data.forEach(row => map.set(row.host_id, row));
setAtlasStatusMap(map);
}
} catch (err) {
console.error('[Atlas] Failed to fetch status:', err.message);
}
}, []);
const fetchAtlasMetrics = useCallback(async () => {
setAtlasMetricsLoading(true);
setAtlasMetricsError(null);
try {
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAtlasMetrics(data);
} else {
const err = await res.json().catch(() => ({}));
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
}
} catch (err) {
setAtlasMetricsError(err.message);
} finally {
setAtlasMetricsLoading(false);
}
}, []);
// CARD API — fetch status and teams (session-level caching)
const cardTeamsFetchedRef = useRef(false);
const fetchCardStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setCardConfigured(data.configured === true);
if (data.configured && !cardTeamsFetchedRef.current) {
cardTeamsFetchedRef.current = true;
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
if (teamsRes.ok) {
const teamsData = await teamsRes.json();
const teams = Array.isArray(teamsData)
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
: [];
setCardTeams(teams);
}
}
}
} catch (err) {
console.error('[card-api] Failed to fetch CARD status:', err.message);
}
}, []);
const fetchFindings = async () => {
setLoading(true);
try {
// Always fetch ALL findings — filtering happens client-side for instant scope switching
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
const data = await res.json();
if (res.ok) {
applyState(data);
tooltipCacheRef.current.clear();
}
} catch (e) {
console.error('Error loading findings:', e);
} finally {
setLoading(false);
}
};
const syncFindings = async () => {
setSyncing(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
const data = await res.json();
if (res.ok) {
applyState(data);
tooltipCacheRef.current.clear();
fetchCounts(); // refresh counts after sync
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
}
} catch (e) {
console.error('Error syncing findings:', e);
} finally {
setSyncing(false);
}
};
useEffect(() => {
fetchFindings();
fetchCounts();
fetchFPWorkflowCounts();
fetchQueue();
fetchFpSubmissions();
fetchAtlasStatus();
fetchAtlasMetrics();
fetchCardStatus();
}, []); // eslint-disable-line
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
// Silent fetch — no loading spinner, just update the numbers
useEffect(() => {
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts`;
fetch(url, { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); })
.catch(() => {});
// Also refresh FP workflow counts for the new scope
fetchFPWorkflowCounts();
}, [adminScope]); // eslint-disable-line
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => {
if (!vals) {
const next = { ...prev };
delete next[colKey];
return next;
}
return { ...prev, [colKey]: vals };
});
}, []);
// Scope findings by selected BU teams (client-side filtering for instant switching)
const scopedFindings = useMemo(() => {
const teamsParam = getActiveTeamsParam();
if (!teamsParam) return findings; // no filter = show all
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
if (teams.length === 0) return findings;
return findings.filter(f =>
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
);
}, [findings, adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
// Visible findings — hidden rows removed before any other filtering
const visibleFindings = useMemo(() => {
if (hiddenRowIds.size === 0) return scopedFindings;
return scopedFindings.filter(f => !hiddenRowIds.has(String(f.id)));
}, [scopedFindings, hiddenRowIds]);
// Apply all active filters to produce the visible row set
const filtered = useMemo(() => {
let result = visibleFindings;
// Column filters
const active = Object.entries(columnFilters);
if (active.length > 0) {
result = result.filter((f) =>
active.every(([key, vals]) => {
if (!vals || vals.size === 0) return false;
const def = COLUMN_DEFS[key];
if (def?.multiValue) {
const arr = f[key] || [];
if (arr.length === 0) return vals.has(EMPTY_SENTINEL);
return arr.some((v) => vals.has(String(v).trim()));
}
const fval = getFilterVal(f, key).trim();
return fval === '' ? vals.has(EMPTY_SENTINEL) : vals.has(fval);
})
);
}
// Action coverage filter (chart segment click)
if (actionFilter) {
result = result.filter((f) => classifyFinding(f) === actionFilter);
}
// EXC filter (navigated from home page Archer ticket)
if (excFilter) {
const upper = excFilter.toUpperCase();
result = result.filter((f) => (f.note || '').toUpperCase().includes(upper));
}
return result;
}, [visibleFindings, columnFilters, actionFilter, excFilter]);
// Visible columns in current order
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
// Sort filtered results
const sorted = useMemo(() => [...filtered].sort((a, b) => {
const av = getVal(a, sort.field);
const bv = getVal(b, sort.field);
let cmp = 0;
if (typeof av === 'number' && typeof bv === 'number') {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
}
return sort.dir === 'asc' ? cmp : -cmp;
}), [filtered, sort]);
// Select/deselect all visible rows
const toggleSelectAll = useCallback(() => {
const allVisibleIds = sorted.map(f => String(f.id));
setSelectedRowIds(prev => {
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
return new Set(allVisibleIds); // select all
});
}, [sorted]);
// Prune selection to only include IDs present in the current sorted (visible) rows
useEffect(() => {
setSelectedRowIds(prev => {
const visibleIds = new Set(sorted.map(f => String(f.id)));
const next = new Set([...prev].filter(id => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [sorted]);
const toggleSort = (key) => {
setSort((prev) =>
prev.field === key
? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
: { field: key, dir: 'asc' }
);
};
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
// Queue state
const [queueItems, setQueueItems] = useState([]);
const [queueOpen, setQueueOpen] = useState(false);
const [queueLoading, setQueueLoading] = useState(false);
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
// FP Workflow modal state
const [fpModalOpen, setFpModalOpen] = useState(false);
const [fpModalItems, setFpModalItems] = useState([]);
// FP Submission editing state
const [fpSubmissionsRaw, setFpSubmissions] = useState([]);
const [editSubmission, setEditSubmission] = useState(null);
// Enrich submissions with actual Ivanti workflow state from findings data
const fpSubmissions = useMemo(() => {
if (fpSubmissionsRaw.length === 0 || findings.length === 0) return fpSubmissionsRaw;
const stateMap = {
'reworked': 'rework', 'rejected': 'rejected', 'expired': 'rejected',
'approved': 'approved', 'requested': 'submitted', 'actionable': 'submitted',
};
return fpSubmissionsRaw.map(sub => {
let findingIds;
try { findingIds = JSON.parse(sub.finding_ids_json || '[]'); } catch { return sub; }
if (findingIds.length === 0) return sub;
const matchedFinding = findings.find(f =>
f.workflow && findingIds.includes(String(f.id))
);
if (!matchedFinding || !matchedFinding.workflow) return sub;
const ivantiState = (matchedFinding.workflow.state || '').toLowerCase();
const mappedStatus = stateMap[ivantiState];
if (mappedStatus && mappedStatus !== sub.lifecycle_status) {
return { ...sub, lifecycle_status: mappedStatus };
}
return sub;
});
}, [fpSubmissionsRaw, findings]);
// Filtered submissions for QueuePanel display — hide approved and dismissed
const fpSubmissionsFiltered = useMemo(() => {
return fpSubmissions.filter(s => s.lifecycle_status !== 'approved' && !s.dismissed_at);
}, [fpSubmissions]);
// Queue API helpers
const fetchQueue = useCallback(async () => {
setQueueLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setQueueItems(data);
} catch (e) {
console.error('Error fetching queue:', e);
} finally {
setQueueLoading(false);
}
}, []);
const fetchFpSubmissions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setFpSubmissions(data);
} catch (e) {
console.error('Error fetching FP submissions:', e);
}
}, []);
// FP Workflow handlers
const handleCreateFpWorkflow = useCallback((selectedIds) => {
const selectedSet = new Set(selectedIds);
const fpItems = filterFpItems(
queueItems.filter(item => selectedSet.has(item.id) && item.status === 'pending')
);
if (fpItems.length > 0) {
setFpModalItems(fpItems);
setFpModalOpen(true);
}
}, [queueItems]);
const handleFpWorkflowSuccess = useCallback(() => {
fetchQueue();
}, [fetchQueue]);
const handleEditSubmission = useCallback((submission) => {
setEditSubmission(submission);
}, []);
const handleEditSuccess = useCallback(() => {
fetchFpSubmissions();
fetchQueue();
fetchFindings();
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
const handleDismissSubmission = useCallback((submissionId) => {
// Optimistically remove the dismissed submission from local state
setFpSubmissions(prev => prev.filter(s => s.id !== submissionId));
}, []);
const addToQueue = useCallback(async () => {
if (!addPopover) return;
const { finding } = addPopover;
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
finding_id: finding.id,
finding_title: finding.title || null,
cves: finding.cves || [],
ip_address: finding.ipAddress || null,
hostname: finding.overrides?.hostName || finding.hostName || null,
vendor: queueForm.vendor.trim(),
workflow_type: queueForm.workflowType,
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, data].sort((a, b) =>
a.vendor.localeCompare(b.vendor) || a.id - b.id
));
// DECOM: auto-set note and auto-hide the finding
if (queueForm.workflowType === 'DECOM') {
// Set note to DECOM
fetch(`${API_BASE}/ivanti/findings/${finding.id}/note`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: 'DECOM' }),
}).catch(() => {});
// Update local findings state
setFindings(prev => prev.map(f =>
f.id === finding.id ? { ...f, note: 'DECOM' } : f
));
// Auto-hide the row
hideRow(finding.id);
}
}
} catch (e) {
console.error('Error adding to queue:', e);
}
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm, hideRow]);
// Prune selection when filters change — keep only IDs still in filtered set
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(filtered.map((f) => f.id));
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [filtered]);
// Escape key clears selection
useEffect(() => {
if (selectedIds.size === 0) return;
const handler = (e) => {
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
setSelectedIds(new Set());
setBatchError(null);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [selectedIds, addPopover]);
const submitBatch = useCallback(async () => {
if (selectedIds.size === 0) return;
setBatchSubmitting(true);
setBatchError(null);
try {
const findingsPayload = [...selectedIds].map((id) => {
const f = findings.find((ff) => ff.id === id);
return f ? {
finding_id: f.id,
finding_title: f.title || null,
cves: f.cves || [],
ip_address: f.ipAddress || null,
hostname: f.overrides?.hostName || f.hostName || null,
} : { finding_id: id };
});
const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
findings: findingsPayload,
workflow_type: batchWorkflowType,
vendor: batchWorkflowType === 'CARD' || batchWorkflowType === 'GRANITE' || batchWorkflowType === 'DECOM' ? '' : batchVendor.trim(),
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, ...(data.items || [])].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
));
// DECOM: auto-set note and auto-hide all selected findings
if (batchWorkflowType === 'DECOM') {
const ids = [...selectedIds];
// Set notes to DECOM in parallel (fire-and-forget)
ids.forEach(id => {
fetch(`${API_BASE}/ivanti/findings/${id}/note`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: 'DECOM' }),
}).catch(() => {});
});
// Update local findings state
setFindings(prev => prev.map(f =>
ids.includes(f.id) ? { ...f, note: 'DECOM' } : f
));
// Auto-hide all
setHiddenRowIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.add(String(id)));
saveHiddenRows(next);
return next;
});
}
setSelectedIds(new Set());
setBatchWorkflowType('FP');
setBatchVendor('');
setBatchError(null);
} else {
setBatchError(data.error || 'Failed to add findings to queue.');
}
} catch (e) {
console.error('Error in batch add:', e);
setBatchError('Network error — please try again.');
} finally {
setBatchSubmitting(false);
}
}, [selectedIds, findings, batchWorkflowType, batchVendor]);
const updateQueueItem = useCallback(async (id, changes) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => prev.map((item) => item.id === id ? data : item));
}
} catch (e) {
console.error('Error updating queue item:', e);
}
}, []);
const deleteQueueItem = useCallback(async (id) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.id !== id));
} catch (e) {
console.error('Error deleting queue item:', e);
}
}, []);
const deleteQueueItems = useCallback(async (ids) => {
try {
await Promise.all(ids.map((id) =>
fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { method: 'DELETE', credentials: 'include' })
));
const removed = new Set(ids);
setQueueItems((prev) => prev.filter((item) => !removed.has(item.id)));
} catch (e) {
console.error('Error bulk-deleting queue items:', e);
}
}, []);
const clearCompleted = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/completed`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.status !== 'complete'));
} catch (e) {
console.error('Error clearing completed queue items:', e);
}
}, []);
const isQueued = useCallback((findingId) =>
queueItems.some((item) => item.finding_id === findingId),
[queueItems]);
const pendingQueueCount = queueItems.filter((i) => i.status === 'pending').length;
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportBtnRef = useRef(null);
// Close export menu on outside click
useEffect(() => {
if (!exportMenuOpen) return;
const handler = (e) => {
if (exportBtnRef.current && !exportBtnRef.current.contains(e.target)) {
setExportMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [exportMenuOpen]);
const buildExportRows = useCallback(() => {
const cols = visibleCols.filter((c) => COLUMN_DEFS[c.key]);
const headers = cols.map((c) => COLUMN_DEFS[c.key].label);
const rows = sorted.map((finding) =>
cols.map((c) => getExportVal(finding, c.key))
);
return [headers, ...rows];
}, [sorted, visibleCols]);
const exportCSV = useCallback(() => {
setExportMenuOpen(false);
const rows = buildExportRows();
const csvContent = rows.map((row) =>
row.map((cell) => {
const s = String(cell ?? '');
// Quote if it contains comma, double-quote, or newline
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}).join(',')
).join('\r\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `findings-export-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [buildExportRows]);
const exportXLSX = useCallback(() => {
setExportMenuOpen(false);
const rows = buildExportRows();
const ws = XLSX.utils.aoa_to_sheet(rows);
// Auto-fit column widths
const colWidths = rows[0].map((_, ci) =>
Math.min(60, Math.max(10, ...rows.map((r) => String(r[ci] ?? '').length)))
);
ws['!cols'] = colWidths.map((w) => ({ wch: w }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Findings');
XLSX.writeFile(wb, `findings-export-${new Date().toISOString().slice(0, 10)}.xlsx`);
}, [buildExportRows]);
const syncedDisplay = syncedAt
? `Synced ${new Date(syncedAt).toLocaleString()}`
: 'Never synced';
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* ----------------------------------------------------------------
Panel 1 Metrics placeholder
---------------------------------------------------------------- */}
<div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: '1px solid rgba(245,158,11,0.2)',
borderLeft: '3px solid #F59E0B',
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
Metric Graphs
</h2>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }} role="tablist">
{[{ key: 'ivanti', label: 'Ivanti Findings' }, { key: 'atlas', label: 'Atlas Coverage' }].map(tab => {
const isActive = metricsTab === tab.key;
return (
<button
key={tab.key}
role="tab"
aria-selected={isActive}
tabIndex={0}
onClick={() => setMetricsTab(tab.key)}
onKeyDown={(e) => { if (e.key === 'Enter') setMetricsTab(tab.key); }}
style={{
background: 'transparent',
border: 'none',
borderBottom: isActive ? '2px solid #F59E0B' : '2px solid transparent',
color: isActive ? '#F59E0B' : '#64748B',
fontFamily: 'monospace',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: '0.08em',
padding: '0.375rem 0.75rem',
cursor: 'pointer',
transition: 'background 0.15s, color 0.15s'
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(245, 158, 11, 0.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{tab.label}
</button>
);
})}
</div>
</div>
<div role="tabpanel">
{metricsTab === 'ivanti' && (
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Open vs Closed donut — per-BU counts from Postgres */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Open vs Closed
</div>
<StatusDonut
open={statusCounts.open}
closed={statusCounts.closed}
loading={countsLoading}
/>
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* Action Coverage donut */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Action Coverage
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}> filtered</span>}
</div>
<ActionCoverageDonut
findings={visibleFindings}
activeSegment={actionFilter}
onSegmentClick={(key) => {
setExcFilter(null);
setActionFilter(key);
}}
/>
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* FP Finding Status donut — # of findings per FP workflow state */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
FP Finding Status
</div>
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
FP Workflow Status
</div>
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
</div>
</div>
)}
{metricsTab === 'atlas' && (
(atlasMetricsLoading || (!atlasMetrics && !atlasMetricsError)) ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0', gap: '0.5rem' }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
</div>
) : atlasMetricsError ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '0.375rem' }}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{atlasMetricsError}</span>
</div>
) : (
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Host Coverage donut */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Host Coverage
</div>
<AtlasCoverageDonut
hostsWithPlans={atlasMetrics.hostsWithPlans}
hostsWithoutPlans={atlasMetrics.hostsWithoutPlans}
totalHosts={atlasMetrics.totalHosts}
/>
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* Plan Types donut */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Plan Types
</div>
<AtlasPlanTypeDonut
plansByType={atlasMetrics.plansByType}
totalPlans={atlasMetrics.totalPlans}
/>
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* Plan Status donut */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Plan Status
</div>
<AtlasPlanStatusDonut
plansByStatus={atlasMetrics.plansByStatus}
totalPlans={atlasMetrics.totalPlans}
/>
</div>
</div>
)
)}
</div>
</div>
{/* ----------------------------------------------------------------
Panel 1.5 Open vs Closed trend over time
---------------------------------------------------------------- */}
{metricsTab === 'ivanti' && <AnomalyBanner />}
{metricsTab === 'ivanti' && <IvantiCountsChart teamsParam={getActiveTeamsParam()} />}
{/* ----------------------------------------------------------------
Panel 2 Findings table
---------------------------------------------------------------- */}
<div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: '1px solid rgba(14,165,233,0.2)',
borderLeft: '3px solid #0EA5E9',
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
}}>
{/* Panel header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
Host Findings
</h2>
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
{syncedDisplay}
{syncStatus === 'success' && total !== null && (
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
{activeFilterCount > 0 && (
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
</span>
)}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{/* EXC filter badge (from home page navigation) */}
{excFilter && (
<button
onClick={() => setExcFilter(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
{excFilter} ×
</button>
)}
{/* Action coverage filter badge (from chart click) */}
{actionFilter && (
<button
onClick={() => setActionFilter(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)',
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)'}`,
borderRadius: '0.375rem',
color: actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444',
cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
{ACTION_DEFS.find(d => d.key === actionFilter)?.label} ×
</button>
)}
{Object.keys(columnFilters).length > 0 && (
<button
onClick={() => setColumnFilters({})}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
Clear Filters
</button>
)}
{/* Export dropdown */}
<div ref={exportBtnRef} style={{ position: 'relative' }}>
<button
onClick={() => setExportMenuOpen((o) => !o)}
disabled={sorted.length === 0}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem',
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
opacity: sorted.length === 0 ? 0.4 : 1,
}}
>
<Download style={{ width: '11px', height: '11px' }} />
Export
<ChevronDown style={{ width: '10px', height: '10px', marginLeft: '1px' }} />
</button>
{exportMenuOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem', overflow: 'hidden',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
minWidth: '120px',
}}>
{[
{ label: 'CSV (.csv)', action: exportCSV },
{ label: 'Excel (.xlsx)', action: exportXLSX },
].map(({ label, action }) => (
<button
key={label}
onClick={action}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.5rem 0.875rem',
background: 'none', border: 'none',
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
color: '#10B981', cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>
{label}
</button>
))}
</div>
)}
</div>
{/* Queue button */}
<button
onClick={() => setQueueOpen((o) => !o)}
style={{
position: 'relative',
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: queueOpen ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
border: `1px solid rgba(14,165,233,${queueOpen ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<ListTodo style={{ width: '13px', height: '13px' }} />
Queue
{pendingQueueCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '16px', height: '16px', padding: '0 4px',
background: '#0EA5E9', borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700', color: '#0A1628',
marginLeft: '1px',
}}>
{pendingQueueCount}
</span>
)}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button
onClick={async () => {
setAtlasSyncing(true);
setAtlasError(null);
try {
const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Atlas sync failed');
}
await fetchAtlasStatus();
await fetchAtlasMetrics();
} catch (err) {
setAtlasError(err.message);
} finally {
setAtlasSyncing(false);
}
}}
disabled={atlasSyncing || !canWrite()}
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
padding: '0.4rem 0.75rem',
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.375rem',
color: atlasSyncing ? '#475569' : '#0EA5E9',
fontSize: '0.72rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
opacity: !canWrite() ? 0.5 : 1,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{atlasSyncing
? <Loader style={{ width: 13, height: 13, animation: 'spin 1s linear infinite' }} />
: <AtlasIcon style={{ width: 13, height: 13 }} />}
Atlas
</button>
<button
onClick={syncFindings}
disabled={syncing || loading}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(14,165,233,0.1)',
border: '1px solid rgba(14,165,233,0.35)',
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
opacity: (syncing || loading) ? 0.6 : 1
}}
>
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync'}
</button>
</div>
</div>
{/* Error banner */}
{syncStatus === 'error' && syncError && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
</div>
)}
{atlasError && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>Atlas: {atlasError}</span>
</div>
)}
{/* Content */}
{loading ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Loading findings</p>
</div>
) : syncStatus === 'never' ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
</div>
) : (
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
{selectedIds.size > 0 && canWrite() && (
<SelectionToolbar
count={selectedIds.size}
workflowType={batchWorkflowType}
vendor={batchVendor}
submitting={batchSubmitting}
error={batchError}
onWorkflowChange={setBatchWorkflowType}
onVendorChange={setBatchVendor}
onSubmit={submitBatch}
onClear={() => { setSelectedIds(new Set()); setBatchError(null); }}
/>
)}
{selectedRowIds.size > 0 && (
<BulkHideToolbar
count={selectedRowIds.size}
onHide={hideSelectedRows}
onClear={() => setSelectedRowIds(new Set())}
onAtlasBulk={() => setBulkAtlasOpen(true)}
canWrite={canWrite()}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{/* Fixed selection checkbox column — row visibility feature */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
textAlign: 'center',
cursor: 'pointer',
}}
onClick={toggleSelectAll}
>
{(() => {
const allVisibleIds = sorted.map(f => String(f.id));
const selectedCount = allVisibleIds.filter(id => selectedRowIds.has(id)).length;
const allSelected = allVisibleIds.length > 0 && selectedCount === allVisibleIds.length;
const someSelected = selectedCount > 0 && !allSelected;
if (allSelected) return <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
if (someSelected) return <MinusSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
return <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />;
})()}
</th>
{/* Fixed hide button column — row visibility feature */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
/>
{/* Fixed checkbox column — not part of column manager */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
textAlign: 'center',
}}
>
{canWrite() && (
<input
type="checkbox"
checked={sorted.length > 0 && sorted.filter((f) => !isQueued(f.id)).length > 0 && sorted.filter((f) => !isQueued(f.id)).every((f) => selectedIds.has(f.id))}
onChange={() => {
const nonQueued = sorted.filter((f) => !isQueued(f.id));
const allSelected = nonQueued.length > 0 && nonQueued.every((f) => selectedIds.has(f.id));
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(nonQueued.map((f) => f.id)));
}
}}
style={{
accentColor: '#0EA5E9',
width: '13px', height: '13px',
cursor: 'pointer',
}}
title="Select all visible findings"
/>
)}
</th>
{visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
const isFiltered = !!columnFilters[col.key];
return (
<th
key={col.key}
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
style={{
padding: '0.5rem 0.75rem', textAlign: 'left',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: active ? '#0EA5E9' : '#64748B',
textTransform: 'uppercase', letterSpacing: '0.08em',
whiteSpace: 'nowrap',
cursor: def?.sortable ? 'pointer' : 'default',
userSelect: 'none',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{def?.label || col.key}
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
{def?.filterable && (
<button
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
onClick={(e) => {
e.stopPropagation();
setOpenFilter(openFilter === col.key ? null : col.key);
}}
title={`Filter ${def.label}`}
style={{
background: 'none', border: 'none',
cursor: 'pointer', padding: '1px 1px 1px 3px',
color: isFiltered ? '#F59E0B' : '#334155',
lineHeight: 1, flexShrink: 0,
transition: 'color 0.15s',
}}
>
<Filter style={{ width: '10px', height: '10px' }} />
</button>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{sorted.map((finding, idx) => {
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 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; }}
>
{/* Selection checkbox cell — row visibility feature */}
<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>
{/* Hide button cell — row visibility feature */}
<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>
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => {
if (queued) return;
// Shift-click range select
if (e.shiftKey && lastClickedId) {
const lastIdx = sorted.findIndex((f) => f.id === lastClickedId);
const currIdx = sorted.findIndex((f) => f.id === finding.id);
if (lastIdx !== -1 && currIdx !== -1) {
const start = Math.min(lastIdx, currIdx);
const end = Math.max(lastIdx, currIdx);
setSelectedIds((prev) => {
const next = new Set(prev);
for (let i = start; i <= end; i++) {
if (!isQueued(sorted[i].id)) next.add(sorted[i].id);
}
return next;
});
}
} else {
// Regular click — toggle selection
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} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
})}
{sorted.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>
)}
</tbody>
</table>
</div>
)}
</div>
{/* Filter dropdown — rendered via portal at document.body */}
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
<FilterDropdown
anchorEl={filterBtnRefs.current[openFilter]}
colKey={openFilter}
findings={visibleFindings}
activeFilter={columnFilters[openFilter] || null}
onFilterChange={(vals) => setColFilter(openFilter, vals)}
onClose={() => setOpenFilter(null)}
/>
)}
{/* Add-to-queue popover — portal */}
{addPopover && (
<AddToQueuePopover
finding={addPopover.finding}
anchorRect={addPopover.anchorRect}
queueForm={queueForm}
setQueueForm={setQueueForm}
onAdd={addToQueue}
onCancel={() => {
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}}
/>
)}
{/* Queue panel — fixed slide-out */}
<QueuePanel
open={queueOpen}
items={queueItems}
onClose={() => setQueueOpen(false)}
onUpdate={updateQueueItem}
onDelete={deleteQueueItem}
onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted}
onCreateFpWorkflow={handleCreateFpWorkflow}
onRedirectComplete={(newItem) => {
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
));
}}
canWrite={canWrite}
fpSubmissions={fpSubmissionsFiltered}
onEditSubmission={handleEditSubmission}
onDismissSubmission={handleDismissSubmission}
cardConfigured={cardConfigured}
cardTeams={cardTeams}
onQueueRefresh={fetchQueue}
/>
<FpWorkflowModal
open={fpModalOpen}
onClose={() => setFpModalOpen(false)}
selectedItems={fpModalItems}
onSuccess={handleFpWorkflowSuccess}
/>
<FpEditModal
open={!!editSubmission}
onClose={() => setEditSubmission(null)}
submission={editSubmission}
queueItems={queueItems}
onSuccess={handleEditSuccess}
/>
<CveTooltip
cveId={tooltipCveId}
anchorRect={tooltipAnchorRect}
cache={tooltipCacheRef}
/>
{atlasPanelOpen && atlasSelectedHostId && (
<AtlasSlideOutPanel
hostId={atlasSelectedHostId}
hostName={atlasSelectedHostName}
findingId={atlasSelectedFindingId}
onClose={() => {
setAtlasPanelOpen(false);
setAtlasSelectedHostId(null);
setAtlasSelectedHostName(null);
setAtlasSelectedFindingId(null);
}}
canWrite={canWrite()}
onPlanChange={fetchAtlasStatus}
/>
)}
{bulkAtlasOpen && (
<BulkAtlasModal
selectedFindings={sorted.filter(f => selectedRowIds.has(String(f.id)))}
onClose={() => setBulkAtlasOpen(false)}
onSuccess={() => { fetchAtlasStatus(); }}
/>
)}
</div>
);
}