Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
|
|
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
|
2026-04-23 22:18:23 +00:00
|
|
|
import AtlasIcon from './AtlasIcon';
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
|
|
|
|
|
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' }}>
|
2026-04-23 22:18:23 +00:00
|
|
|
<AtlasIcon style={{ width: 16, height: 16, color: ACCENT, flexShrink: 0 }} />
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|