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:
@@ -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)'}
|
||||
|
||||
Reference in New Issue
Block a user