Files
cve-dashboard/frontend/src/components/pages/ReportingPage.js
jramos e1b0236874 feat: add FP attachment library — attach existing CVE documents to FP submissions
- Add GET /api/ivanti/fp-workflow/documents/search endpoint for querying the document library
- Update POST /api/ivanti/fp-workflow to accept libraryDocIds for attaching library documents on create
- Update POST .../submissions/:id/attachments to accept libraryDocIds on edit
- Add AttachmentSourcePicker component with local upload and library search modes
- Integrate picker into FpWorkflowModal (create) and FpEditModal (edit)
- Track attachment source (local/library) in attachment_results_json for traceability
2026-04-15 15:27:21 -06:00

4777 lines
245 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 CveTooltip from '../CveTooltip';
import RedirectModal from '../RedirectModal';
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.hostName ?? '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return 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.hostName ?? '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return 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>
);
}
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 }) {
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>
</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 }) {
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}
/>
);
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';
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' },
].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 }) {
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);
// 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());
};
const handleRedirectSuccess = (newItem) => {
if (onRedirectComplete) onRedirectComplete(newItem);
setRedirectItem(null);
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
setTimeout(() => setRedirectSuccess(null), 3000);
};
// 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' }
: { 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';
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: '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>
{/* 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>
);
};
// Inventory items (CARD + GRANITE) 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');
const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD');
const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE');
const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE');
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, 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 }) => (
<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 with optional sub-divider */}
{isInventory ? (
<>
{cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
{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 }))}
</>
) : (
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
)}
</div>
))}
</div>
{/* Submissions section */}
{fpSubmissions && fpSubmissions.length > 0 && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Submissions
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{fpSubmissions.length}
</span>
</div>
{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;
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>
</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';
}
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)}
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} 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 notes = [
submission.ivanti_rework_note && { label: 'Rework Note', text: submission.ivanti_rework_note },
submission.ivanti_approval_note && { label: 'Approval Note', text: submission.ivanti_approval_note },
submission.ivanti_current_state_notes && { label: 'Current State Notes', text: submission.ivanti_current_state_notes },
submission.ivanti_previous_state_notes && { label: 'Previous State Notes', text: 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: '#475569', textAlign: 'center', padding: '2rem 0' }}>
No history entries.
</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';
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' },
].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 }) {
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>
{/* 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>
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const { canWrite } = 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);
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 {
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { 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 res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { 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 fetchFindings = async () => {
setLoading(true);
try {
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();
}, []); // 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 };
});
}, []);
// Visible findings — hidden rows removed before any other filtering
const visibleFindings = useMemo(() => {
if (hiddenRowIds.size === 0) return findings;
return findings.filter(f => !hiddenRowIds.has(String(f.id)));
}, [findings, 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]);
// 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 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
));
}
} catch (e) {
console.error('Error adding to queue:', e);
}
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]);
// 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' ? '' : 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
));
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.replace(' ', 'T') + 'Z').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>
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Open vs Closed 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' }}>
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>
</div>
{/* ----------------------------------------------------------------
Panel 1.5 — Open vs Closed trend over time
---------------------------------------------------------------- */}
<IvantiCountsChart />
{/* ----------------------------------------------------------------
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={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>
)}
{/* 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())}
/>
)}
<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} />
))}
</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={fpSubmissions}
onEditSubmission={handleEditSubmission}
/>
<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}
/>
</div>
);
}