import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import ReactDOM from 'react-dom'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; import AnomalyBanner from './AnomalyBanner'; import CveTooltip from '../CveTooltip'; import CardOwnerTooltip from '../CardOwnerTooltip'; import CardDetailModal from '../CardDetailModal'; import RedirectModal from '../RedirectModal'; import RemediationModal from '../RemediationModal'; import AtlasBadge from '../AtlasBadge'; import LoaderModal from '../LoaderModal'; import CardActionModal from '../CardActionModal'; import ConsolidationModal from '../ConsolidationModal'; import AtlasSlideOutPanel from '../AtlasSlideOutPanel'; import AtlasIcon from '../AtlasIcon'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; // Sentinel used in filter Sets to represent cells with no value (blank / —) const EMPTY_SENTINEL = '__EMPTY__'; // --------------------------------------------------------------------------- // Column definitions — source of truth for labels, sort behaviour, rendering // --------------------------------------------------------------------------- const COLUMN_DEFS = { findingId: { label: 'Finding ID', sortable: true, filterable: false }, severity: { label: 'Severity', sortable: true, filterable: true }, title: { label: 'Title', sortable: true, filterable: true }, cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true }, hostName: { label: 'Host', sortable: true, filterable: true }, ipAddress: { label: 'IP Address', sortable: true, filterable: true }, dns: { label: 'DNS', sortable: true, filterable: true }, dueDate: { label: 'Due Date', sortable: true, filterable: true }, slaStatus: { label: 'SLA', sortable: true, filterable: true }, buOwnership: { label: 'BU', sortable: true, filterable: true }, workflow: { label: 'Workflow', sortable: true, filterable: true }, lastFoundOn: { label: 'Last Found', sortable: true, filterable: true }, note: { label: 'Notes', sortable: false, filterable: false }, }; const DEFAULT_COLUMN_ORDER = [ { key: 'findingId', visible: true }, { key: 'severity', visible: true }, { key: 'title', visible: true }, { key: 'cves', visible: true }, { key: 'hostName', visible: true }, { key: 'ipAddress', visible: true }, { key: 'dns', visible: true }, { key: 'dueDate', visible: true }, { key: 'slaStatus', visible: true }, { key: 'buOwnership', visible: true }, { key: 'workflow', visible: true }, { key: 'lastFoundOn', visible: true }, { key: 'note', visible: true }, ]; // --------------------------------------------------------------------------- // Persist / load column config // --------------------------------------------------------------------------- function loadColumnOrder() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); if (saved && Array.isArray(saved)) { const savedKeys = new Set(saved.map((c) => c.key)); const merged = saved.filter((c) => COLUMN_DEFS[c.key]); DEFAULT_COLUMN_ORDER.forEach((d) => { if (!savedKeys.has(d.key)) merged.push({ ...d }); }); return merged; } } catch { /* ignore */ } return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c })); } function saveColumnOrder(order) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ } } // --------------------------------------------------------------------------- // Persist / load hidden row IDs (row visibility feature) // --------------------------------------------------------------------------- const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows'; function loadHiddenRows() { try { const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null'); if (saved && Array.isArray(saved)) return new Set(saved); } catch { /* corrupted — treat as empty */ } return new Set(); } function saveHiddenRows(hiddenSet) { try { localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet])); } catch { /* ignore */ } } // --------------------------------------------------------------------------- // Sort accessor by column key // --------------------------------------------------------------------------- function getVal(finding, key) { switch (key) { case 'findingId': return finding.id ?? ''; case 'severity': return finding.severity ?? 0; case 'title': return finding.title ?? ''; case 'hostName': return finding.overrides?.hostName || finding.hostName || ''; case 'ipAddress': return finding.ipAddress ?? ''; case 'dns': return finding.overrides?.dns || finding.dns || ''; case 'dueDate': return finding.dueDate ?? ''; case 'slaStatus': return finding.slaStatus ?? ''; case 'cves': return (finding.cves || []).length; // sort by CVE count case 'buOwnership': return finding.buOwnership ?? ''; case 'workflow': return finding.workflow?.id ?? ''; case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'note': return finding.note ?? ''; default: return ''; } } // --------------------------------------------------------------------------- // Filter accessor — severity → vrrGroup label; cves handled as multi-value // --------------------------------------------------------------------------- function getFilterVal(finding, key) { if (key === 'severity') return finding.vrrGroup || ''; if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic if (key === 'workflow') return finding.workflow?.id || ''; return String(getVal(finding, key) ?? ''); } // --------------------------------------------------------------------------- // Export value accessor — plain text representation for CSV/XLSX // --------------------------------------------------------------------------- function getExportVal(finding, key) { switch (key) { case 'findingId': return finding.id ?? ''; case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? ''); case 'title': return finding.title ?? ''; case 'cves': return (finding.cves || []).join(', '); case 'hostName': return finding.overrides?.hostName || finding.hostName || ''; case 'ipAddress': return finding.ipAddress ?? ''; case 'dns': return finding.overrides?.dns || finding.dns || ''; case 'dueDate': return finding.dueDate ?? ''; case 'slaStatus': return finding.slaStatus ?? ''; case 'buOwnership': return finding.buOwnership ?? ''; case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : ''; case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'note': return finding.note ?? ''; default: return ''; } } // --------------------------------------------------------------------------- // Action coverage classification — used by chart and filter // --------------------------------------------------------------------------- const EXC_PATTERN = /EXC-\d+/i; function classifyFinding(finding) { if (finding.workflow != null) return 'fp'; if (EXC_PATTERN.test(finding.note || '')) return 'archer'; return 'pending'; } // --------------------------------------------------------------------------- // Style helpers // --------------------------------------------------------------------------- function severityColor(vrrGroup) { switch ((vrrGroup || '').toUpperCase()) { case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' }; case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' }; case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' }; default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' }; } } function slaColor(slaStatus) { switch ((slaStatus || '').toUpperCase()) { case 'OVERDUE': return '#EF4444'; case 'AT_RISK': return '#F59E0B'; case 'WITHIN_SLA': return '#10B981'; default: return '#64748B'; } } function dueDateColor(dueDate) { if (!dueDate) return '#64748B'; const today = new Date(); const due = new Date(dueDate); const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24)); if (diffDays < 0) return '#EF4444'; if (diffDays <= 30) return '#F59E0B'; return '#94A3B8'; } function workflowStyle(state) { // Colors reflect action urgency — all findings here are Open, so Approved won't appear. switch ((state || '').toLowerCase()) { case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state } } function lifecycleStatusBadge(status) { switch ((status || '').toLowerCase()) { case 'submitted': case 'resubmitted': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.4)', text: '#0EA5E9' }; case 'approved': return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.4)', text: '#10B981' }; case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; case 'rework': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; } } // --------------------------------------------------------------------------- // SVG Donut Chart — Open vs Closed findings // --------------------------------------------------------------------------- function polarToCartesian(cx, cy, r, angleDeg) { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; } function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) { // Full circle must be split into two arcs (SVG can't render a 360° arc) if (Math.abs(endDeg - startDeg) >= 359.9) { const mid = startDeg + 180; return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' + donutArcPath(cx, cy, outerR, innerR, mid, endDeg); } const largeArc = endDeg - startDeg > 180 ? 1 : 0; const s = polarToCartesian(cx, cy, outerR, startDeg); const e = polarToCartesian(cx, cy, outerR, endDeg); const si = polarToCartesian(cx, cy, innerR, endDeg); const ei = polarToCartesian(cx, cy, innerR, startDeg); return [ `M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`, `A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`, `L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`, `A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`, 'Z', ].join(' '); } function StatusDonut({ open, closed, loading }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; const OUTER = 72; const INNER = 48; if (loading) { return (
); } const total = open + closed; if (total === 0) { return (

No data — click Sync to load

); } 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 (
{/* Gap ring behind slices */} {segments.map((seg) => ( ))} {/* Center total */} {total.toLocaleString()} TOTAL {/* Legend */}
{segments.map((seg) => (
{seg.label}
{seg.count.toLocaleString()} ({((seg.count / total) * 100).toFixed(1)}%)
))}
); } // --------------------------------------------------------------------------- // 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 (

No data — click Sync to load

); } 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 (
{segments.filter((s) => s.count > 0).map((seg) => { const isActive = activeSegment === seg.key; return ( onSegmentClick(isActive ? null : seg.key)} /> ); })} {total.toLocaleString()} TOTAL {/* Legend — always shows all 3 categories */}
{segments.map((seg) => { const isActive = activeSegment === seg.key; const dimmed = hasActive && !isActive; return (
onSegmentClick(isActive ? null : seg.key)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }} >
{seg.label} {seg.count} ({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
); })} {hasActive && ( )}
); } // --------------------------------------------------------------------------- // 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 (

No FP workflows — click Sync to load

); } 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 (
{segments.map((seg) => ( ))} {total.toLocaleString()} {centerLabel} {/* Legend */}
{segments.map((seg) => (
{seg.label} {seg.count} ({((seg.count / total) * 100).toFixed(0)}%)
))}
); } // --------------------------------------------------------------------------- // Atlas Donut Charts — Coverage, Plan Type, Plan Status // --------------------------------------------------------------------------- const PLAN_TYPE_DEFS = [ { key: 'decommission', label: 'Decommission', color: '#EF4444' }, { key: 'remediation', label: 'Remediation', color: '#0EA5E9' }, { key: 'false_positive', label: 'False Positive', color: '#A855F7' }, { key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' }, { key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' }, ]; function getStatusColor(status) { if (status === 'active') return '#10B981'; if (status === 'expired') return '#EF4444'; if (status === 'completed') return '#0EA5E9'; return '#64748B'; } function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; const OUTER = 72; const INNER = 48; if (totalHosts === 0) { return (

No data — run Atlas Sync

); } const segments = [ { label: 'With Plans', count: hostsWithPlans, color: '#10B981', start: 0, end: (hostsWithPlans / totalHosts) * 360 }, { label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', start: (hostsWithPlans / totalHosts) * 360, end: 360 }, ].filter((s) => s.count > 0); return (
{segments.map((seg) => ( ))} {totalHosts.toLocaleString()} HOSTS {/* Legend */}
{segments.map((seg) => (
{seg.label}
{seg.count.toLocaleString()} ({((seg.count / totalHosts) * 100).toFixed(1)}%)
))}
); } function AtlasPlanTypeDonut({ plansByType, totalPlans }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; const OUTER = 72; const INNER = 48; if (totalPlans === 0) { return (

No plans — run Atlas Sync

); } let cursor = 0; const segments = PLAN_TYPE_DEFS.map((def) => { const count = plansByType[def.key] || 0; const start = cursor; const end = count > 0 ? cursor + (count / totalPlans) * 360 : cursor; if (count > 0) cursor = end; return { ...def, count, start, end }; }).filter(s => s.count > 0); return (
{segments.map((seg) => ( ))} {totalPlans.toLocaleString()} PLANS {/* Legend */}
{segments.map((seg) => (
{seg.label} {seg.count} ({((seg.count / totalPlans) * 100).toFixed(0)}%)
))}
); } function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; const OUTER = 72; const INNER = 48; if (totalPlans === 0) { return (

No plans — run Atlas Sync

); } const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0); let cursor = 0; const segments = entries.map(([status, count]) => { const start = cursor; const end = cursor + (count / totalPlans) * 360; cursor = end; return { key: status, label: status.charAt(0).toUpperCase() + status.slice(1), color: getStatusColor(status), count, start, end, }; }); return (
{segments.map((seg) => ( ))} {totalPlans.toLocaleString()} STATUS {/* Legend */}
{segments.map((seg) => (
{seg.label} {seg.count} ({((seg.count / totalPlans) * 100).toFixed(0)}%)
))}
); } function SortIcon({ colKey, sort }) { if (sort.field !== colKey) return ; return sort.dir === 'asc' ? : ; } // --------------------------------------------------------------------------- // OverrideCell — inline editable hostname/dns with amber dot when overridden // --------------------------------------------------------------------------- function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite, suffix }) { const effective = initialOverride ?? originalValue ?? ''; const [value, setValue] = useState(effective); const [isOverridden, setOverridden] = useState(!!initialOverride); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const lastSaved = useRef(effective); const inputRef = useRef(null); // Sync when the finding updates (e.g. after a full sync) useEffect(() => { const eff = initialOverride ?? originalValue ?? ''; setValue(eff); setOverridden(!!initialOverride); lastSaved.current = eff; }, [initialOverride, originalValue]); useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]); const persist = useCallback(async (newVal) => { const trimmed = newVal.trim(); if (trimmed === lastSaved.current) return; setSaving(true); try { const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field, value: trimmed }), }); if (res.ok) { const data = await res.json(); const cleared = data.value === null; const displayed = cleared ? (originalValue ?? '') : trimmed; setValue(displayed); setOverridden(!cleared); lastSaved.current = displayed; } else { setValue(lastSaved.current); // revert on error } } catch { setValue(lastSaved.current); } finally { setSaving(false); } }, [findingId, field, originalValue]); const handleBlur = () => { setEditing(false); persist(value); }; const handleKeyDown = (e) => { if (e.key === 'Enter') { e.target.blur(); } if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); } }; const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); }; if (editing) { return ( 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', }} /> ); } return ( 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 && ( )} {value || '—'} {saving && } {isOverridden && canWrite && !saving && ( )} {suffix} ); } // --------------------------------------------------------------------------- // NoteCell — inline editable, saves on blur // --------------------------------------------------------------------------- function NoteCell({ findingId, initialNote, onNoteSaved }) { 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; if (onNoteSaved) onNoteSaved(findingId, value); } catch (e) { console.error('Failed to save note:', e); } finally { setSaving(false); } }, [findingId, value, onNoteSaved]); return (
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 && }
); } // --------------------------------------------------------------------------- // 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 (
{open && (
Drag to reorder · click to toggle
{columnOrder.map((col, idx) => { const def = COLUMN_DEFS[col.key]; const isDragging = dragIdx === idx; const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx; return (
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' }} > {def?.label || col.key}
); })}
)}
); } // --------------------------------------------------------------------------- // 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(
{/* Search */} 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 */}
{/* Value checkboxes */}
{displayed.length === 0 ? (
No values
) : displayed.map((val) => ( ))}
{/* Status footer */}
{activeCount} / {allValues.length} selected
, document.body ); } // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick, onNoteSaved }) { switch (colKey) { case 'findingId': return ( {finding.id || '—'} ); case 'severity': { const sc = severityColor(finding.vrrGroup); return ( {finding.severity?.toFixed(2)} {finding.vrrGroup} ); } case 'title': return ( {finding.title} ); case 'cves': { const cves = finding.cves || []; if (cves.length === 0) return —; const shown = cves.slice(0, 2); const rest = cves.length - shown.length; return (
{shown.map((cve) => ( 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} ))} {rest > 0 && ( +{rest} more )}
); } case 'hostName': return ( } /> ); case 'ipAddress': return ( onIpMouseEnter(finding.ipAddress, e, finding.hostId) : undefined} onMouseLeave={onIpMouseLeave || undefined} > {finding.ipAddress || '—'} ); case 'dns': return ( ); case 'dueDate': { const color = dueDateColor(finding.dueDate); return ( {finding.dueDate || '—'} ); } case 'slaStatus': return ( {finding.slaStatus || '—'} ); case 'buOwnership': { const bu = finding.buOwnership || ''; const isSteam = bu.toUpperCase().includes('STEAM'); return ( {bu ? ( {bu.replace('NTS-AEO-', '')} ) : ( )} ); } case 'workflow': { const wf = finding.workflow; if (!wf || !wf.id) return —; const ws = workflowStyle(wf.state); return ( {wf.id} {wf.state} ); } case 'lastFoundOn': return ( {finding.lastFoundOn || '—'} ); case 'note': return ( ); default: return —; } } // --------------------------------------------------------------------------- // AddToQueuePopover — portal-based popover for adding a finding to the queue // --------------------------------------------------------------------------- function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) { const panelRef = useRef(null); const inputRef = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0 }); useEffect(() => { if (!anchorRect) return; const PANEL_W = 260; const PANEL_H = 360; // conservative estimate (3 workflow buttons) const spaceBelow = window.innerHeight - anchorRect.bottom - 6; const top = spaceBelow >= PANEL_H ? anchorRect.bottom + 6 : Math.max(8, anchorRect.top - PANEL_H - 6); const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8); setPos({ top, left }); setTimeout(() => inputRef.current?.focus(), 0); }, [anchorRect]); // Close on outside click useEffect(() => { const handler = (e) => { if (panelRef.current && !panelRef.current.contains(e.target)) onCancel(); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [onCancel]); // Close on Escape useEffect(() => { const handler = (e) => { if (e.key === 'Escape') onCancel(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [onCancel]); const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE' || queueForm.workflowType === 'DECOM'; const canSubmit = isCard || queueForm.vendor.trim().length > 0; return ReactDOM.createPortal(
{/* Header */}
Add to Ivanti Queue
{finding.id}
{/* Vendor input — hidden for CARD */} {isCard ? (
No vendor required — disposition handled in CARD
) : ( )} {/* Workflow type toggle */}
Workflow Type
{[ { key: 'FP', col: '#F59E0B', rgb: '245,158,11' }, { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, { key: 'CARD', col: '#10B981', rgb: '16,185,129' }, { key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, { key: 'DECOM', col: '#EF4444', rgb: '239,68,68' }, { key: 'Remediate', col: '#A855F7', rgb: '168,85,247' }, ].map(({ key, col, rgb }) => { const active = queueForm.workflowType === key; return ( ); })}
{/* Actions */}
, document.body ); } // --------------------------------------------------------------------------- // QueuePanel — fixed slide-out panel showing the user's Ivanti queue // --------------------------------------------------------------------------- function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, onDismissSubmission, cardConfigured, cardTeams, onQueueRefresh }) { const pendingCount = items.filter((i) => i.status === 'pending').length; const completedCount = items.filter((i) => i.status === 'complete').length; const [selectedIds, setSelectedIds] = useState(new Set()); const [redirectItem, setRedirectItem] = useState(null); const [redirectSuccess, setRedirectSuccess] = useState(null); // CARD action state — tracks which item has an active action form const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' } const [cardFormTeam, setCardFormTeam] = useState(''); const [cardFormComment, setCardFormComment] = useState(''); const [cardFormFromTeam, setCardFormFromTeam] = useState(''); const [cardFormToTeam, setCardFormToTeam] = useState(''); const [cardActionLoading, setCardActionLoading] = useState(false); const [cardActionError, setCardActionError] = useState(null); // Granite Loader Sheet modal state const [showLoaderModal, setShowLoaderModal] = useState(false); // CARD Action Modal state const [cardModalItem, setCardModalItem] = useState(null); const [cardModalAction, setCardModalAction] = useState('confirm'); // Create Jira modal state const [createJiraOpen, setCreateJiraOpen] = useState(false); const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' }); const [createJiraError, setCreateJiraError] = useState(null); // Remediation Modal state const [remediationModalItem, setRemediationModalItem] = useState(null); const [createJiraSaving, setCreateJiraSaving] = useState(false); const [createJiraSummaryError, setCreateJiraSummaryError] = useState(null); // Consolidated Jira ticket modal state (multi-item → 1 ticket) const [showConsolidationModal, setShowConsolidationModal] = useState(false); const [consolidationSuccess, setConsolidationSuccess] = useState(null); // { ticket_key, jira_url } // CARD Asset Search state const [assetSearchOpen, setAssetSearchOpen] = useState(false); const [assetSearchTeam, setAssetSearchTeam] = useState(''); const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed'); const [assetSearchResults, setAssetSearchResults] = useState(null); const [assetSearchLoading, setAssetSearchLoading] = useState(false); const [assetSearchError, setAssetSearchError] = useState(null); const [assetSearchPage, setAssetSearchPage] = useState(1); // Drop any selected IDs that no longer exist in items useEffect(() => { setSelectedIds((prev) => { if (prev.size === 0) return prev; const valid = new Set(items.map((i) => i.id)); const next = new Set([...prev].filter((id) => valid.has(id))); return next.size === prev.size ? prev : next; }); }, [items]); const toggleSelect = (id) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const handleDeleteSelected = () => { onDeleteMany([...selectedIds]); setSelectedIds(new Set()); }; // Submissions section — collapsible state (Task 6) const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true'); const [dismissError, setDismissError] = useState(null); // Collapsible section state for queue groups const [collapsedSections, setCollapsedSections] = useState({}); const toggleSectionCollapse = (sectionKey) => { setCollapsedSections((prev) => ({ ...prev, [sectionKey]: !prev[sectionKey] })); }; const toggleSubmissionsCollapsed = () => { setSubmissionsCollapsed(prev => { const next = !prev; try { localStorage.setItem('steam_submissions_collapsed', String(next)); } catch { /* ignore */ } return next; }); }; // Dismiss handler (Task 5) const handleDismiss = async (e, submissionId) => { e.stopPropagation(); setDismissError(null); try { const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submissionId}/dismiss`, { method: 'PATCH', credentials: 'include', }); if (!res.ok) { const data = await res.json().catch(() => ({})); setDismissError(data.error || 'Failed to dismiss submission'); return; } if (onDismissSubmission) onDismissSubmission(submissionId); } catch (err) { setDismissError('Network error — could not dismiss submission'); } }; const handleRedirectSuccess = (newItem) => { if (onRedirectComplete) onRedirectComplete(newItem); setRedirectItem(null); setRedirectSuccess(`Redirected to ${newItem.workflow_type}`); setTimeout(() => setRedirectSuccess(null), 3000); }; // CARD action handlers — open the CardActionModal instead of inline form const openCardAction = (itemId, type) => { const targetItem = items.find(i => i.id === itemId); if (targetItem) { setCardModalItem(targetItem); setCardModalAction(type); } }; const closeCardAction = () => { setCardAction(null); setCardFormTeam(''); setCardFormComment(''); setCardFormFromTeam(''); setCardFormToTeam(''); setCardActionError(null); setCardActionLoading(false); }; const handleCardConfirmDecline = async (item, actionType) => { if (!cardFormTeam) return; if (!item.ip_address) { setCardActionError('No IP address on this queue item — cannot resolve CARD asset.'); return; } setCardActionLoading(true); setCardActionError(null); try { const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ teamName: cardFormTeam, assetId: item.ip_address, comment: cardFormComment || '', }), }); const data = await res.json(); if (!res.ok) { const errorMsg = data.error || data.message || (typeof data === 'string' ? data : `${actionType} failed.`); setCardActionError(errorMsg); setCardActionLoading(false); return; } // Update local state to complete without full refresh onUpdate(item.id, { status: 'complete' }); closeCardAction(); } catch (err) { setCardActionError(err.message || 'Network error.'); setCardActionLoading(false); } }; const handleCardRedirect = async (item) => { if (!cardFormFromTeam || !cardFormToTeam) return; if (!item.ip_address) { setCardActionError('No IP address on this queue item — cannot resolve CARD asset.'); return; } setCardActionLoading(true); setCardActionError(null); try { const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fromTeam: cardFormFromTeam, toTeam: cardFormToTeam, assetId: item.ip_address, }), }); const data = await res.json(); if (!res.ok) { const errorMsg = data.error || data.message || (typeof data === 'string' ? data : JSON.stringify(data)); setCardActionError(errorMsg); setCardActionLoading(false); return; } onUpdate(item.id, { status: 'complete' }); closeCardAction(); } catch (err) { setCardActionError(err.message || 'Network error.'); setCardActionLoading(false); } }; // CARD Asset Search handler const handleAssetSearch = async (page = 1) => { if (!assetSearchTeam || !assetSearchDisposition) return; setAssetSearchLoading(true); setAssetSearchError(null); setAssetSearchPage(page); try { const res = await fetch( `${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`, { credentials: 'include' } ); const data = await res.json(); if (!res.ok) { setAssetSearchError(data.error || 'Search failed.'); setAssetSearchLoading(false); return; } setAssetSearchResults(data); } catch (err) { setAssetSearchError(err.message || 'Network error.'); } finally { setAssetSearchLoading(false); } }; // Open Create Jira modal pre-populated from a queue item const openCreateJiraFromQueue = async (item) => { // Parse cves_json — it may be a JSON string or already an array let cves = []; if (item.cves_json) { try { cves = typeof item.cves_json === 'string' ? JSON.parse(item.cves_json) : item.cves_json; } catch { cves = []; } } else if (item.cves && Array.isArray(item.cves)) { cves = item.cves; } const firstCve = (Array.isArray(cves) && cves.length > 0) ? cves[0] : ''; const summary = (item.finding_title || '').slice(0, 255); // Build description — include finding details and remediation notes for Remediate items let description = ''; // Always include finding info in description const cveList = (Array.isArray(cves) && cves.length > 0) ? cves.join(', ') : 'None'; description += `== ${item.vendor || 'Unknown Vendor'} ==\n`; description += `- ${item.finding_title || 'Untitled'}\n`; description += ` CVEs: ${cveList}\n`; description += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n`; if (item.workflow_type === 'Remediate') { try { const notesRes = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' }); if (notesRes.ok) { const notes = await notesRes.json(); if (notes.length > 0) { const sorted = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); description += '\n== Remediation Notes ==\n'; for (const note of sorted) { const date = note.created_at ? note.created_at.slice(0, 10) : 'Unknown'; description += `[${date}] ${note.username}: ${note.note_text}\n`; } } } } catch (_) { /* best-effort */ } } setCreateJiraForm({ summary, cve_id: firstCve, vendor: item.vendor || '', source_context: 'ivanti_queue', description, project_key: '', issue_type: '', }); setCreateJiraError(null); setCreateJiraSummaryError(null); setCreateJiraOpen(true); }; // Submit the Create Jira form const submitCreateJira = async () => { const trimmedSummary = (createJiraForm.summary || '').trim(); if (!trimmedSummary) { setCreateJiraSummaryError('Summary is required.'); return; } if (trimmedSummary.length > 255) { setCreateJiraSummaryError('Summary must be 255 characters or fewer.'); return; } setCreateJiraSummaryError(null); setCreateJiraError(null); setCreateJiraSaving(true); try { const payload = { ...createJiraForm }; if (!payload.source_context) delete payload.source_context; const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); setCreateJiraOpen(false); setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' }); } catch (err) { setCreateJiraError(err.message); } finally { setCreateJiraSaving(false); } }; // Render a single queue item row const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => { const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } : item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' } : item.workflow_type === 'DECOM' ? { col: '#EF4444', rgb: '239,68,68' } : item.workflow_type === 'Remediate' ? { col: '#A855F7', rgb: '168,85,247' } : { col: '#10B981', rgb: '16,185,129' }; const cves = item.cves || []; const cveDisplay = cves.length > 0 ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') : '—'; const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE' || item.workflow_type === 'DECOM'; return (
{/* Selection checkbox — for bulk delete */} 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 */} onUpdate(item.id, { status: done ? 'pending' : 'complete' })} style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }} /> {/* Content */}
{item.finding_id}
{isInventoryItem ? ( <> {item.hostname && (
{item.hostname}
)} {item.ip_address && (
{item.ip_address}
)} {cves.length > 0 && (
{cveDisplay}
)} ) : ( <> {cves.length > 0 && (
{cveDisplay}
)} {item.hostname && (
{item.hostname}
)} {item.ip_address && (
{item.ip_address}
)} )}
{/* Workflow type badge */} {item.workflow_type} {/* CARD action buttons — pending CARD items only */} {item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
)} {/* Remediation Notes button — Remediate items only */} {item.workflow_type === 'Remediate' && ( )} {/* Redirect button — available on all items */} {canWrite && ( )} {/* Create Jira Ticket button — pending items only */} {canWrite && !done && ( )} {/* Delete button */}
); }; // Render CARD action inline form below a queue item const renderCardActionForm = (item) => { if (!cardAction || cardAction.itemId !== item.id) return null; const { type } = cardAction; if (type === 'confirm' || type === 'decline') { const accentColor = type === 'confirm' ? '#10B981' : '#EF4444'; const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68'; const canSubmit = !cardActionLoading && cardFormTeam.length > 0; return (
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
setCardFormComment(e.target.value)} disabled={cardActionLoading} placeholder="Comment (optional)" maxLength={500} style={{ width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.6)', border: `1px solid rgba(${accentRgb}, 0.15)`, borderRadius: '0.25rem', padding: '0.35rem 0.5rem', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem', color: '#E2E8F0', outline: 'none', marginBottom: '0.375rem', }} /> {cardActionError && (
{cardActionError}
)}
); } if (type === 'redirect') { const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0; return (
Redirect Asset
{cardActionError && (
{cardActionError}
)}
); } return null; }; // Inventory items (CARD + GRANITE + DECOM) are their own top section; everything else groups by vendor const grouped = useMemo(() => { const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE' || i.workflow_type === 'DECOM'); const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD'); const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE'); const decomItems = inventoryItems.filter((i) => i.workflow_type === 'DECOM'); const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE' && i.workflow_type !== 'DECOM'); const map = {}; otherItems.forEach((item) => { const v = item.vendor || 'Unknown'; if (!map[v]) map[v] = []; map[v].push(item); }); const vendorGroups = Object.keys(map).sort().map((vendor) => ({ key: vendor, label: vendor, items: map[vendor], isInventory: false, })); return inventoryItems.length > 0 ? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, decomItems, items: inventoryItems, isInventory: true }, ...vendorGroups] : vendorGroups; }, [items]); return ( <> {/* Backdrop */} {open && (
)} {/* Panel */}
{/* Header */}
Ivanti Queue {pendingCount > 0 && ( {pendingCount} )}
{/* Body */}
{items.length === 0 ? (
No items in queue.
Check a row in the findings table to add it.
) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems, decomItems }) => (
{/* Group header — clickable to collapse/expand */}
toggleSectionCollapse(key)} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.3rem 0', marginBottom: '0.375rem', borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`, cursor: 'pointer', userSelect: 'none', }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSectionCollapse(key); } }} aria-expanded={!collapsedSections[key]} aria-label={`${label} section, ${groupItems.length} items`} > {collapsedSections[key] ? : } {label} {groupItems.length}
{/* Items — only rendered when section is expanded */} {!collapsedSections[key] && (isInventory ? ( <> {cardItems.map((item) => ( {renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })} {renderCardActionForm(item)} ))} {cardItems.length > 0 && graniteItems.length > 0 && (
)} {graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} {(cardItems.length > 0 || graniteItems.length > 0) && decomItems.length > 0 && (
)} {decomItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} ) : ( groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })) ))}
))}
{/* CARD Asset Search section */} {cardConfigured && (
setAssetSearchOpen(!assetSearchOpen)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.3rem 0', marginBottom: '0.375rem', borderBottom: '1px solid rgba(14,165,233,0.2)', cursor: 'pointer', }} >
CARD Asset Search
{assetSearchOpen ? : }
{assetSearchOpen && (
{/* Error */} {assetSearchError && (
{assetSearchError}
)} {/* Results */} {assetSearchResults && (
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
{/* Results table */} {Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => ( ))} {assetSearchResults.assets.map((asset, idx) => { const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3); return ( {extraKeys.map(k => ( ))} ); })}
Asset ID {k.replace(/_/g, ' ')}
{asset.asset_id || asset._id || '—'} {typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
) : (
No assets found.
)} {/* Pagination */} {assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
)}
)}
)}
)} {/* Submissions section */} {fpSubmissions && fpSubmissions.length > 0 && (
{submissionsCollapsed ? : } Submissions
{fpSubmissions.length}
{dismissError && (
{dismissError}
)} {!submissionsCollapsed && fpSubmissions.map((sub) => { const lsBadge = lifecycleStatusBadge(sub.lifecycle_status); const findingCount = (() => { try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; } })(); const clickable = canWrite && onEditSubmission; const showDismiss = sub.lifecycle_status === 'rejected' && !sub.dismissed_at; return (
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} >
{sub.workflow_name || `Batch ${sub.ivanti_workflow_batch_id}`}
#{sub.ivanti_workflow_batch_id} {findingCount} finding{findingCount !== 1 ? 's' : ''} {sub.created_at ? new Date(sub.created_at).toLocaleDateString() : ''}
{sub.lifecycle_status || 'submitted'} {showDismiss && ( )}
); })}
)} {/* Footer */}
{/* Create FP Workflow — visible for editor/admin only */} {canWrite && (() => { const fpEnabled = isCreateFpButtonEnabled(items, selectedIds); return ( ); })()} {/* Delete selected — only shown when items are selected */} {selectedIds.size > 0 && ( )} {/* Generate Loader Sheet — visible when CARD/GRANITE/DECOM items are selected or as standalone */} {(() => { const selectedCardGranite = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)); const hasCardGraniteItems = items.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)); const isEnabled = selectedCardGranite.length > 0 || hasCardGraniteItems; return isEnabled ? ( ) : null; })()} {selectedIds.size > 0 && ( )}
{/* Redirect success notification */} {redirectSuccess && (
{redirectSuccess}
)} {/* Consolidated Jira ticket success notification */} {consolidationSuccess && ( )} {/* Redirect modal */} {redirectItem && ( setRedirectItem(null)} onRedirect={handleRedirectSuccess} /> )} {/* Remediation Notes modal */} {remediationModalItem && ( setRemediationModalItem(null)} onNoteAdded={() => { if (onQueueRefresh) onQueueRefresh(); }} /> )} {/* Create Jira Ticket modal */} {createJiraOpen && (
setCreateJiraOpen(false)} />

Create Issue in Jira

Creates a new Jira issue from this Ivanti queue item.

{createJiraError &&
{createJiraError}
}
{ setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (createJiraSummaryError) setCreateJiraSummaryError(null); }} maxLength={255} /> {createJiraSummaryError &&
{createJiraSummaryError}
}
setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />