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 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)'} />
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)'} />
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)'} />
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)'} />
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)'} />
{editError && (
{editError}
)}
)} ); } // --------------------------------------------------------------------------- // InactiveSection — collapsible history of overridden/inactive plans // --------------------------------------------------------------------------- function InactiveSection({ plans }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{plans.map((plan, idx) => (
{plan.status || 'inactive'}
Commit {plan.commit_date || '—'}
{plan.created_at && (
Created {plan.created_at.split('T')[0]}
)}
))}
)}
); } // --------------------------------------------------------------------------- // 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 */}
{/* Panel */}
{/* Header */}
{hostName || 'Unknown Host'}
Host ID: {hostId}
{/* Success message */} {successMsg && (
{successMsg}
)} {/* Loading */} {loading && (
)} {/* Error */} {error && !loading && (
{error}
)} {/* Plan list */} {!loading && !error && (
{/* Section: Active plans */}
Active Plans ({plans.filter(p => p.status === 'active' || p._localPending).length})
{plans.filter(p => p.status === 'active' || p._localPending).length === 0 && (
No active action plans for this host.
)} {plans.filter(p => p.status === 'active' || p._localPending).map((plan, idx) => ( setEditingId(null)} onSaveEdit={handleSaveEdit} /> ))}
{/* Section: Inactive plans (history) — collapsible */} {plans.filter(p => p.status !== 'active' && !p._localPending).length > 0 && ( p.status !== 'active' && !p._localPending)} /> )} {/* Section: Create form */} {canWrite && (
{!showCreate ? ( ) : (
Create Action Plan
{/* Plan type */}
setCreateForm({ ...createForm, plan_type: val })} />
{/* Commit date */}
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)'} />
{/* Qualys ID */}
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)'} />
{/* Active Host Findings ID */}
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)'} />
{/* Jira VNR */}
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)'} />
{/* Archer EXC */}
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)'} />
{/* Create error */} {createError && (
{createError}
)} {/* Buttons */}
)}
)}
)}
); }