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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user