feat: add batch finding disposition — multi-select findings and bulk add to Ivanti queue
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user