Add CI/CD pipeline, feedback modal, Atlas qualys_id fallback, and health endpoint

- Rewrite .gitlab-ci.yml with proper stages, blocking tests, staging
  environment on dev box, and SSH-based production deploy to 71.85.90.6
- Add POST /api/health endpoint for pipeline verification
- Add POST /atlas/hosts/:hostId/refresh-cache for Atlas cache staleness
- AtlasSlideOutPanel: auto-resolve qualys_id from Atlas vulnerabilities,
  prefer qualys_id over active_host_findings_id, retry on failure
- Add FeedbackModal component with bug report button in header and
  feature request in UserMenu, creates GitLab issues via /api/feedback
- Fix all frontend test failures (ESM transforms, TextDecoder polyfill,
  fast-check resolution, App.test.js boilerplate replacement)
- Fix root package.json test script to run jest
- Add deploy/ directory with staging systemd service and setup script
This commit is contained in:
Jordan Ramos
2026-05-08 12:47:39 -06:00
parent 86fdd084ac
commit de2c5f245e
14 changed files with 1049 additions and 66 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown, RefreshCw } from 'lucide-react';
import AtlasIcon from './AtlasIcon';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -469,6 +469,14 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
// Resolved qualys_id from Atlas vulnerabilities lookup
const [resolvedQualysId, setResolvedQualysId] = useState(qualysId || '');
const [qualysLoading, setQualysLoading] = useState(false);
// Cache refresh state
const [refreshing, setRefreshing] = useState(false);
const [refreshMsg, setRefreshMsg] = useState(null);
// Create form state — prepopulate qualys_id and findings ID from the clicked finding
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
@@ -483,6 +491,43 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
const [createError, setCreateError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
// -----------------------------------------------------------------------
// Resolve qualys_id from Atlas vulnerabilities for this host+finding
// -----------------------------------------------------------------------
useEffect(() => {
if (qualysId || !hostId || !findingId) return;
let cancelled = false;
const resolve = async () => {
setQualysLoading(true);
try {
const res = await fetch(`${API_BASE}/atlas/hosts/vulnerabilities`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host_ids: [hostId] }),
});
if (!res.ok) return;
const data = await res.json();
// Atlas returns { "host_id": [ { qualys_id, title, active_host_findings_id, ... }, ... ] }
const vulns = data[String(hostId)] || data[hostId] || [];
if (!Array.isArray(vulns)) return;
// Find the vuln that matches our finding ID
const match = vulns.find(v =>
String(v.active_host_findings_id) === String(findingId) ||
String(v.id) === String(findingId)
);
if (match && !cancelled) {
const qid = match.qualys_id || match.sourceId || '';
setResolvedQualysId(qid);
setCreateForm(prev => ({ ...prev, qualys_id: qid }));
}
} catch (_) { /* non-fatal */ }
finally { if (!cancelled) setQualysLoading(false); }
};
resolve();
return () => { cancelled = true; };
}, [hostId, findingId, qualysId]);
// -----------------------------------------------------------------------
// Parse Atlas response — handles { active: [...], inactive: [...] } format
// -----------------------------------------------------------------------
@@ -565,7 +610,8 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
}, [successMsg]);
// -----------------------------------------------------------------------
// Create plan
// Create plan — prefers qualys_id over active_host_findings_id for
// resilience against Atlas cache staleness.
// -----------------------------------------------------------------------
const handleCreate = async () => {
if (!createForm.commit_date) {
@@ -579,19 +625,46 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
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);
// Prefer qualys_id — it's stable across cache refreshes.
// Only fall back to active_host_findings_id if no qualys_id is available.
if (createForm.qualys_id.trim()) {
body.qualys_id = createForm.qualys_id.trim();
} else 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`, {
let 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})`);
let data = await res.json().catch(() => ({}));
// If the request failed due to finding not found and we used active_host_findings_id,
// retry with qualys_id if we have one resolved
if (!res.ok && body.active_host_findings_id && !body.qualys_id && resolvedQualysId) {
const retryBody = { ...body };
delete retryBody.active_host_findings_id;
retryBody.qualys_id = resolvedQualysId;
res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(retryBody),
});
data = await res.json().catch(() => ({}));
if (res.ok) {
// Update form to use the working qualys_id going forward
setCreateForm(prev => ({ ...prev, qualys_id: resolvedQualysId }));
}
}
if (!res.ok) throw new Error(data.error || data.detail || `Create failed (${res.status})`);
// Add optimistic local plan immediately — shown as "pending" until sync confirms
const localPlan = {
@@ -609,7 +682,7 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
setPlans(prev => [localPlan, ...prev]);
// Reset form
setCreateForm({ plan_type: 'remediation', commit_date: '', qualys_id: qualysId || '', active_host_findings_id: findingId || '', jira_vnr: '', archer_exc: '' });
setCreateForm({ plan_type: 'remediation', commit_date: '', qualys_id: resolvedQualysId || qualysId || '', active_host_findings_id: findingId || '', jira_vnr: '', archer_exc: '' });
setShowCreate(false);
setSuccessMsg('Action plan created');
if (onPlanChange) onPlanChange();
@@ -620,6 +693,30 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
}
};
// -----------------------------------------------------------------------
// Refresh Atlas cache for this host
// -----------------------------------------------------------------------
const handleRefreshCache = async () => {
setRefreshing(true);
setRefreshMsg(null);
try {
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/refresh-cache`, {
method: 'POST',
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Refresh failed (${res.status})`);
setRefreshMsg('Cache refreshed');
// Re-fetch plans after cache refresh
await fetchPlans();
} catch (err) {
setRefreshMsg('Refresh failed: ' + err.message);
} finally {
setRefreshing(false);
setTimeout(() => setRefreshMsg(null), 4000);
}
};
// -----------------------------------------------------------------------
// Edit plan
// -----------------------------------------------------------------------
@@ -662,6 +759,43 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace" }}>
Host ID: {hostId}
</span>
{canWrite && (
<button
onClick={handleRefreshCache}
disabled={refreshing}
title="Refresh Atlas cache for this host"
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
marginTop: '0.4rem',
padding: '0.25rem 0.5rem',
background: 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.25rem',
color: '#64748B',
fontSize: '0.62rem',
fontFamily: "'JetBrains Mono', monospace",
cursor: refreshing ? 'wait' : 'pointer',
opacity: refreshing ? 0.6 : 1,
transition: 'all 0.15s',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
onMouseEnter={e => { if (!refreshing) { e.currentTarget.style.color = '#38BDF8'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; } }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.15)'; }}
>
<RefreshCw style={{ width: 10, height: 10, animation: refreshing ? 'spin 1s linear infinite' : 'none' }} />
{refreshing ? 'Refreshing...' : 'Refresh Cache'}
</button>
)}
{refreshMsg && (
<span style={{
display: 'block', marginTop: '0.3rem',
fontSize: '0.62rem', fontFamily: "'JetBrains Mono', monospace",
color: refreshMsg.startsWith('Refresh failed') ? '#F87171' : '#10B981',
}}>
{refreshMsg}
</span>
)}
</div>
<button
onClick={onClose}
@@ -797,12 +931,16 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
{/* Qualys ID */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Qualys ID</label>
<label style={labelStyle}>
Qualys ID
{qualysLoading && <span style={{ color: '#475569', marginLeft: '0.4rem', fontStyle: 'italic', textTransform: 'none' }}>(resolving...)</span>}
{!qualysLoading && resolvedQualysId && !qualysId && <span style={{ color: '#10B981', marginLeft: '0.4rem', textTransform: 'none' }}>(auto-resolved)</span>}
</label>
<input
type="text"
value={createForm.qualys_id}
onChange={e => setCreateForm({ ...createForm, qualys_id: e.target.value })}
placeholder="Optional"
placeholder="Preferred — stable across cache refreshes"
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)'}