Add bulk Atlas action plan creation from row selection toolbar

This commit is contained in:
root
2026-04-24 21:49:04 +00:00
parent 3f9e1da2a3
commit 5a9dc007db

View File

@@ -3765,7 +3765,7 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// BulkHideToolbar — appears when rows are selected for bulk hiding // BulkHideToolbar — appears when rows are selected for bulk hiding
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function BulkHideToolbar({ count, onHide, onClear }) { function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
return ( return (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.75rem',
@@ -3801,6 +3801,27 @@ function BulkHideToolbar({ count, onHide, onClear }) {
Hide Selected Hide Selected
</button> </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 */} {/* Clear button */}
<button <button
onClick={onClear} 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 // Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3864,6 +4143,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null); const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null); const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null); const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
const [bulkAtlasOpen, setBulkAtlasOpen] = useState(false);
// Atlas metrics state (for Atlas Coverage tab donut charts) // Atlas metrics state (for Atlas Coverage tab donut charts)
const [atlasMetrics, setAtlasMetrics] = useState(null); const [atlasMetrics, setAtlasMetrics] = useState(null);
@@ -4919,6 +5199,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
count={selectedRowIds.size} count={selectedRowIds.size}
onHide={hideSelectedRows} onHide={hideSelectedRows}
onClear={() => setSelectedRowIds(new Set())} onClear={() => setSelectedRowIds(new Set())}
onAtlasBulk={() => setBulkAtlasOpen(true)}
canWrite={canWrite()}
/> />
)} )}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
@@ -5216,6 +5498,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onPlanChange={fetchAtlasStatus} onPlanChange={fetchAtlasStatus}
/> />
)} )}
{bulkAtlasOpen && (
<BulkAtlasModal
selectedFindings={sorted.filter(f => selectedRowIds.has(String(f.id)))}
onClose={() => setBulkAtlasOpen(false)}
onSuccess={() => { fetchAtlasStatus(); }}
/>
)}
</div> </div>
); );
} }