5 Commits

Author SHA1 Message Date
ade3cc25ad Merge feature/reporting-page: Add CVEs column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:05 -06:00
3fd6158eb3 feat(reporting): add CVEs column from vulnerabilities.vulnInfoList
- Backend extracts cves[] array from f.vulnerabilities.vulnInfoList[].cve
- Frontend shows up to 2 CVE badges (purple) with "+N more" overflow tooltip
- Filter is multi-value aware: selecting a CVE matches any finding containing it
- FilterDropdown expands multi-value arrays into individual checkbox options
- Sort by CVE count (number of associated CVEs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:01 -06:00
5bbaaf5918 Merge feature/reporting-page: BU Ownership column + column filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:20 -06:00
1f36d302ea feat(reporting): add BU Ownership column and per-column Excel-style filters
- buOwnership field extracted from assetCustomAttributes['1550_host_1'][0]
  and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG)
- All columns except Notes get a funnel filter button in the header
- FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape
  overflowX:auto clipping; shows unique value checkboxes with search input,
  Select All, Clear, and a selected/total count footer
- Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value
- columnFilters state gates a useMemo filtered array before sorting
- Active filter count shown in panel header with amber badge; Clear Filters
  button appears in the toolbar when any filters are active
- Empty Set filter (Clear All) hides all rows, consistent with Excel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:17 -06:00
8697ba4ef3 Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source
Backend:
- Extract dueDate from statusEmbedded.dueDate (strip time portion)
- Remove discoveredOn and source from extractFinding (not needed)

Frontend:
- Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future)
- Remove Discovered and Source columns
- ColumnManager component: gear button opens popover with drag-to-reorder and
  eye toggle per column; column state persisted to localStorage
- Column order/visibility survives page refresh and syncs
- SortIcon, TableCell, NoteCell all driven by current visible column list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:47:11 -06:00
2 changed files with 691 additions and 183 deletions

View File

@@ -120,6 +120,16 @@ function initTables(db) {
// Extract only the fields we need from a raw finding object // Extract only the fields we need from a raw finding object
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function extractFinding(f) { function extractFinding(f) {
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
const rawDueDate = f.statusEmbedded?.dueDate || '';
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
// CVE list: vulnerabilities.vulnInfoList[].cve
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
return { return {
id: String(f.id), id: String(f.id),
title: f.title || '', title: f.title || '',
@@ -130,11 +140,10 @@ function extractFinding(f) {
dns: f.dns || f.host?.fqdn || '', dns: f.dns || f.host?.fqdn || '',
status: f.status || '', status: f.status || '',
slaStatus: f.slaStatus || '', slaStatus: f.slaStatus || '',
discoveredOn: f.discoveredOn || '', dueDate,
lastFoundOn: f.lastFoundOn || '', lastFoundOn: f.lastFoundOn || '',
source: f.scannerPrettyName || f.scannerName || f.source || '', buOwnership,
pluginFamily: f.pluginFamily || '', cves
findingType: f.findingType || ''
}; };
} }

View File

@@ -1,26 +1,94 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-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 API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v1';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Column definitions // Column definitions — source of truth for labels, sort behaviour, rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const COLUMNS = [ const COLUMN_DEFS = {
{ key: 'severity', label: 'Severity', accessor: (f) => f.severity, sortable: true }, severity: { label: 'Severity', sortable: true, filterable: true },
{ key: 'title', label: 'Title', accessor: (f) => f.title, sortable: true }, title: { label: 'Title', sortable: true, filterable: true },
{ key: 'hostName', label: 'Host', accessor: (f) => f.hostName, sortable: true }, cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
{ key: 'ipAddress', label: 'IP Address', accessor: (f) => f.ipAddress, sortable: true }, hostName: { label: 'Host', sortable: true, filterable: true },
{ key: 'dns', label: 'DNS', accessor: (f) => f.dns, sortable: true }, ipAddress: { label: 'IP Address', sortable: true, filterable: true },
{ key: 'slaStatus', label: 'SLA', accessor: (f) => f.slaStatus, sortable: true }, dns: { label: 'DNS', sortable: true, filterable: true },
{ key: 'discoveredOn',label: 'Discovered', accessor: (f) => f.discoveredOn,sortable: true }, dueDate: { label: 'Due Date', sortable: true, filterable: true },
{ key: 'lastFoundOn', label: 'Last Found', accessor: (f) => f.lastFoundOn, sortable: true }, slaStatus: { label: 'SLA', sortable: true, filterable: true },
{ key: 'source', label: 'Source', accessor: (f) => f.source, sortable: true }, buOwnership: { label: 'BU', sortable: true, filterable: true },
{ key: 'note', label: 'Notes', accessor: (f) => f.note, sortable: false }, 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: '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: 'lastFoundOn', visible: true },
{ key: 'note', visible: true },
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // 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 'cves': return (finding.cves || []).length; // sort by CVE count
case 'buOwnership': return finding.buOwnership ?? '';
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
return String(getVal(finding, key) ?? '');
}
// ---------------------------------------------------------------------------
// Style helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function severityColor(vrrGroup) { function severityColor(vrrGroup) {
switch ((vrrGroup || '').toUpperCase()) { switch ((vrrGroup || '').toUpperCase()) {
@@ -35,16 +103,26 @@ function slaColor(slaStatus) {
switch ((slaStatus || '').toUpperCase()) { switch ((slaStatus || '').toUpperCase()) {
case 'OVERDUE': return '#EF4444'; case 'OVERDUE': return '#EF4444';
case 'AT_RISK': return '#F59E0B'; case 'AT_RISK': return '#F59E0B';
case 'OK': return '#10B981'; case 'WITHIN_SLA': return '#10B981';
default: return '#64748B'; 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 }) { function SortIcon({ colKey, sort }) {
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '12px', height: '12px', opacity: 0.3, marginLeft: '4px', flexShrink: 0 }} />; if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
return sort.dir === 'asc' return sort.dir === 'asc'
? <ChevronUp style={{ width: '12px', height: '12px', color: '#0EA5E9', marginLeft: '4px', flexShrink: 0 }} /> ? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
: <ChevronDown style={{ width: '12px', height: '12px', color: '#0EA5E9', marginLeft: '4px', flexShrink: 0 }} />; : <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -53,9 +131,15 @@ function SortIcon({ colKey, sort }) {
function NoteCell({ findingId, initialNote }) { function NoteCell({ findingId, initialNote }) {
const [value, setValue] = useState(initialNote || ''); const [value, setValue] = useState(initialNote || '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const lastSaved = useRef(initialNote || '');
useEffect(() => {
setValue(initialNote || '');
lastSaved.current = initialNote || '';
}, [initialNote]);
const save = useCallback(async () => { const save = useCallback(async () => {
if (value === (initialNote || '')) return; // nothing changed if (value === lastSaved.current) return;
setSaving(true); setSaving(true);
try { try {
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, { await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
@@ -64,12 +148,13 @@ function NoteCell({ findingId, initialNote }) {
credentials: 'include', credentials: 'include',
body: JSON.stringify({ note: value }) body: JSON.stringify({ note: value })
}); });
lastSaved.current = value;
} catch (e) { } catch (e) {
console.error('Failed to save note:', e); console.error('Failed to save note:', e);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [findingId, value, initialNote]); }, [findingId, value]);
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -81,27 +166,403 @@ function NoteCell({ findingId, initialNote }) {
onBlur={save} onBlur={save}
placeholder="Add note…" placeholder="Add note…"
style={{ style={{
width: '100%', width: '100%', minWidth: '160px',
minWidth: '160px',
background: 'rgba(14,165,233,0.05)', background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '4px', borderRadius: '4px', padding: '4px 8px',
padding: '4px 8px', color: '#CBD5E1', fontSize: '0.75rem',
color: '#CBD5E1', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
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)'; }} onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
/> />
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />} {saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
</div> </div>
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main ReportingPage component // ColumnManager — popover with drag-to-reorder and show/hide toggles
// ---------------------------------------------------------------------------
function ColumnManager({ columnOrder, onChange }) {
const [open, setOpen] = useState(false);
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const panelRef = useRef(null);
const btnRef = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const toggleVisible = (key) => {
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
};
const handleDragStart = (idx) => setDragIdx(idx);
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
const handleDrop = (idx) => {
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
const updated = [...columnOrder];
const [moved] = updated.splice(dragIdx, 1);
updated.splice(idx, 0, moved);
onChange(updated);
setDragIdx(null);
setOverIdx(null);
};
const visibleCount = columnOrder.filter((c) => c.visible).length;
return (
<div style={{ position: 'relative' }}>
<button
ref={btnRef}
onClick={() => setOpen((p) => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Settings2 style={{ width: '13px', height: '13px' }} />
Columns
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
</button>
{open && (
<div
ref={panelRef}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: '220px', zIndex: 100,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '0.5rem'
}}
>
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
Drag to reorder · click to toggle
</div>
{columnOrder.map((col, idx) => {
const def = COLUMN_DEFS[col.key];
const isDragging = dragIdx === idx;
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
return (
<div
key={col.key}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={() => handleDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
opacity: isDragging ? 0.4 : 1,
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
transition: 'background 0.1s'
}}
>
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
{def?.label || col.key}
</span>
<button
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
>
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// FilterDropdown — portal-based so it escapes overflow:auto clipping
// ---------------------------------------------------------------------------
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [search, setSearch] = useState('');
const panelRef = useRef(null);
const inputRef = useRef(null);
// Compute fixed position from anchor button's viewport rect
useEffect(() => {
if (!anchorEl) return;
const r = anchorEl.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorEl]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) &&
!(anchorEl && anchorEl.contains(e.target))) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Unique values from the full (unfiltered) findings list.
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
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(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '220px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.3)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.5rem',
}}
>
{/* Search */}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search values…"
style={{
width: '100%', marginBottom: '0.375rem',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
color: '#CBD5E1', fontSize: '0.72rem',
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
}}
/>
{/* Select All / Clear */}
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={() => onFilterChange(null)}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Select All
</button>
<button
onClick={() => onFilterChange(new Set())}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Clear
</button>
</div>
{/* Value checkboxes */}
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{displayed.length === 0 ? (
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
) : displayed.map((val) => (
<label
key={val}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<input
type="checkbox"
checked={isChecked(val)}
onChange={() => toggle(val)}
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
/>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</label>
))}
</div>
{/* Status footer */}
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
{activeCount} / {allValues.length} selected
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding }) {
switch (colKey) {
case 'severity': {
const sc = severityColor(finding.vrrGroup);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
{finding.severity?.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
</span>
</td>
);
}
case 'title':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
{finding.title}
</span>
</td>
);
case 'cves': {
const cves = finding.cves || [];
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}></td>;
const shown = cves.slice(0, 2);
const rest = cves.length - shown.length;
return (
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{shown.map((cve) => (
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
{cve}
</span>
))}
{rest > 0 && (
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
+{rest} more
</span>
)}
</div>
</td>
);
}
case 'hostName':
case 'ipAddress':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding[colKey] || '—'}
</td>
);
case 'dns':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
{finding.dns || '—'}
</span>
</td>
);
case 'dueDate': {
const color = dueDateColor(finding.dueDate);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
{finding.dueDate || '—'}
</td>
);
}
case 'slaStatus':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
{finding.slaStatus || '—'}
</td>
);
case 'buOwnership': {
const bu = finding.buOwnership || '';
const isSteam = bu.toUpperCase().includes('STEAM');
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
{bu ? (
<span
title={bu}
style={{
display: 'inline-block', padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: isSteam ? '#0EA5E9' : '#F59E0B',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
}}
>
{bu.replace('NTS-AEO-', '')}
</span>
) : (
<span style={{ color: '#475569' }}></span>
)}
</td>
);
}
case 'lastFoundOn':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.lastFoundOn || '—'}
</td>
);
case 'note':
return (
<td style={{ padding: '0.45rem 0.75rem' }}>
<NoteCell findingId={finding.id} initialNote={finding.note} />
</td>
);
default:
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}></td>;
}
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ReportingPage() { export default function ReportingPage() {
const [findings, setFindings] = useState([]); const [findings, setFindings] = useState([]);
@@ -112,6 +573,15 @@ export default function ReportingPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); 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) => { const applyState = (data) => {
setTotal(data.total ?? 0); setTotal(data.total ?? 0);
@@ -137,10 +607,7 @@ export default function ReportingPage() {
const syncFindings = async () => { const syncFindings = async () => {
setSyncing(true); setSyncing(true);
try { try {
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
method: 'POST',
credentials: 'include'
});
const data = await res.json(); const data = await res.json();
if (res.ok) applyState(data); if (res.ok) applyState(data);
} catch (e) { } catch (e) {
@@ -152,12 +619,42 @@ export default function ReportingPage() {
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
// Sort findings // Set/clear a single column filter
const sorted = [...findings].sort((a, b) => { const setColFilter = useCallback((colKey, vals) => {
const col = COLUMNS.find((c) => c.key === sort.field); setColumnFilters((prev) => {
if (!col) return 0; if (!vals) {
const av = col.accessor(a) ?? ''; const next = { ...prev };
const bv = col.accessor(b) ?? ''; 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;
const def = COLUMN_DEFS[key];
if (def?.multiValue) {
// Row matches if ANY of its values is in the selected set
return (f[key] || []).some((v) => vals.has(String(v).trim()));
}
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; let cmp = 0;
if (typeof av === 'number' && typeof bv === 'number') { if (typeof av === 'number' && typeof bv === 'number') {
cmp = av - bv; cmp = av - bv;
@@ -165,7 +662,7 @@ export default function ReportingPage() {
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true }); cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
} }
return sort.dir === 'asc' ? cmp : -cmp; return sort.dir === 'asc' ? cmp : -cmp;
}); }), [filtered, sort]);
const toggleSort = (key) => { const toggleSort = (key) => {
setSort((prev) => setSort((prev) =>
@@ -175,8 +672,10 @@ export default function ReportingPage() {
); );
}; };
const activeFilterCount = Object.keys(columnFilters).length;
const syncedDisplay = syncedAt const syncedDisplay = syncedAt
? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString() ? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
: 'Never synced'; : 'Never synced';
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -186,7 +685,7 @@ export default function ReportingPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* ---------------------------------------------------------------- {/* ----------------------------------------------------------------
Panel 1 — Metrics placeholder (full width) Panel 1 — Metrics placeholder
---------------------------------------------------------------- */} ---------------------------------------------------------------- */}
<div style={{ <div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)', background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
@@ -202,13 +701,7 @@ export default function ReportingPage() {
Metric Graphs Metric Graphs
</h2> </h2>
</div> </div>
<div style={{ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '120px', border: '1px dashed rgba(245,158,11,0.2)', borderRadius: '0.375rem', background: 'rgba(245,158,11,0.03)' }}>
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '120px',
border: '1px dashed rgba(245,158,11,0.2)',
borderRadius: '0.375rem',
background: 'rgba(245,158,11,0.03)'
}}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}> <p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Pie charts &amp; metrics coming soon Pie charts &amp; metrics coming soon
</p> </p>
@@ -226,7 +719,7 @@ export default function ReportingPage() {
padding: '1.5rem', padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)' boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
}}> }}>
{/* Table header row */} {/* Panel header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div> <div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}> <h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
@@ -235,10 +728,39 @@ export default function ReportingPage() {
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}> <div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
{syncedDisplay} {syncedDisplay}
{syncStatus === 'success' && total !== null && ( {syncStatus === 'success' && total !== null && (
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} total findings</span> <span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
{activeFilterCount > 0 && (
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
</span>
)}
</span>
)} )}
</div> </div>
</div> </div>
{/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{activeFilterCount > 0 && (
<button
onClick={() => setColumnFilters({})}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
Clear Filters
</button>
)}
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<button <button
onClick={syncFindings} onClick={syncFindings}
disabled={syncing || loading} disabled={syncing || loading}
@@ -258,6 +780,7 @@ export default function ReportingPage() {
{syncing ? 'Syncing…' : 'Sync'} {syncing ? 'Syncing…' : 'Sync'}
</button> </button>
</div> </div>
</div>
{/* Error banner */} {/* Error banner */}
{syncStatus === 'error' && syncError && ( {syncStatus === 'error' && syncError && (
@@ -267,7 +790,7 @@ export default function ReportingPage() {
</div> </div>
)} )}
{/* Loading state */} {/* Content */}
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}> <div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} /> <Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
@@ -278,41 +801,60 @@ export default function ReportingPage() {
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p> <p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
</div> </div>
) : ( ) : (
/* Table */
<div style={{ overflowX: 'auto', marginTop: '0.75rem' }}> <div style={{ overflowX: 'auto', marginTop: '0.75rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}> <tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{COLUMNS.map((col) => ( {visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
const isFiltered = !!columnFilters[col.key];
return (
<th <th
key={col.key} key={col.key}
onClick={col.sortable ? () => toggleSort(col.key) : undefined} onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
style={{ style={{
padding: '0.5rem 0.75rem', padding: '0.5rem 0.75rem', textAlign: 'left',
textAlign: 'left', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
fontFamily: 'monospace', color: active ? '#0EA5E9' : '#64748B',
fontSize: '0.68rem', textTransform: 'uppercase', letterSpacing: '0.08em',
fontWeight: '600',
color: sort.field === col.key ? '#0EA5E9' : '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.08em',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
cursor: col.sortable ? 'pointer' : 'default', cursor: def?.sortable ? 'pointer' : 'default',
userSelect: 'none', userSelect: 'none',
background: 'rgba(15,26,46,0.6)' background: 'rgba(15,26,46,0.6)',
position: 'relative',
}} }}
> >
<span style={{ display: 'inline-flex', alignItems: 'center' }}> <span style={{ display: 'inline-flex', alignItems: 'center' }}>
{col.label} {def?.label || col.key}
{col.sortable && <SortIcon colKey={col.key} sort={sort} />} {def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
{def?.filterable && (
<button
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
onClick={(e) => {
e.stopPropagation();
setOpenFilter(openFilter === col.key ? null : col.key);
}}
title={`Filter ${def.label}`}
style={{
background: 'none', border: 'none',
cursor: 'pointer', padding: '1px 1px 1px 3px',
color: isFiltered ? '#F59E0B' : '#334155',
lineHeight: 1, flexShrink: 0,
transition: 'color 0.15s',
}}
>
<Filter style={{ width: '10px', height: '10px' }} />
</button>
)}
</span> </span>
</th> </th>
))} );
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((finding, idx) => { {sorted.map((finding, idx) => {
const sc = severityColor(finding.vrrGroup);
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'; const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
return ( return (
<tr <tr
@@ -321,71 +863,16 @@ export default function ReportingPage() {
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'} onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg} onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
> >
{/* Severity */} {visibleCols.map((col) => (
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap' }}> <TableCell key={col.key} colKey={col.key} finding={finding} />
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.375rem', padding: '0.2rem 0.5rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}> ))}
{finding.severity?.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.8 }}>{finding.vrrGroup}</span>
</span>
</td>
{/* Title */}
<td style={{ padding: '0.5rem 0.75rem', maxWidth: '280px' }}>
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
{finding.title}
</span>
</td>
{/* Host */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.hostName || '—'}
</td>
{/* IP */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.ipAddress || '—'}
</td>
{/* DNS */}
<td style={{ padding: '0.5rem 0.75rem', maxWidth: '200px' }}>
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
{finding.dns || '—'}
</span>
</td>
{/* SLA */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
{finding.slaStatus || '—'}
</span>
</td>
{/* Discovered */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.discoveredOn || '—'}
</td>
{/* Last Found */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.lastFoundOn || '—'}
</td>
{/* Source */}
<td style={{ padding: '0.5rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.68rem' }}>
{finding.source || '—'}
</td>
{/* Notes */}
<td style={{ padding: '0.5rem 0.75rem' }}>
<NoteCell findingId={finding.id} initialNote={finding.note} />
</td>
</tr> </tr>
); );
})} })}
{sorted.length === 0 && ( {sorted.length === 0 && (
<tr> <tr>
<td colSpan={COLUMNS.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}> <td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
No findings found {activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td> </td>
</tr> </tr>
)} )}
@@ -394,6 +881,18 @@ export default function ReportingPage() {
</div> </div>
)} )}
</div> </div>
{/* Filter dropdown — rendered via portal at document.body */}
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
<FilterDropdown
anchorEl={filterBtnRefs.current[openFilter]}
colKey={openFilter}
findings={findings}
activeFilter={columnFilters[openFilter] || null}
onFilterChange={(vals) => setColFilter(openFilter, vals)}
onClose={() => setOpenFilter(null)}
/>
)}
</div> </div>
); );
} }