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 } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v1'; // --------------------------------------------------------------------------- // Column definitions — source of truth for labels, sort behaviour, rendering // --------------------------------------------------------------------------- const COLUMN_DEFS = { severity: { label: 'Severity', sortable: true, filterable: true }, title: { label: 'Title', sortable: true, filterable: 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 }, lastFoundOn: { label: 'Last Found', sortable: true, filterable: true }, note: { label: 'Notes', sortable: false, filterable: false }, }; const DEFAULT_COLUMN_ORDER = [ { key: 'severity', visible: true }, { key: 'title', 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: '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 '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 'buOwnership': return finding.buOwnership ?? ''; case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'note': return finding.note ?? ''; default: return ''; } } // --------------------------------------------------------------------------- // Filter accessor — severity filters by vrrGroup label, not numeric value // --------------------------------------------------------------------------- function getFilterVal(finding, key) { if (key === 'severity') return finding.vrrGroup || ''; return String(getVal(finding, key) ?? ''); } // --------------------------------------------------------------------------- // 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 SortIcon({ colKey, sort }) { if (sort.field !== colKey) return ; return sort.dir === 'asc' ? : ; } // --------------------------------------------------------------------------- // 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 const allValues = useMemo(() => { const vals = new Set(); findings.forEach((f) => { 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 }) { switch (colKey) { case 'severity': { const sc = severityColor(finding.vrrGroup); return ( {finding.severity?.toFixed(2)} {finding.vrrGroup} ); } case 'title': return ( {finding.title} ); case 'hostName': case 'ipAddress': return ( {finding[colKey] || '—'} ); case 'dns': return ( {finding.dns || '—'} ); 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 'lastFoundOn': return ( {finding.lastFoundOn || '—'} ); case 'note': return ( ); default: return —; } } // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- export default function ReportingPage() { 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 [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); const [columnOrder, setColumnOrder] = useState(loadColumnOrder); const [columnFilters, setColumnFilters] = useState({}); const [openFilter, setOpenFilter] = useState(null); const filterBtnRefs = useRef({}); 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 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); } catch (e) { console.error('Error syncing findings:', e); } finally { setSyncing(false); } }; useEffect(() => { fetchFindings(); }, []); // 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 column filters to produce the visible row set const filtered = useMemo(() => { const active = Object.entries(columnFilters); if (active.length === 0) return findings; return findings.filter((f) => active.every(([key, vals]) => { if (!vals || vals.size === 0) return false; return vals.has(getFilterVal(f, key).trim()); }) ); }, [findings, columnFilters]); // 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; const syncedDisplay = syncedAt ? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}` : 'Never synced'; // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (
{/* ---------------------------------------------------------------- Panel 1 — Metrics placeholder ---------------------------------------------------------------- */}

Metric Graphs

Pie charts & metrics — coming soon

{/* ---------------------------------------------------------------- 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 */}
{activeFilterCount > 0 && ( )}
{/* 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: 'rgba(15,26,46,0.6)', position: 'relative', }} > {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)} /> )}
); }