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 } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; // --------------------------------------------------------------------------- // 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 */ } } // --------------------------------------------------------------------------- // 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 } } // --------------------------------------------------------------------------- // 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 && ( )}
); } 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 }) { 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 && ( )} ); } // --------------------------------------------------------------------------- // 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 (
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. const allValues = useMemo(() => { const def = COLUMN_DEFS[colKey]; const vals = new Set(); findings.forEach((f) => { if (def?.multiValue) { (f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); }); } else { const v = getFilterVal(f, colKey).trim(); if (v) vals.add(v); } }); return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); }, [findings, colKey]); const displayed = search.trim() ? allValues.filter((v) => 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 }) { 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) => ( {cve} ))} {rest > 0 && ( +{rest} more )}
); } case 'hostName': return ( ); case 'ipAddress': return ( {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 —; } } // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- export default function ReportingPage({ 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 [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 updateColumns = useCallback((newOrder) => { setColumnOrder(newOrder); saveColumnOrder(newOrder); }, []); 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 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); } 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); fetchCounts(); // refresh counts after sync } } catch (e) { console.error('Error syncing findings:', e); } finally { setSyncing(false); } }; useEffect(() => { fetchFindings(); fetchCounts(); }, []); // 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 }; }); }, []); // Apply all active filters to produce the visible row set const filtered = useMemo(() => { let result = findings; // 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) { return (f[key] || []).some((v) => vals.has(String(v).trim())); } return vals.has(getFilterVal(f, key).trim()); }) ); } // 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; }, [findings, 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]); 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); 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 (
{/* ---------------------------------------------------------------- Panel 1 — Metrics placeholder ---------------------------------------------------------------- */}

Metric Graphs

{/* Open vs Closed donut */}
Open vs Closed
{/* Divider */}
{/* Action Coverage donut */}
Action Coverage {actionFilter && d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered}
{ setExcFilter(null); setActionFilter(key); }} />
{/* ---------------------------------------------------------------- Panel 2 — Findings table ---------------------------------------------------------------- */}
{/* Panel header */}

Host Findings

{syncedDisplay} {syncStatus === 'success' && total !== null && ( {activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings {activeFilterCount > 0 && ( ({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active) )} )}
{/* Action buttons */}
{/* EXC filter badge (from home page navigation) */} {excFilter && ( )} {/* Action coverage filter badge (from chart click) */} {actionFilter && ( )} {Object.keys(columnFilters).length > 0 && ( )} {/* Export dropdown */}
{exportMenuOpen && (
{[ { label: 'CSV (.csv)', action: exportCSV }, { label: 'Excel (.xlsx)', action: exportXLSX }, ].map(({ label, action }) => ( ))}
)}
{/* Error banner */} {syncStatus === 'error' && syncError && (
{syncError}
)} {/* Content */} {loading ? (

Loading findings…

) : syncStatus === 'never' ? (

Click Sync to load findings data

) : (
{visibleCols.map((col) => { const def = COLUMN_DEFS[col.key]; const active = sort.field === col.key; const isFiltered = !!columnFilters[col.key]; return ( ); })} {sorted.map((finding, idx) => { const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'; return ( e.currentTarget.style.background = 'rgba(14,165,233,0.05)'} onMouseLeave={(e) => e.currentTarget.style.background = rowBg} > {visibleCols.map((col) => ( ))} ); })} {sorted.length === 0 && ( )}
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)', }} > {def?.label || col.key} {def?.sortable && } {def?.filterable && ( )}
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
)}
{/* Filter dropdown — rendered via portal at document.body */} {openFilter && COLUMN_DEFS[openFilter]?.filterable && ( setColFilter(openFilter, vals)} onClose={() => setOpenFilter(null)} /> )}
); }