Files
cve-dashboard/frontend/src/components/AtlasSlideOutPanel.js

872 lines
36 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
import AtlasIcon from './AtlasIcon';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Plan type badge colors
// ---------------------------------------------------------------------------
const PLAN_TYPE_COLORS = {
remediation: '#0EA5E9',
decommission: '#EF4444',
false_positive: '#F59E0B',
risk_acceptance: '#A855F7',
scan_exclusion: '#64748B',
};
const VALID_PLAN_TYPES = Object.keys(PLAN_TYPE_COLORS);
// ---------------------------------------------------------------------------
// Shared inline style constants
// ---------------------------------------------------------------------------
const ACCENT = '#0EA5E9';
const panelStyle = {
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
background: '#0A1220',
borderLeft: '1px solid rgba(14,165,233,0.15)',
boxShadow: '-8px 0 32px rgba(0,0,0,0.6)',
zIndex: 41,
display: 'flex', flexDirection: 'column',
overflowY: 'auto',
fontFamily: "'JetBrains Mono', monospace",
};
const backdropStyle = {
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.4)',
zIndex: 40,
};
const headerStyle = {
padding: '1.25rem 1.25rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
};
const sectionTitleStyle = {
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase', letterSpacing: '0.1em',
color: '#475569', marginBottom: '0.75rem',
};
const inputStyle = {
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',
transition: 'border-color 0.15s',
};
const labelStyle = {
display: 'block',
fontSize: '0.68rem',
fontFamily: "'JetBrains Mono', monospace",
color: '#94A3B8',
marginBottom: '0.3rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const primaryBtnStyle = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
padding: '0.5rem 1rem',
background: 'rgba(14,165,233,0.15)',
border: '1px solid #0EA5E9',
borderRadius: '0.375rem',
color: '#38BDF8',
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
// ---------------------------------------------------------------------------
// Custom dropdown — dark-themed replacement for native <select>
// ---------------------------------------------------------------------------
function PlanTypeDropdown({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handleClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const color = PLAN_TYPE_COLORS[value] || '#94A3B8';
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen(!open)}
style={{
...inputStyle,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', textAlign: 'left',
borderColor: open ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
}}
>
<span style={{ color, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{value.replace(/_/g, ' ')}
</span>
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{open && (
<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: 50, overflow: 'hidden',
}}>
{VALID_PLAN_TYPES.map(t => {
const c = PLAN_TYPE_COLORS[t] || '#94A3B8';
const isSelected = t === value;
return (
<div
key={t}
onClick={() => { onChange(t); setOpen(false); }}
style={{
padding: '0.5rem 0.625rem',
cursor: 'pointer',
background: isSelected ? 'rgba(14,165,233,0.12)' : 'transparent',
color: c,
fontSize: '0.78rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
>
{t.replace(/_/g, ' ')}
</div>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// PlanTypeBadge — colored pill for plan type
// ---------------------------------------------------------------------------
function PlanTypeBadge({ type }) {
const color = PLAN_TYPE_COLORS[type] || '#94A3B8';
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.2rem 0.5rem',
background: `${color}18`,
border: `1px solid ${color}50`,
borderRadius: '0.25rem',
color,
fontSize: '0.7rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}>
{type.replace(/_/g, ' ')}
</span>
);
}
// ---------------------------------------------------------------------------
// PlanCard — displays a single action plan
// ---------------------------------------------------------------------------
function PlanCard({ plan, canWrite, onSaveEdit, editingId, onStartEdit, onCancelEdit }) {
const isEditing = editingId === (plan.action_plan_id || plan.id);
const [editForm, setEditForm] = useState({
commit_date: plan.commit_date || '',
qualys_id: plan.qualys_id || '',
active_host_findings_id: plan.active_host_findings_id || '',
jira_vnr: plan.jira_vnr || '',
archer_exc: plan.archer_exc || '',
});
const [saving, setSaving] = useState(false);
const [editError, setEditError] = useState(null);
const handleSave = async () => {
setSaving(true);
setEditError(null);
try {
await onSaveEdit(plan.action_plan_id || plan.id, editForm);
} catch (err) {
setEditError(err.message);
} finally {
setSaving(false);
}
};
const isPending = !!plan._localPending;
return (
<div style={{
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
background: isPending ? 'rgba(245,158,11,0.06)' : 'rgba(14,165,233,0.04)',
border: isPending ? '1px solid rgba(245,158,11,0.25)' : '1px solid rgba(14,165,233,0.12)',
borderRadius: '0.375rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
{isPending && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '3px',
padding: '0.15rem 0.4rem',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.35)',
borderRadius: '0.25rem',
color: '#F59E0B',
fontSize: '0.6rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
pending
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{plan.status && !isPending && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '3px',
padding: '0.15rem 0.4rem',
background: 'rgba(16,185,129,0.12)',
border: '1px solid rgba(16,185,129,0.35)',
borderRadius: '0.25rem',
color: '#10B981',
fontSize: '0.6rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{plan.status}
</span>
)}
{canWrite && !isEditing && !isPending && (
<button
onClick={() => onStartEdit(plan.action_plan_id || plan.id)}
title="Edit plan"
style={{
background: 'none', border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.25rem', padding: '0.2rem',
cursor: 'pointer', color: '#475569',
transition: 'all 0.15s', lineHeight: 1,
}}
onMouseEnter={e => { e.currentTarget.style.color = ACCENT; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.15)'; }}
>
<Edit3 style={{ width: 11, height: 11 }} />
</button>
)}
</div>
</div>
{!isEditing ? (
<>
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>Commit</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
</div>
{plan.jira_vnr && (
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>VNR</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.jira_vnr}</span>
</div>
)}
{plan.archer_exc && (
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>EXC</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.archer_exc}</span>
</div>
)}
</>
) : (
<div style={{ marginTop: '0.5rem' }}>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Commit Date</label>
<input
type="date"
value={editForm.commit_date}
onChange={e => setEditForm({ ...editForm, commit_date: e.target.value })}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Qualys ID</label>
<input
type="text"
value={editForm.qualys_id}
onChange={e => setEditForm({ ...editForm, qualys_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Findings ID</label>
<input
type="number"
value={editForm.active_host_findings_id}
onChange={e => setEditForm({ ...editForm, active_host_findings_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Jira VNR</label>
<input
type="text"
value={editForm.jira_vnr}
onChange={e => setEditForm({ ...editForm, jira_vnr: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Archer EXC</label>
<input
type="text"
value={editForm.archer_exc}
onChange={e => setEditForm({ ...editForm, archer_exc: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{editError && (
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.5rem' }}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{editError}
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleSave}
disabled={saving}
style={{ ...primaryBtnStyle, fontSize: '0.68rem', padding: '0.35rem 0.75rem', opacity: saving ? 0.6 : 1 }}
>
{saving ? <Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} /> : <Check style={{ width: 12, height: 12 }} />}
Save
</button>
<button
onClick={onCancelEdit}
style={{
padding: '0.35rem 0.75rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontSize: '0.68rem',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// InactiveSection — collapsible history of overridden/inactive plans
// ---------------------------------------------------------------------------
function InactiveSection({ plans }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ padding: '0.75rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<button
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase', letterSpacing: '0.1em',
color: '#475569', width: '100%',
}}
>
<ChevronDown style={{ width: 12, height: 12, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
History ({plans.length})
</button>
{expanded && (
<div style={{ marginTop: '0.625rem' }}>
{plans.map((plan, idx) => (
<div key={plan.action_plan_id || idx} style={{
marginBottom: '0.5rem', padding: '0.5rem 0.625rem',
background: 'rgba(100,116,139,0.04)',
border: '1px solid rgba(100,116,139,0.1)',
borderRadius: '0.375rem',
opacity: 0.7,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem' }}>
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
<span style={{
fontSize: '0.6rem', color: '#64748B',
fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase',
}}>
{plan.status || 'inactive'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Commit</span>
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
</div>
{plan.created_at && (
<div style={{ display: 'flex', gap: '0.4rem' }}>
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Created</span>
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.created_at.split('T')[0]}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AtlasSlideOutPanel — main exported component
// ---------------------------------------------------------------------------
export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualysId, onClose, canWrite, onPlanChange }) {
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
// Create form state — prepopulate qualys_id and findings ID from the clicked finding
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
plan_type: 'remediation',
commit_date: '',
qualys_id: qualysId || '',
active_host_findings_id: findingId || '',
jira_vnr: '',
archer_exc: '',
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
// -----------------------------------------------------------------------
// Parse Atlas response — handles { active: [...], inactive: [...] } format
// -----------------------------------------------------------------------
function parseAtlasPlans(data) {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') {
const active = Array.isArray(data.active) ? data.active : [];
const inactive = Array.isArray(data.inactive) ? data.inactive : [];
return [...active, ...inactive];
}
return [];
}
// -----------------------------------------------------------------------
// Fetch plans
// -----------------------------------------------------------------------
const fetchPlans = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Failed to load plans (${res.status})`);
}
const data = await res.json();
const remotePlans = parseAtlasPlans(data);
// Merge: keep local pending plans that aren't yet confirmed by Atlas
setPlans(prev => {
const localPending = prev.filter(p => p._localPending);
const remoteIds = new Set(remotePlans.map(p => p.action_plan_id));
// Remove local pending plans that now appear in remote (confirmed)
const stillPending = localPending.filter(p => !remoteIds.has(p.action_plan_id));
return [...remotePlans, ...stillPending];
});
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [hostId]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
// Clear success message after 3s
useEffect(() => {
if (!successMsg) return;
const t = setTimeout(() => setSuccessMsg(null), 3000);
return () => clearTimeout(t);
}, [successMsg]);
// -----------------------------------------------------------------------
// Create plan
// -----------------------------------------------------------------------
const handleCreate = async () => {
if (!createForm.commit_date) {
setCreateError('Commit date is required');
return;
}
setCreating(true);
setCreateError(null);
try {
const body = {
plan_type: createForm.plan_type,
commit_date: createForm.commit_date,
};
if (createForm.qualys_id.trim()) body.qualys_id = createForm.qualys_id.trim();
if (createForm.active_host_findings_id) body.active_host_findings_id = Number(createForm.active_host_findings_id);
if (createForm.jira_vnr.trim()) body.jira_vnr = createForm.jira_vnr.trim();
if (createForm.archer_exc.trim()) body.archer_exc = createForm.archer_exc.trim();
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
method: 'PUT',
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 || `Create failed (${res.status})`);
// Add optimistic local plan immediately — shown as "pending" until sync confirms
const localPlan = {
action_plan_id: data.action_plan_id || ('local-' + Date.now()),
plan_type: body.plan_type,
commit_date: body.commit_date,
qualys_id: body.qualys_id || null,
active_host_findings_id: body.active_host_findings_id || null,
jira_vnr: body.jira_vnr || null,
archer_exc: body.archer_exc || null,
status: 'pending',
_localPending: true,
created_at: new Date().toISOString(),
};
setPlans(prev => [localPlan, ...prev]);
// Reset form
setCreateForm({ plan_type: 'remediation', commit_date: '', qualys_id: qualysId || '', active_host_findings_id: findingId || '', jira_vnr: '', archer_exc: '' });
setShowCreate(false);
setSuccessMsg('Action plan created');
if (onPlanChange) onPlanChange();
} catch (err) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
// -----------------------------------------------------------------------
// Edit plan
// -----------------------------------------------------------------------
const handleSaveEdit = async (actionPlanId, updates) => {
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action_plan_id: actionPlanId, updates }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Update failed (${res.status})`);
setEditingId(null);
setSuccessMsg('Action plan updated');
await fetchPlans();
if (onPlanChange) onPlanChange();
};
// -----------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={backdropStyle} data-testid="atlas-panel-backdrop" />
{/* Panel */}
<div style={panelStyle} data-testid="atlas-slide-out-panel">
{/* Header */}
<div style={headerStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.3rem' }}>
<AtlasIcon style={{ width: 16, height: 16, color: ACCENT, flexShrink: 0 }} />
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#E2E8F0', wordBreak: 'break-all', lineHeight: 1.3 }}>
{hostName || 'Unknown Host'}
</span>
</div>
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace" }}>
Host ID: {hostId}
</span>
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
data-testid="atlas-panel-close"
>
<X style={{ width: 18, height: 18 }} />
</button>
</div>
</div>
{/* Success message */}
{successMsg && (
<div style={{
margin: '0.75rem 1.25rem 0', padding: '0.5rem 0.75rem',
background: 'rgba(16,185,129,0.1)',
border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem',
color: '#10B981', fontSize: '0.75rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<Check style={{ width: 14, height: 14 }} />{successMsg}
</div>
)}
{/* Loading */}
{loading && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0' }}>
<Loader style={{ width: 28, height: 28, color: ACCENT, animation: 'spin 1s linear infinite' }} />
</div>
)}
{/* Error */}
{error && !loading && (
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem', alignItems: 'center' }}>
<AlertCircle style={{ width: 16, height: 16, flexShrink: 0 }} />{error}
</div>
<button
onClick={fetchPlans}
style={{
...primaryBtnStyle,
fontSize: '0.68rem',
padding: '0.35rem 0.75rem',
}}
>
Retry
</button>
</div>
)}
{/* Plan list */}
{!loading && !error && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Section: Active plans */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div style={sectionTitleStyle}>
<Shield style={{ width: 14, height: 14, color: ACCENT }} />
Active Plans ({plans.filter(p => p.status === 'active' || p._localPending).length})
</div>
{plans.filter(p => p.status === 'active' || p._localPending).length === 0 && (
<div style={{ color: '#475569', fontSize: '0.75rem', fontStyle: 'italic' }}>
No active action plans for this host.
</div>
)}
{plans.filter(p => p.status === 'active' || p._localPending).map((plan, idx) => (
<PlanCard
key={plan.action_plan_id || plan.id || idx}
plan={plan}
canWrite={canWrite}
editingId={editingId}
onStartEdit={setEditingId}
onCancelEdit={() => setEditingId(null)}
onSaveEdit={handleSaveEdit}
/>
))}
</div>
{/* Section: Inactive plans (history) — collapsible */}
{plans.filter(p => p.status !== 'active' && !p._localPending).length > 0 && (
<InactiveSection plans={plans.filter(p => p.status !== 'active' && !p._localPending)} />
)}
{/* Section: Create form */}
{canWrite && (
<div style={{ padding: '1rem 1.25rem' }}>
{!showCreate ? (
<button
onClick={() => setShowCreate(true)}
style={{
...primaryBtnStyle,
width: '100%',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; e.currentTarget.style.boxShadow = 'none'; }}
data-testid="atlas-create-plan-btn"
>
<Plus style={{ width: 14, height: 14 }} />
New Action Plan
</button>
) : (
<div data-testid="atlas-create-form">
<div style={sectionTitleStyle}>
<Plus style={{ width: 14, height: 14, color: ACCENT }} />
Create Action Plan
</div>
{/* Plan type */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Plan Type</label>
<PlanTypeDropdown
value={createForm.plan_type}
onChange={val => setCreateForm({ ...createForm, plan_type: val })}
/>
</div>
{/* Commit date */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Commit Date *</label>
<input
type="date"
value={createForm.commit_date}
onChange={e => setCreateForm({ ...createForm, commit_date: e.target.value })}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Qualys ID */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Qualys ID</label>
<input
type="text"
value={createForm.qualys_id}
onChange={e => setCreateForm({ ...createForm, qualys_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Active Host Findings ID */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Findings ID</label>
<input
type="number"
value={createForm.active_host_findings_id}
onChange={e => setCreateForm({ ...createForm, active_host_findings_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Jira VNR */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Jira VNR</label>
<input
type="text"
value={createForm.jira_vnr}
onChange={e => setCreateForm({ ...createForm, jira_vnr: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Archer EXC */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Archer EXC</label>
<input
type="text"
value={createForm.archer_exc}
onChange={e => setCreateForm({ ...createForm, archer_exc: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Create error */}
{createError && (
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.625rem' }}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{createError}
</div>
)}
{/* Buttons */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleCreate}
disabled={creating}
style={{ ...primaryBtnStyle, opacity: creating ? 0.6 : 1 }}
onMouseEnter={e => { if (!creating) { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; } }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; }}
>
{creating
? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} />
: <Check style={{ width: 14, height: 14 }} />}
Create
</button>
<button
onClick={() => { setShowCreate(false); setCreateError(null); }}
style={{
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</>
);
}