feat: add row visibility controls to Reporting page — hide/bulk-hide rows, localStorage persistence, visibility manager popover, chart/export integration

This commit is contained in:
jramos
2026-04-15 13:15:01 -06:00
parent 938dda400a
commit ed48522932
5 changed files with 1019 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3 } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
@@ -70,6 +70,23 @@ function saveColumnOrder(order) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Persist / load hidden row IDs (row visibility feature)
// ---------------------------------------------------------------------------
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
function loadHiddenRows() {
try {
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
if (saved && Array.isArray(saved)) return new Set(saved);
} catch { /* corrupted — treat as empty */ }
return new Set();
}
function saveHiddenRows(hiddenSet) {
try { localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet])); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Sort accessor by column key
// ---------------------------------------------------------------------------
@@ -2953,6 +2970,221 @@ function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWo
);
}
// ---------------------------------------------------------------------------
// RowVisibilityManager — popover for viewing and restoring hidden rows
// ---------------------------------------------------------------------------
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
const [open, setOpen] = useState(false);
const panelRef = useRef(null);
const btnRef = useRef(null);
// Close on outside click (same pattern as ColumnManager)
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const hiddenCount = hiddenRowIds.size;
// Build list of hidden findings with title lookup
const hiddenEntries = useMemo(() => {
const ids = [...hiddenRowIds];
return ids.map(id => {
const finding = findings.find(f => String(f.id) === String(id));
return { id, title: finding ? finding.title : null };
});
}, [hiddenRowIds, findings]);
return (
<div style={{ position: 'relative' }}>
<button
ref={btnRef}
onClick={() => setOpen(p => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.35rem 0.75rem',
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.2'})`,
borderRadius: '0.375rem',
color: '#94a3b8', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<EyeOff style={{ width: '13px', height: '13px' }} />
Hidden ({hiddenCount})
</button>
{open && (
<div
ref={panelRef}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: '300px', zIndex: 100,
background: 'linear-gradient(135deg, rgba(15,23,42,0.98), rgba(30,41,59,0.98))',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
padding: '0.5rem',
maxHeight: '320px',
overflowY: 'auto',
}}
>
{/* Header */}
<div style={{
fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace',
textTransform: 'uppercase', letterSpacing: '0.1em',
padding: '0.25rem 0.5rem 0.5rem',
borderBottom: '1px solid rgba(255,255,255,0.05)',
marginBottom: '0.375rem',
}}>
Hidden Rows
</div>
{hiddenCount === 0 ? (
<div style={{
padding: '1rem 0.5rem',
textAlign: 'center',
fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569',
}}>
No rows hidden
</div>
) : (
<>
{hiddenEntries.map(entry => (
<div
key={entry.id}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.5rem', borderRadius: '0.25rem',
transition: 'background 0.1s',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
color: '#CBD5E1', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{entry.id}
</div>
{entry.title && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
marginTop: '1px',
}}>
{entry.title}
</div>
)}
</div>
<button
onClick={() => onRestore(entry.id)}
title="Restore row"
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px', color: '#334155', lineHeight: 1,
transition: 'color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
>
<Eye style={{ width: '14px', height: '14px' }} />
</button>
</div>
))}
{/* Restore All button */}
<div style={{
borderTop: '1px solid rgba(255,255,255,0.05)',
marginTop: '0.375rem',
paddingTop: '0.375rem',
}}>
<button
onClick={onRestoreAll}
style={{
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
padding: '0.4rem 0.5rem',
background: 'rgba(14,165,233,0.08)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.15s',
}}
>
<RotateCcw style={{ width: '12px', height: '12px' }} />
Restore All
</button>
</div>
</>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// BulkHideToolbar — appears when rows are selected for bulk hiding
// ---------------------------------------------------------------------------
function BulkHideToolbar({ count, onHide, onClear }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',
border: '1px solid rgba(14,165,233,0.3)',
borderRadius: '6px',
marginBottom: '0.5rem',
}}>
{/* Count label */}
<span style={{
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#e2e8f0',
}}>
{count} row{count !== 1 ? 's' : ''} selected
</span>
{/* Hide Selected button */}
<button
onClick={onHide}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.625rem',
background: 'rgba(14,165,233,0.12)',
border: '1px solid rgba(79,195,247,0.35)',
borderRadius: '0.25rem',
color: '#4fc3f7',
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<EyeOff style={{ width: '12px', height: '12px' }} />
Hide Selected
</button>
{/* Clear button */}
<button
onClick={onClear}
style={{
background: 'none', border: 'none',
fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B',
cursor: 'pointer', padding: '0.3rem 0.375rem',
transition: 'color 0.15s',
}}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
@@ -2996,6 +3228,54 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
saveColumnOrder(newOrder);
}, []);
// Hidden row state (row visibility feature)
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
const hideRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.add(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
const restoreRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.delete(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
const restoreAllRows = useCallback(() => {
setHiddenRowIds(new Set());
saveHiddenRows(new Set());
}, []);
// Selection state (row visibility feature — bulk hide)
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
const toggleRowSelection = useCallback((findingId) => {
setSelectedRowIds(prev => {
const next = new Set(prev);
const id = String(findingId);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const hideSelectedRows = useCallback(() => {
setHiddenRowIds(prev => {
const next = new Set(prev);
selectedRowIds.forEach(id => next.add(String(id)));
saveHiddenRows(next);
return next;
});
setSelectedRowIds(new Set());
}, [selectedRowIds]);
// CVE tooltip hover handlers
const handleCveMouseEnter = useCallback((cveId, e) => {
clearTimeout(hoverTimerRef.current);
@@ -3101,9 +3381,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
});
}, []);
// Visible findings — hidden rows removed before any other filtering
const visibleFindings = useMemo(() => {
if (hiddenRowIds.size === 0) return findings;
return findings.filter(f => !hiddenRowIds.has(String(f.id)));
}, [findings, hiddenRowIds]);
// Apply all active filters to produce the visible row set
const filtered = useMemo(() => {
let result = findings;
let result = visibleFindings;
// Column filters
const active = Object.entries(columnFilters);
@@ -3135,7 +3421,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
return result;
}, [findings, columnFilters, actionFilter, excFilter]);
}, [visibleFindings, columnFilters, actionFilter, excFilter]);
// Visible columns in current order
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
@@ -3153,6 +3439,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
return sort.dir === 'asc' ? cmp : -cmp;
}), [filtered, sort]);
// Select/deselect all visible rows
const toggleSelectAll = useCallback(() => {
const allVisibleIds = sorted.map(f => String(f.id));
setSelectedRowIds(prev => {
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
return new Set(allVisibleIds); // select all
});
}, [sorted]);
// Prune selection to only include IDs present in the current sorted (visible) rows
useEffect(() => {
setSelectedRowIds(prev => {
const visibleIds = new Set(sorted.map(f => String(f.id)));
const next = new Set([...prev].filter(id => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [sorted]);
const toggleSort = (key) => {
setSort((prev) =>
prev.field === key
@@ -3522,7 +3826,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}> filtered</span>}
</div>
<ActionCoverageDonut
findings={findings}
findings={visibleFindings}
activeSegment={actionFilter}
onSegmentClick={(key) => {
setExcFilter(null);
@@ -3735,6 +4039,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
)}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button
onClick={syncFindings}
disabled={syncing || loading}
@@ -3789,9 +4094,47 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onClear={() => { setSelectedIds(new Set()); setBatchError(null); }}
/>
)}
{selectedRowIds.size > 0 && (
<BulkHideToolbar
count={selectedRowIds.size}
onHide={hideSelectedRows}
onClear={() => setSelectedRowIds(new Set())}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{/* Fixed selection checkbox column — row visibility feature */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
textAlign: 'center',
cursor: 'pointer',
}}
onClick={toggleSelectAll}
>
{(() => {
const allVisibleIds = sorted.map(f => String(f.id));
const selectedCount = allVisibleIds.filter(id => selectedRowIds.has(id)).length;
const allSelected = allVisibleIds.length > 0 && selectedCount === allVisibleIds.length;
const someSelected = selectedCount > 0 && !allSelected;
if (allSelected) return <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
if (someSelected) return <MinusSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />;
return <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />;
})()}
</th>
{/* Fixed hide button column — row visibility feature */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
/>
{/* Fixed checkbox column — not part of column manager */}
<th
style={{
@@ -3885,6 +4228,33 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
{/* Selection checkbox cell — row visibility feature */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }}
onClick={() => toggleRowSelection(finding.id)}
>
{selectedRowIds.has(String(finding.id))
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
}
</td>
{/* Hide button cell — row visibility feature */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
>
<button
onClick={() => hideRow(finding.id)}
title="Hide this row"
style={{
background: 'none', border: 'none', padding: 0,
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }}
onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}
>
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
</button>
</td>
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
@@ -3936,7 +4306,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
})}
{sorted.length === 0 && (
<tr>
<td colSpan={visibleCols.length + 1} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
<td colSpan={visibleCols.length + 3} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td>
</tr>
@@ -3952,7 +4322,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<FilterDropdown
anchorEl={filterBtnRefs.current[openFilter]}
colKey={openFilter}
findings={findings}
findings={visibleFindings}
activeFilter={columnFilters[openFilter] || null}
onFilterChange={(vals) => setColFilter(openFilter, vals)}
onClose={() => setOpenFilter(null)}