diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 1cd080a..fcd47db 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -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, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet } from 'lucide-react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; @@ -5852,6 +5852,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const [cardConfigured, setCardConfigured] = useState(false); const [cardTeams, setCardTeams] = useState([]); + // Group-by-host toggle state + const [groupByHost, setGroupByHost] = useState(false); + const [expandedHosts, setExpandedHosts] = useState(new Set()); + const updateColumns = useCallback((newOrder) => { setColumnOrder(newOrder); saveColumnOrder(newOrder); @@ -6165,6 +6169,67 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { return sort.dir === 'asc' ? cmp : -cmp; }), [filtered, sort]); + // Grouped view — aggregate findings by hostName + ipAddress + const groupedByHost = useMemo(() => { + if (!groupByHost) return { groups: [], singles: [] }; + const map = new Map(); + sorted.forEach(f => { + const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`; + if (!map.has(hostKey)) { + map.set(hostKey, { + hostKey, + hostName: f.overrides?.hostName || f.hostName || '', + ipAddress: f.ipAddress || '', + findings: [], + highestSeverity: 0, + highestVrrGroup: '', + cveSet: new Set(), + }); + } + const group = map.get(hostKey); + group.findings.push(f); + if (f.severity > group.highestSeverity) { + group.highestSeverity = f.severity; + group.highestVrrGroup = f.vrrGroup || ''; + } + (f.cves || []).forEach(c => group.cveSet.add(c)); + }); + // Separate: groups with 2+ findings vs singles that stay flat + const groups = []; + const singles = []; + for (const g of map.values()) { + if (g.findings.length > 1) groups.push(g); + else singles.push(g.findings[0]); + } + groups.sort((a, b) => b.highestSeverity - a.highestSeverity); + return { groups, singles }; + }, [sorted, groupByHost]); + + // Combined render order for grouped mode: grouped hosts first, then singles + const groupedRenderList = useMemo(() => { + if (!groupByHost) return []; + const list = []; + groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g })); + groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f })); + return list; + }, [groupByHost, groupedByHost]); + + const toggleHostExpand = useCallback((hostKey) => { + setExpandedHosts(prev => { + const next = new Set(prev); + if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey); + return next; + }); + }, []); + + const expandAllHosts = useCallback(() => { + setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey))); + }, [groupedByHost]); + + const collapseAllHosts = useCallback(() => { + setExpandedHosts(new Set()); + }, []); + // Select/deselect all visible rows const toggleSelectAll = useCallback(() => { const allVisibleIds = sorted.map(f => String(f.id)); @@ -6908,6 +6973,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { )} + + + + + + )} + {groupedRenderList.map((item, itemIdx) => { + if (item.type === 'single') { + // Render single-finding hosts as normal flat rows + const finding = item.finding; + const isSelected = selectedIds.has(finding.id); + const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'); + const queued = isQueued(finding.id); + return ( + { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }} + > + toggleRowSelection(finding.id)}> + {selectedRowIds.has(String(finding.id)) + ? + : + } + + + + + { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}> + + + {visibleCols.map((col) => ( + { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> + ))} + + ); + } + // Render grouped host header + expandable sub-rows + const group = item.group; + const isExpanded = expandedHosts.has(group.hostKey); + const sc = severityColor(group.highestVrrGroup); + return ( + + {/* Host group header — uses same columns as regular rows */} + toggleHostExpand(group.hostKey)} + style={{ + borderBottom: '1px solid rgba(139,92,246,0.15)', + background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)', + cursor: 'pointer', + }} + onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }} + > + {/* Expand/collapse icon in first fixed column */} + + {isExpanded + ? + : + } + + {/* Empty cells for hide + checkbox columns */} + + + {/* Render each column cell — show host-level summary data in the matching column positions */} + {visibleCols.map((col) => { + switch (col.key) { + case 'findingId': + return ( + + + {group.findings.length} findings + + + ); + case 'severity': + return ( + + + {group.highestSeverity.toFixed(2)} + {group.highestVrrGroup} + + + ); + case 'hostName': + return ( + + + {group.hostName || '—'} + + + ); + case 'ipAddress': + return ( + + + {group.ipAddress || '—'} + + + ); + case 'cves': + return ( + + + {group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''} + + + ); + default: + return ; + } + })} + + {/* Expanded sub-rows — individual findings */} + {isExpanded && group.findings.map((finding, idx) => { + const isSelected = selectedIds.has(finding.id); + const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)'); + const queued = isQueued(finding.id); + return ( + { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }} + > + toggleRowSelection(finding.id)}> + {selectedRowIds.has(String(finding.id)) + ? + : + } + + + + + { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}> + + + {visibleCols.map((col) => ( + { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> + ))} + + ); + })} + + ); + })} + {groupedRenderList.length === 0 && ( + + + {activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'} + + + )} + + ) : ( + /* ---- Flat view (default) ---- */ + <> {sorted.map((finding, idx) => { const isSelected = selectedIds.has(finding.id); const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'); @@ -7230,6 +7485,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { )} + + )}