871 lines
36 KiB
JavaScript
871 lines
36 KiB
JavaScript
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
|
|
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
|
||
|
|
|
||
|
|
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' }}>
|
||
|
|
<Shield 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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|