Add bulk Atlas action plan creation from row selection toolbar
This commit is contained in:
@@ -3765,7 +3765,7 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
|
||||
// ---------------------------------------------------------------------------
|
||||
// BulkHideToolbar — appears when rows are selected for bulk hiding
|
||||
// ---------------------------------------------------------------------------
|
||||
function BulkHideToolbar({ count, onHide, onClear }) {
|
||||
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
@@ -3801,6 +3801,27 @@ function BulkHideToolbar({ count, onHide, onClear }) {
|
||||
Hide Selected
|
||||
</button>
|
||||
|
||||
{/* Bulk Atlas Action Plan button */}
|
||||
{canWrite && onAtlasBulk && (
|
||||
<button
|
||||
onClick={onAtlasBulk}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Database style={{ width: '12px', height: '12px' }} />
|
||||
Atlas Action Plan
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Clear button */}
|
||||
<button
|
||||
onClick={onClear}
|
||||
@@ -3817,6 +3838,264 @@ function BulkHideToolbar({ count, onHide, onClear }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BulkAtlasModal — modal for creating action plans on multiple hosts at once
|
||||
// ---------------------------------------------------------------------------
|
||||
const BULK_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const BULK_PLAN_TYPE_COLORS = {
|
||||
remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B',
|
||||
risk_acceptance: '#A855F7', scan_exclusion: '#64748B',
|
||||
};
|
||||
|
||||
function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
const [planType, setPlanType] = useState('risk_acceptance');
|
||||
const [commitDate, setCommitDate] = useState('');
|
||||
const [qualysId, setQualysId] = useState('');
|
||||
const [jiraVnr, setJiraVnr] = useState('');
|
||||
const [archerExc, setArcherExc] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
const [typeOpen, setTypeOpen] = useState(false);
|
||||
const typeRef = useRef(null);
|
||||
|
||||
// Close type dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!typeOpen) return;
|
||||
const handler = (e) => { if (typeRef.current && !typeRef.current.contains(e.target)) setTypeOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [typeOpen]);
|
||||
|
||||
// Deduplicate host IDs from selected findings
|
||||
const hostEntries = useMemo(() => {
|
||||
const seen = new Map();
|
||||
for (const f of selectedFindings) {
|
||||
if (f.hostId && !seen.has(f.hostId)) {
|
||||
seen.set(f.hostId, { hostId: f.hostId, hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId) });
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}, [selectedFindings]);
|
||||
|
||||
const hostIds = useMemo(() => hostEntries.map(h => h.hostId), [hostEntries]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!commitDate) { setError('Commit date is required'); return; }
|
||||
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
|
||||
if (qualysId.trim()) body.qualys_id = qualysId.trim();
|
||||
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
|
||||
if (archerExc.trim()) body.archer_exc = archerExc.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE}/atlas/hosts/bulk-action-plans`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || data.detail || `Failed (${res.status})`);
|
||||
setResult(data);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputSt = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(14,165,233,0.06)', border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.375rem', color: '#E2E8F0', padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.78rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const labelSt = {
|
||||
display: 'block', fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
|
||||
color: '#94A3B8', marginBottom: '0.3rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 60 }} />
|
||||
|
||||
{/* Modal */}
|
||||
<div style={{
|
||||
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||
width: '520px', maxHeight: '80vh', overflowY: 'auto',
|
||||
background: '#0A1220',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
|
||||
zIndex: 61,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Database style={{ width: 16, height: 16, color: '#0EA5E9' }} />
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#E2E8F0' }}>
|
||||
Bulk Atlas Action Plan
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}>
|
||||
<X style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
{result ? (
|
||||
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
|
||||
<Check style={{ width: 32, height: 32, color: '#10B981', margin: '0 auto 0.75rem' }} />
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Action plans created
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '1rem' }}>
|
||||
{hostIds.length} host{hostIds.length !== 1 ? 's' : ''} — {planType.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9',
|
||||
borderRadius: '0.375rem', color: '#38BDF8',
|
||||
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '1rem 1.25rem' }}>
|
||||
{/* Host summary */}
|
||||
<div style={{
|
||||
marginBottom: '1rem', padding: '0.625rem 0.75rem',
|
||||
background: 'rgba(14,165,233,0.06)',
|
||||
border: '1px solid rgba(14,165,233,0.15)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.68rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
|
||||
{hostEntries.length} unique host{hostEntries.length !== 1 ? 's' : ''} from {selectedFindings.length} selected finding{selectedFindings.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div style={{ maxHeight: '100px', overflowY: 'auto', fontSize: '0.72rem', color: '#CBD5E1', lineHeight: 1.6 }}>
|
||||
{hostEntries.map(h => (
|
||||
<div key={h.hostId} style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{h.hostName}</span>
|
||||
<span style={{ color: '#475569', flexShrink: 0 }}>{h.hostId}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan type dropdown */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelSt}>Plan Type</label>
|
||||
<div ref={typeRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setTypeOpen(!typeOpen)} style={{
|
||||
...inputSt, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
borderColor: typeOpen ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
|
||||
}}>
|
||||
<span style={{ color: BULK_PLAN_TYPE_COLORS[planType], fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
||||
{planType.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: typeOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{typeOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: '4px',
|
||||
background: '#0F1A2E', border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.375rem', boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
zIndex: 65, overflow: 'hidden',
|
||||
}}>
|
||||
{BULK_PLAN_TYPES.map(t => (
|
||||
<div key={t} onClick={() => { setPlanType(t); setTypeOpen(false); }} style={{
|
||||
padding: '0.5rem 0.625rem', cursor: 'pointer',
|
||||
background: t === planType ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
color: BULK_PLAN_TYPE_COLORS[t], fontSize: '0.78rem',
|
||||
fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}
|
||||
onMouseEnter={e => { if (t !== planType) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
|
||||
onMouseLeave={e => { if (t !== planType) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit date */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelSt}>Commit Date</label>
|
||||
<input type="date" value={commitDate} onChange={e => setCommitDate(e.target.value)}
|
||||
style={{ ...inputSt, colorScheme: 'dark' }} />
|
||||
</div>
|
||||
|
||||
{/* Optional fields — shown based on plan type */}
|
||||
{(planType === 'remediation' || planType === 'false_positive') && (
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelSt}>Qualys ID <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
|
||||
<input value={qualysId} onChange={e => setQualysId(e.target.value)}
|
||||
placeholder="QID-12345" style={inputSt} />
|
||||
</div>
|
||||
)}
|
||||
{planType === 'false_positive' && (
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelSt}>Jira VNR <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
|
||||
<input value={jiraVnr} onChange={e => setJiraVnr(e.target.value)}
|
||||
placeholder="VNR-67890" style={inputSt} />
|
||||
</div>
|
||||
)}
|
||||
{(planType === 'risk_acceptance' || planType === 'scan_exclusion') && (
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={labelSt}>Archer EXC <span style={{ color: '#475569', textTransform: 'none' }}>(optional)</span></label>
|
||||
<input value={archerExc} onChange={e => setArcherExc(e.target.value)}
|
||||
placeholder="EXC-54321" style={inputSt} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginBottom: '0.75rem', padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.75rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: 14, height: 14, flexShrink: 0 }} />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button onClick={handleSubmit} disabled={submitting} style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
padding: '0.6rem 1rem',
|
||||
background: submitting ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)',
|
||||
border: '1px solid #0EA5E9', borderRadius: '0.375rem',
|
||||
color: submitting ? '#475569' : '#38BDF8',
|
||||
fontSize: '0.78rem', fontWeight: 600, cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}>
|
||||
{submitting ? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> : <Database style={{ width: 14, height: 14 }} />}
|
||||
{submitting ? 'Creating...' : `Create ${hostEntries.length} Action Plan${hostEntries.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3864,6 +4143,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
|
||||
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
|
||||
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
|
||||
const [bulkAtlasOpen, setBulkAtlasOpen] = useState(false);
|
||||
|
||||
// Atlas metrics state (for Atlas Coverage tab donut charts)
|
||||
const [atlasMetrics, setAtlasMetrics] = useState(null);
|
||||
@@ -4919,6 +5199,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
count={selectedRowIds.size}
|
||||
onHide={hideSelectedRows}
|
||||
onClear={() => setSelectedRowIds(new Set())}
|
||||
onAtlasBulk={() => setBulkAtlasOpen(true)}
|
||||
canWrite={canWrite()}
|
||||
/>
|
||||
)}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||||
@@ -5216,6 +5498,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onPlanChange={fetchAtlasStatus}
|
||||
/>
|
||||
)}
|
||||
{bulkAtlasOpen && (
|
||||
<BulkAtlasModal
|
||||
selectedFindings={sorted.filter(f => selectedRowIds.has(String(f.id)))}
|
||||
onClose={() => setBulkAtlasOpen(false)}
|
||||
onSuccess={() => { fetchAtlasStatus(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user