feat: add batch finding disposition — multi-select findings and bulk add to Ivanti queue

This commit is contained in:
jramos
2026-04-09 09:49:40 -06:00
parent 328e48ea8c
commit ccc3576706
6 changed files with 1036 additions and 20 deletions

View File

@@ -2113,6 +2113,139 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
);
}
// ---------------------------------------------------------------------------
// SelectionToolbar — batch action bar for multi-selected findings
// ---------------------------------------------------------------------------
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
const isCard = workflowType === 'CARD';
const canSubmit = !submitting && (isCard || vendor.trim().length > 0);
return (
<div style={{
position: 'sticky', top: 0, zIndex: 20,
display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap',
padding: '0.625rem 1rem',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
marginBottom: '0.5rem',
}}>
{/* Count badge */}
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '22px', height: '22px', padding: '0 6px',
background: 'rgba(14,165,233,0.2)', border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px', fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '700', color: '#0EA5E9',
}}>
{count}
</span>
selected
</span>
{/* Workflow type toggles */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{[
{ type: 'FP', color: '#F59E0B', rgb: '245,158,11' },
{ type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' },
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
].map(({ type, color, rgb }) => {
const active = workflowType === type;
return (
<button
key={type}
onClick={() => onWorkflowChange(type)}
style={{
padding: '0.25rem 0.5rem',
background: active ? `rgba(${rgb},0.2)` : 'transparent',
border: `1px solid rgba(${rgb},${active ? '0.5' : '0.15'})`,
borderRadius: '0.25rem',
color: active ? color : '#475569',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{type}
</button>
);
})}
</div>
{/* Vendor input or CARD indicator */}
{isCard ? (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
padding: '0.25rem 0.5rem',
background: 'rgba(16,185,129,0.06)', border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
}}>
No vendor required
</span>
) : (
<input
type="text"
value={vendor}
onChange={(e) => onVendorChange(e.target.value)}
placeholder="Vendor / Platform"
style={{
width: '160px', boxSizing: 'border-box',
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.75rem', fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onSubmit(); }}
/>
)}
{/* Add to Queue button */}
<button
onClick={onSubmit}
disabled={!canSubmit}
style={{
padding: '0.3rem 0.75rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'transparent',
border: `1px solid rgba(14,165,233,${canSubmit ? '0.4' : '0.1'})`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{submitting ? 'Adding…' : 'Add to Queue'}
</button>
{/* Clear selection */}
<button
onClick={onClear}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#475569', padding: '4px', lineHeight: 1,
}}
title="Clear selection"
>
<X style={{ width: '16px', height: '16px' }} />
</button>
{/* Error message */}
{error && (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#EF4444',
display: 'flex', alignItems: 'center', gap: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px' }} />
{error}
</span>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
@@ -2138,6 +2271,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [actionFilter, setActionFilter] = useState(null);
const [excFilter, setExcFilter] = useState(filterEXC || null);
const [selectedIds, setSelectedIds] = useState(new Set());
const [lastClickedId, setLastClickedId] = useState(null);
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchError, setBatchError] = useState(null);
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState('');
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
@@ -2216,6 +2356,29 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchQueue();
}, []); // eslint-disable-line
// Prune selection when filters change — keep only IDs still in filtered set
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(filtered.map((f) => f.id));
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [filtered]);
// Escape key clears selection
useEffect(() => {
if (selectedIds.size === 0) return;
const handler = (e) => {
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
setSelectedIds(new Set());
setBatchError(null);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [selectedIds, addPopover]);
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => {
@@ -2361,6 +2524,50 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]);
const submitBatch = useCallback(async () => {
if (selectedIds.size === 0) return;
setBatchSubmitting(true);
setBatchError(null);
try {
const findingsPayload = [...selectedIds].map((id) => {
const f = findings.find((ff) => ff.id === id);
return f ? {
finding_id: f.id,
finding_title: f.title || null,
cves: f.cves || [],
ip_address: f.ipAddress || null,
} : { finding_id: id };
});
const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
findings: findingsPayload,
workflow_type: batchWorkflowType,
vendor: batchWorkflowType === 'CARD' ? '' : batchVendor.trim(),
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, ...(data.items || [])].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
));
setSelectedIds(new Set());
setBatchWorkflowType('FP');
setBatchVendor('');
setBatchError(null);
} else {
setBatchError(data.error || 'Failed to add findings to queue.');
}
} catch (e) {
console.error('Error in batch add:', e);
setBatchError('Network error — please try again.');
} finally {
setBatchSubmitting(false);
}
}, [selectedIds, findings, batchWorkflowType, batchVendor]);
const updateQueueItem = useCallback(async (id, changes) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
@@ -2786,6 +2993,19 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</div>
) : (
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
{selectedIds.size > 0 && canWrite() && (
<SelectionToolbar
count={selectedIds.size}
workflowType={batchWorkflowType}
vendor={batchVendor}
submitting={batchSubmitting}
error={batchError}
onWorkflowChange={setBatchWorkflowType}
onVendorChange={setBatchVendor}
onSubmit={submitBatch}
onClear={() => { setSelectedIds(new Set()); setBatchError(null); }}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
@@ -2796,8 +3016,31 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
textAlign: 'center',
}}
/>
>
{canWrite() && (
<input
type="checkbox"
checked={sorted.length > 0 && sorted.filter((f) => !isQueued(f.id)).length > 0 && sorted.filter((f) => !isQueued(f.id)).every((f) => selectedIds.has(f.id))}
onChange={() => {
const nonQueued = sorted.filter((f) => !isQueued(f.id));
const allSelected = nonQueued.length > 0 && nonQueued.every((f) => selectedIds.has(f.id));
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(nonQueued.map((f) => f.id)));
}
}}
style={{
accentColor: '#0EA5E9',
width: '13px', height: '13px',
cursor: 'pointer',
}}
title="Select all visible findings"
/>
)}
</th>
{visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
@@ -2849,31 +3092,60 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</thead>
<tbody>
{sorted.map((finding, idx) => {
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
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)');
const queued = isQueued(finding.id);
return (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => {
if (queued) return;
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
// If nothing selected and not shift-click, open single-add popover
if (selectedIds.size === 0 && !e.shiftKey) {
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
return;
}
// Shift-click range select
if (e.shiftKey && lastClickedId) {
const lastIdx = sorted.findIndex((f) => f.id === lastClickedId);
const currIdx = sorted.findIndex((f) => f.id === finding.id);
if (lastIdx !== -1 && currIdx !== -1) {
const start = Math.min(lastIdx, currIdx);
const end = Math.max(lastIdx, currIdx);
setSelectedIds((prev) => {
const next = new Set(prev);
for (let i = start; i <= end; i++) {
if (!isQueued(sorted[i].id)) next.add(sorted[i].id);
}
return next;
});
}
} else {
// Toggle selection
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}
checked={queued || isSelected}
style={{
accentColor: '#0EA5E9',
accentColor: queued ? '#10B981' : '#0EA5E9',
width: '13px', height: '13px',
cursor: queued ? 'default' : 'pointer',
pointerEvents: 'none',