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
|
// 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user