Add Group by Host toggle to Ivanti findings table

Client-side grouping that collapses duplicate assets (same hostname + IP)
with multiple finding IDs into expandable host rows. Hosts with only one
finding remain as normal flat rows.

- Toggle button in toolbar switches between flat and grouped views
- Group header rows preserve column alignment (severity, host, IP in proper columns)
- Expanded sub-rows show full finding details with all interactions intact
- Selection, queue, hide, and workflow actions all work in both modes
- Groups sorted by highest severity; expand/collapse all controls included
This commit is contained in:
Jordan Ramos
2026-06-03 15:44:48 -06:00
parent 4e8f4cbb10
commit d9c47ec030

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, 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 }) {
</span>
)}
</button>
<button
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
borderRadius: '0.375rem',
color: groupByHost ? '#A78BFA' : '#7C3AED',
cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<Layers style={{ width: '13px', height: '13px' }} />
{groupByHost ? 'Grouped' : 'Group'}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button
@@ -7136,6 +7219,178 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</tr>
</thead>
<tbody>
{groupByHost ? (
/* ---- Grouped-by-host view ---- */
<>
{groupedRenderList.length > 0 && (
<tr>
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
</span>
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
</div>
</td>
</tr>
)}
{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 (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
<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>
<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>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { 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); }}>
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
}
// Render grouped host header + expandable sub-rows
const group = item.group;
const isExpanded = expandedHosts.has(group.hostKey);
const sc = severityColor(group.highestVrrGroup);
return (
<React.Fragment key={group.hostKey}>
{/* Host group header — uses same columns as regular rows */}
<tr
onClick={() => 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 */}
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
{isExpanded
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
}
</td>
{/* Empty cells for hide + checkbox columns */}
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
{/* Render each column cell — show host-level summary data in the matching column positions */}
{visibleCols.map((col) => {
switch (col.key) {
case 'findingId':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
{group.findings.length} findings
</span>
</td>
);
case 'severity':
return (
<td key={col.key} 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' }}>
{group.highestSeverity.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
</span>
</td>
);
case 'hostName':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
{group.hostName || '—'}
</span>
</td>
);
case 'ipAddress':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{group.ipAddress || '—'}
</span>
</td>
);
case 'cves':
return (
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
</span>
</td>
);
default:
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
}
})}
</tr>
{/* 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 (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
<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>
<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>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { 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); }}>
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
})}
</React.Fragment>
);
})}
{groupedRenderList.length === 0 && (
<tr>
<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>
)}
</>
) : (
/* ---- 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 }) {
</td>
</tr>
)}
</>
)}
</tbody>
</table>
</div>