Files
cve-dashboard/frontend/src/components/pages/ExportsPage.js
Jordan Ramos 33e449f520 Add Jira Tickets, CCP Metrics, and Remediation Status export cards
New export cards on the Exports page:

- Jira Tickets: All tickets, open/active only, by-CVE multi-sheet
- CCP Compliance Metrics: Current snapshot, non-compliant devices,
  trend history, full multi-sheet report
- Remediation Status: Cross-domain report combining CVEs, Jira tickets,
  Archer exceptions, and Ivanti findings into a per-CVE progress view
2026-05-22 14:15:06 -06:00

899 lines
44 KiB
JavaScript

import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import AtlasIcon from '../AtlasIcon';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function classifyFinding(f) {
if (f.workflow != null) return 'fp';
if (EXC_PATTERN.test(f.note || '')) return 'archer';
return 'pending';
}
const dateStr = () => new Date().toISOString().slice(0, 10);
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function autoFit(ws, rows) {
if (!rows[0]) return;
ws['!cols'] = rows[0].map((_, ci) => ({
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
}));
}
function toXLSX(rows, sheetName, filename) {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, filename);
}
function toMultiXLSX(sheets, filename) {
const wb = XLSX.utils.book_new();
sheets.forEach(({ name, rows }) => {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Unknown').slice(0, 31));
});
XLSX.writeFile(wb, filename);
}
function toCSV(rows, filename) {
const csv = rows.map(row =>
row.map(cell => {
const s = String(cell ?? '');
return (s.includes(',') || s.includes('"') || s.includes('\n'))
? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')
).join('\r\n');
triggerDownload(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), filename);
}
// ---------------------------------------------------------------------------
// Finding column definitions
// ---------------------------------------------------------------------------
const FINDING_HEADERS = [
'Finding ID', 'Title', 'Severity Score', 'Severity Group',
'Host', 'IP Address', 'DNS', 'Due Date', 'SLA Status',
'Business Unit', 'FP# ID', 'FP# State', 'Last Found', 'CVEs', 'Notes',
];
function findingRow(f) {
return [
f.id,
f.title,
f.severity != null ? Number(f.severity).toFixed(2) : '',
f.vrrGroup ?? '',
f.overrides?.hostName ?? f.hostName ?? '',
f.ipAddress ?? '',
f.overrides?.dns ?? f.dns ?? '',
f.dueDate ?? '',
f.slaStatus ?? '',
f.buOwnership ?? '',
f.workflow?.id ?? '',
f.workflow?.state ?? '',
f.lastFoundOn ?? '',
(f.cves || []).join(', '),
f.note ?? '',
];
}
// ---------------------------------------------------------------------------
// API fetchers
// ---------------------------------------------------------------------------
async function fetchFindings(teamsParam) {
const url = teamsParam
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
const data = await res.json();
return data.findings || [];
}
async function fetchCVEs(status) {
const url = status ? `${API_BASE}/cves?status=${encodeURIComponent(status)}` : `${API_BASE}/cves`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`CVE list returned ${res.status}`);
return res.json();
}
async function fetchArcher() {
const res = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
if (!res.ok) throw new Error(`Archer tickets returned ${res.status}`);
return res.json();
}
async function fetchCompliance() {
const res = await fetch(`${API_BASE}/cves/compliance`, { credentials: 'include' });
if (!res.ok) throw new Error(`Compliance data returned ${res.status}`);
return res.json();
}
async function fetchAtlasStatus() {
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
if (!res.ok) throw new Error(`Atlas status returned ${res.status}`);
return res.json();
}
async function fetchJiraTickets() {
const res = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
if (!res.ok) throw new Error(`Jira tickets returned ${res.status}`);
return res.json();
}
async function fetchCCPStats() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP stats returned ${res.status}`);
return res.json();
}
async function fetchCCPVerticals() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/verticals`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP verticals returned ${res.status}`);
return res.json();
}
async function fetchCCPMetrics() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP metrics returned ${res.status}`);
return res.json();
}
async function fetchCCPTrend() {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP trend returned ${res.status}`);
return res.json();
}
async function fetchCCPVerticalMetrics(code) {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(code)}/metrics`, { credentials: 'include' });
if (!res.ok) throw new Error(`CCP vertical metrics returned ${res.status}`);
return res.json();
}
async function fetchAtlasAndFindings(teamsParam) {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
const hostMap = {};
findings.forEach(f => {
if (f.hostId && !hostMap[f.hostId]) {
hostMap[f.hostId] = {
hostName: f.overrides?.hostName ?? f.hostName ?? '',
ipAddress: f.ipAddress ?? '',
dns: f.overrides?.dns ?? f.dns ?? '',
buOwnership: f.buOwnership ?? '',
findingCount: 0,
};
}
if (f.hostId && hostMap[f.hostId]) hostMap[f.hostId].findingCount++;
});
return { atlasRows, hostMap };
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
return (
<div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: `1px solid rgba(${colorRgb},0.2)`,
borderLeft: `3px solid ${color}`,
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Icon style={{ width: '18px', height: '18px', color, flexShrink: 0 }} />
<h3 style={{
fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '600',
color, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 12px rgba(${colorRgb},0.4)`, margin: 0,
}}>
{title}
</h3>
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
{description}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
{children}
</div>
</div>
);
}
function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabled }) {
const isLoading = loading === exportKey;
return (
<button
onClick={onClick}
disabled={!!loading || disabled}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.45rem 0.875rem',
background: `rgba(${colorRgb},0.08)`,
border: `1px solid rgba(${colorRgb},0.25)`,
borderRadius: '0.375rem',
color: isLoading ? '#64748B' : color,
cursor: (!!loading || disabled) ? 'not-allowed' : 'pointer',
opacity: (!!loading && !isLoading) ? 0.45 : 1,
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
letterSpacing: '0.05em',
transition: 'opacity 0.15s, color 0.15s',
whiteSpace: 'nowrap',
}}
>
{isLoading
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
: <Download style={{ width: '12px', height: '12px', flexShrink: 0 }} />
}
{label}
</button>
);
}
function Toggle({ label, checked, onChange, color, colorRgb }) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
<div
onClick={() => onChange(!checked)}
style={{
width: '32px', height: '18px', borderRadius: '9px',
background: checked ? color : 'rgba(255,255,255,0.1)',
border: `1px solid rgba(${colorRgb},0.4)`,
position: 'relative', transition: 'background 0.2s',
cursor: 'pointer', flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: '2px',
left: checked ? '14px' : '2px',
width: '12px', height: '12px', borderRadius: '50%',
background: '#E2E8F0',
transition: 'left 0.2s',
}} />
</div>
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B' }}>{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function ExportsPage() {
const { canExport, getActiveTeamsParam } = useAuth();
const teamsParam = getActiveTeamsParam();
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState('');
const [missingOnly, setMissingOnly] = useState(false);
const run = useCallback(async (key, fn) => {
setLoading(key);
setError(null);
try {
await fn();
} catch (e) {
console.error('[Export]', e);
setError(e.message || 'Export failed — check console for details');
} finally {
setLoading(null);
}
}, []);
// ---- Card 1: Ivanti Findings ----
const exportFullFindings = () => run('ivanti-full', async () => {
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
toXLSX(
[FINDING_HEADERS, ...findings.map(findingRow)],
'All Findings',
`findings-full-${scopeLabel}-${dateStr()}.xlsx`,
);
});
const exportPending = () => run('ivanti-pending', async () => {
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportOverdue = () => run('ivanti-overdue', async () => {
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const today = dateStr();
const rows = findings.filter(f => {
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
}).map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportByBU = () => run('ivanti-bu', async () => {
const findings = await fetchFindings(teamsParam);
const groups = {};
findings.forEach(f => {
const bu = f.buOwnership || 'Unknown';
if (!groups[bu]) groups[bu] = [];
groups[bu].push(f);
});
const sheets = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, rows]) => ({ name, rows: [FINDING_HEADERS, ...rows.map(findingRow)] }));
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [FINDING_HEADERS] });
toMultiXLSX(sheets, `findings-by-bu-${dateStr()}.xlsx`);
});
// ---- Card 2: FP Workflow Summary ----
const exportFPSummary = () => run('fp-summary', async () => {
const findings = await fetchFindings(teamsParam);
const fpMap = {};
findings.forEach(f => {
if (!f.workflow?.id) return;
const id = f.workflow.id;
if (!fpMap[id]) fpMap[id] = { id, state: f.workflow.state || '', count: 0, hosts: new Set(), bus: new Set(), cves: new Set() };
fpMap[id].count++;
const host = f.overrides?.hostName ?? f.hostName;
if (host) fpMap[id].hosts.add(host);
if (f.buOwnership) fpMap[id].bus.add(f.buOwnership);
(f.cves || []).forEach(c => fpMap[id].cves.add(c));
});
const headers = ['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'];
const rows = Object.values(fpMap)
.sort((a, b) => a.id.localeCompare(b.id))
.map(e => [e.id, e.state, e.count, [...e.hosts].join(', '), [...e.bus].join(', '), [...e.cves].join(', ')]);
toXLSX([headers, ...rows], 'FP Workflows', `fp-workflow-summary-${dateStr()}.xlsx`);
});
// ---- Card 3: CVE Database ----
const exportCVEs = (fmt) => run(`cves-${fmt}`, async () => {
const data = await fetchCVEs(cveStatus);
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'];
const rows = data.map(c => [c.cve_id, c.vendor, c.severity, c.status, c.published_date ?? '', c.description ?? '', c.document_count ?? 0]);
if (fmt === 'csv') {
toCSV([headers, ...rows], `cve-database-${dateStr()}.csv`);
} else {
toXLSX([headers, ...rows], 'CVEs', `cve-database-${dateStr()}.xlsx`);
}
});
// ---- Card 4: Archer Tickets ----
const exportArcher = () => run('archer', async () => {
const data = await fetchArcher();
const headers = ['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created'];
const rows = data.map(t => [t.exc_number, t.status, t.cve_id ?? '', t.vendor ?? '', t.archer_url ?? '', t.created_at ?? '']);
toXLSX([headers, ...rows], 'Archer Tickets', `archer-tickets-${dateStr()}.xlsx`);
});
// ---- Card 5: Compliance Report ----
const exportCompliance = () => run('compliance', async () => {
const data = await fetchCompliance();
const filtered = missingOnly ? data.filter(r => r.compliance_status !== 'Complete') : data;
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'];
const rows = filtered.map(r => [r.cve_id, r.vendor, r.severity, r.status, r.total_documents, r.advisory_count, r.email_count, r.screenshot_count, r.compliance_status]);
toXLSX([headers, ...rows], 'Compliance', `compliance-report-${dateStr()}.xlsx`);
});
// ---- Card 6: Atlas Action Plans ----
const ATLAS_HEADERS = ['Host ID', 'Hostname', 'IP Address', 'Business Unit', 'Open Findings', 'Active Plans', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Last Synced'];
function atlasRow(atlasEntry, hostInfo) {
const plans = JSON.parse(atlasEntry.plans_json || '[]');
const activePlans = plans.filter(p => p.status === 'active');
const h = hostInfo || {};
if (activePlans.length === 0) {
return [[
atlasEntry.host_id, h.hostName || '', h.ipAddress || '', h.buOwnership || '',
h.findingCount || '', 0, '', '', 'No Plan', '', '', '', '', atlasEntry.synced_at || '',
]];
}
return activePlans.map(p => [
atlasEntry.host_id, h.hostName || '', h.ipAddress || '', h.buOwnership || '',
h.findingCount || '', activePlans.length,
(p.plan_type || '').replace(/_/g, ' '), p.commit_date || '', p.status || '',
p.qualys_id || '', p.active_host_findings_id || '',
p.jira_vnr || '', p.archer_exc || '', atlasEntry.synced_at || '',
]);
}
const exportAtlasStatus = () => run('atlas-status', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
});
const exportAtlasGaps = () => run('atlas-gaps', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const gaps = atlasRows.filter(a => !a.has_action_plan);
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
});
const exportAtlasFull = () => run('atlas-full', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const withPlans = atlasRows.filter(a => a.has_action_plan);
const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
const sheets = [
{ name: 'Active Plans', rows: [ATLAS_HEADERS, ...withPlans.flatMap(a => atlasRow(a, hostMap[a.host_id]))] },
{ name: 'No Plan', rows: [ATLAS_HEADERS, ...withoutPlans.flatMap(a => atlasRow(a, hostMap[a.host_id]))] },
];
// Add history sheet with inactive plans
const historyHeaders = ['Host ID', 'Hostname', 'Plan Type', 'Commit Date', 'Status', 'Qualys ID', 'Findings ID', 'VNR', 'EXC', 'Created'];
const historyRows = [];
atlasRows.forEach(a => {
const plans = JSON.parse(a.plans_json || '[]');
const inactive = plans.filter(p => p.status !== 'active');
const h = hostMap[a.host_id] || {};
inactive.forEach(p => {
historyRows.push([
a.host_id, h.hostName || '',
(p.plan_type || '').replace(/_/g, ' '), p.commit_date || '', p.status || '',
p.qualys_id || '', p.active_host_findings_id || '',
p.jira_vnr || '', p.archer_exc || '', p.created_at ? p.created_at.split('T')[0] : '',
]);
});
});
sheets.push({ name: 'History', rows: [historyHeaders, ...historyRows] });
toMultiXLSX(sheets, `atlas-full-report-${dateStr()}.xlsx`);
});
// ---- Card 7: Jira Tickets ----
const exportJiraAll = () => run('jira-all', async () => {
const tickets = await fetchJiraTickets();
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
const rows = tickets.map(t => [
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
]);
toXLSX([headers, ...rows], 'All Tickets', `jira-tickets-all-${dateStr()}.xlsx`);
});
const exportJiraOpen = () => run('jira-open', async () => {
const tickets = await fetchJiraTickets();
const closedStatuses = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
const open = tickets.filter(t => {
const lower = (t.status || '').toLowerCase();
return !closedStatuses.some(s => lower.includes(s));
});
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
const rows = open.map(t => [
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
]);
toXLSX([headers, ...rows], 'Open Tickets', `jira-tickets-open-${dateStr()}.xlsx`);
});
const exportJiraByCVE = () => run('jira-by-cve', async () => {
const tickets = await fetchJiraTickets();
const groups = {};
tickets.forEach(t => {
const key = t.cve_id || 'No CVE';
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
const headers = ['Ticket Key', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced'];
const sheets = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([cve, tix]) => ({
name: cve.slice(0, 31),
rows: [headers, ...tix.map(t => [
t.ticket_key, t.vendor || '', t.summary || '', t.status || 'Open',
t.source_context || 'cve', t.url || '',
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
])],
}));
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [headers] });
toMultiXLSX(sheets, `jira-tickets-by-cve-${dateStr()}.xlsx`);
});
// ---- Card 8: CCP Metrics ----
const exportCCPSnapshot = () => run('ccp-snapshot', async () => {
const stats = await fetchCCPStats();
const verticals = stats.verticals || [];
const headers = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
const rows = verticals.map(v => [
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : (v.compliancePct != null ? `${Number(v.compliancePct).toFixed(1)}%` : ''),
v.failing_metrics ?? v.failingMetrics ?? '',
v.report_date ?? v.reportDate ?? '',
]);
toXLSX([headers, ...rows], 'CCP Snapshot', `ccp-compliance-snapshot-${dateStr()}.xlsx`);
});
const exportCCPNonCompliant = () => run('ccp-noncompliant', async () => {
const verticals = await fetchCCPVerticals();
const allRows = [];
for (const v of verticals) {
const code = v.code || v.vertical;
if (!code) continue;
try {
const metrics = await fetchCCPVerticalMetrics(code);
const metricList = metrics.metrics || metrics || [];
metricList.forEach(m => {
const devices = m.devices || [];
devices.forEach(d => {
allRows.push([
code, m.metric_id || m.metricId || '', m.metric_desc || m.metricDesc || '',
d.hostname || '', d.ip_address || d.ipAddress || '', d.device_type || d.deviceType || '',
d.team || '',
]);
});
});
} catch (e) {
// Skip verticals that fail
}
}
const headers = ['Vertical', 'Metric ID', 'Metric Description', 'Hostname', 'IP Address', 'Device Type', 'Team'];
toXLSX([headers, ...allRows], 'Non-Compliant Devices', `ccp-non-compliant-devices-${dateStr()}.xlsx`);
});
const exportCCPTrend = () => run('ccp-trend', async () => {
const trend = await fetchCCPTrend();
const snapshots = trend.snapshots || trend || [];
const headers = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
const rows = snapshots.flatMap(s => {
const date = s.report_date || s.reportDate || s.date || '';
const verts = s.verticals || [s];
return verts.map(v => [
date,
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
]);
});
toXLSX([headers, ...rows], 'Trend', `ccp-compliance-trend-${dateStr()}.xlsx`);
});
const exportCCPFull = () => run('ccp-full', async () => {
const [stats, trend] = await Promise.all([fetchCCPStats(), fetchCCPTrend()]);
const verticals = stats.verticals || [];
const snapshots = trend.snapshots || trend || [];
// Sheet 1: Summary
const summaryHeaders = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
const summaryRows = verticals.map(v => [
v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
v.failing_metrics ?? v.failingMetrics ?? '',
v.report_date ?? v.reportDate ?? '',
]);
// Sheet 2: Trend
const trendHeaders = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
const trendRows = snapshots.flatMap(s => {
const date = s.report_date || s.reportDate || s.date || '';
const verts = s.verticals || [s];
return verts.map(v => [
date, v.vertical || v.code || '',
v.total_devices ?? v.totalDevices ?? '',
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
]);
});
toMultiXLSX([
{ name: 'Summary', rows: [summaryHeaders, ...summaryRows] },
{ name: 'Trend', rows: [trendHeaders, ...trendRows] },
], `ccp-full-report-${dateStr()}.xlsx`);
});
// ---- Card 9: Remediation Status (Cross-Domain) ----
const exportRemediationStatus = () => run('remediation', async () => {
const [cves, tickets, archer, findings] = await Promise.all([
fetchCVEs(''),
fetchJiraTickets(),
fetchArcher(),
fetchFindings(teamsParam),
]);
// Build lookup maps
const ticketsByCVE = {};
tickets.forEach(t => {
const key = `${t.cve_id}|${t.vendor || ''}`;
if (!ticketsByCVE[key]) ticketsByCVE[key] = [];
ticketsByCVE[key].push(t);
});
const archerByCVE = {};
archer.forEach(a => {
const key = `${a.cve_id}|${a.vendor || ''}`;
if (!archerByCVE[key]) archerByCVE[key] = [];
archerByCVE[key].push(a);
});
const findingsByCVE = {};
findings.forEach(f => {
(f.cves || []).forEach(cve => {
if (!findingsByCVE[cve]) findingsByCVE[cve] = [];
findingsByCVE[cve].push(f);
});
});
const headers = [
'CVE ID', 'Vendor', 'Severity', 'CVE Status',
'Jira Tickets', 'Jira Statuses',
'Archer EXC#', 'Archer Status',
'Ivanti Findings', 'Overdue Findings',
'Overall Progress',
];
const rows = cves.map(c => {
const key = `${c.cve_id}|${c.vendor}`;
const cveTickets = ticketsByCVE[key] || [];
const cveArcher = archerByCVE[key] || [];
const cveFindings = findingsByCVE[c.cve_id] || [];
const today = dateStr();
const overdueCount = cveFindings.filter(f => f.dueDate && f.dueDate < today).length;
// Determine overall progress
let progress = 'Not Started';
if (cveTickets.length > 0 || cveArcher.length > 0) {
const closedKeywords = ['closed', 'done', 'resolved', 'complete', 'completed'];
const allTicketsClosed = cveTickets.length > 0 && cveTickets.every(t => closedKeywords.some(s => (t.status || '').toLowerCase().includes(s)));
const allArcherAccepted = cveArcher.length > 0 && cveArcher.every(a => a.status === 'Accepted');
if (allTicketsClosed && (cveArcher.length === 0 || allArcherAccepted)) {
progress = 'Complete';
} else {
progress = 'In Progress';
}
}
return [
c.cve_id, c.vendor, c.severity, c.status,
cveTickets.map(t => t.ticket_key).join(', '),
cveTickets.map(t => `${t.ticket_key}: ${t.status || 'Open'}`).join('; '),
cveArcher.map(a => a.exc_number).join(', '),
cveArcher.map(a => `${a.exc_number}: ${a.status}`).join('; '),
cveFindings.length,
overdueCount,
progress,
];
});
toXLSX([headers, ...rows], 'Remediation Status', `remediation-status-${dateStr()}.xlsx`);
});
// ---- Render ----
if (!canExport()) {
return (
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
</div>
);
}
return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Download style={{ width: '20px', height: '20px', color: '#8B5CF6' }} />
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)', margin: 0 }}>
Exports
</h2>
</div>
{/* Error banner */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.75rem 1rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem',
}}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#EF4444', padding: 0 }}>
<X style={{ width: '14px', height: '14px' }} />
</button>
</div>
)}
{/* Card grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: '1.5rem' }}>
{/* ── Card 1: Ivanti Findings ── */}
<ExportCard
color="#F59E0B" colorRgb="245,158,11"
icon={BarChart2}
title="Ivanti Host Findings"
description="Export host findings from the local cache. Four report types: full dump, findings with no action taken, overdue SLA, and a per-business-unit multi-sheet workbook."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="Full Dump" exportKey="ivanti-full" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportFullFindings} />
<ExportBtn label="Pending Action" exportKey="ivanti-pending" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportPending} />
<ExportBtn label="Overdue SLA" exportKey="ivanti-overdue" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportOverdue} />
<ExportBtn label="By Business Unit" exportKey="ivanti-bu" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportByBU} />
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
"By Business Unit" creates one sheet per BU in a single workbook.
</p>
</ExportCard>
{/* ── Card 2: FP Workflow Summary ── */}
<ExportCard
color="#0EA5E9" colorRgb="14,165,233"
icon={FileText}
title="FP Workflow Summary"
description="One row per unique FP# ticket ID. Shows state, how many findings belong to that ticket, which hosts are affected, and which CVEs are involved. Use this for status meetings."
>
<ExportBtn label="Export FP Summary (.xlsx)" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
</ExportCard>
{/* ── Card 3: CVE Database ── */}
<ExportCard
color="#22C55E" colorRgb="34,197,94"
icon={Shield}
title="CVE Database"
description="Export the full CVE registry. Optionally filter by status to produce a focused remediation backlog. Includes document count per entry."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap' }}>Status</span>
<select
value={cveStatus}
onChange={e => setCveStatus(e.target.value)}
disabled={!!loading}
style={{
background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)',
borderRadius: '0.25rem', color: '#CBD5E1', padding: '0.25rem 0.5rem',
fontFamily: 'monospace', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
}}
>
<option value="">All Statuses</option>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Addressed">Addressed</option>
<option value="Resolved">Resolved</option>
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<ExportBtn label="Export CSV" exportKey="cves-csv" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('csv')} />
<ExportBtn label="Export .xlsx" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('xlsx')} />
</div>
</div>
</ExportCard>
{/* ── Card 4: Archer Tickets ── */}
<ExportCard
color="#F97316" colorRgb="249,115,22"
icon={Tag}
title="Archer Risk Acceptance Tickets"
description="Export all Archer EXC exception tickets with their linked CVE IDs, vendors, statuses, and Archer URLs. Useful for risk acceptance reporting and audits."
>
<ExportBtn label="Export Archer Tickets (.xlsx)" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
</ExportCard>
{/* ── Card 5: Compliance Report ── */}
<ExportCard
color="#EF4444" colorRgb="239,68,68"
icon={CheckCircle}
title="Document Compliance Report"
description="Shows document coverage per CVE/vendor pair. A row is marked Complete when an advisory document has been uploaded; otherwise Missing Required Docs. Filter to missing-only to generate a gap list."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<Toggle
label="Missing required docs only"
checked={missingOnly}
onChange={setMissingOnly}
color="#EF4444"
colorRgb="239,68,68"
/>
<ExportBtn label="Export Compliance Report (.xlsx)" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
</div>
</ExportCard>
{/* ── Card 6: Atlas Action Plans ── */}
<ExportCard
color="#A855F7" colorRgb="168,85,247"
icon={AtlasIcon}
title="Atlas Action Plans"
description="Export Atlas InfoSec action plan status for all synced hosts. Includes plan type, commit date, and coverage status. Three report types: full status, coverage gaps only, and a multi-sheet workbook with active plans, gaps, and plan history."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="Full Status" exportKey="atlas-status" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasStatus} />
<ExportBtn label="Coverage Gaps" exportKey="atlas-gaps" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasGaps} />
</div>
<div style={{ marginTop: '0.5rem' }}>
<ExportBtn label="Full Report (multi-sheet)" exportKey="atlas-full" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasFull} />
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
"Full Report" creates three sheets: Active Plans, No Plan, and History (overridden plans).
</p>
</ExportCard>
{/* ── Card 7: Jira Tickets ── */}
<ExportCard
color="#7DD3FC" colorRgb="125,211,252"
icon={FileText}
title="Jira Tickets"
description="Export Jira ticket tracking data. Full list, open/active only, or a multi-sheet workbook grouped by CVE for remediation status meetings."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="All Tickets" exportKey="jira-all" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraAll} />
<ExportBtn label="Open/Active Only" exportKey="jira-open" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraOpen} />
</div>
<div style={{ marginTop: '0.5rem' }}>
<ExportBtn label="By CVE (multi-sheet)" exportKey="jira-by-cve" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraByCVE} />
</div>
</ExportCard>
{/* ── Card 8: CCP Metrics ── */}
<ExportCard
color="#14B8A6" colorRgb="20,184,166"
icon={BarChart2}
title="CCP Compliance Metrics"
description="Export cross-vertical compliance posture data. Current snapshot, non-compliant device list, historical trend, or a combined multi-sheet report."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="Current Snapshot" exportKey="ccp-snapshot" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPSnapshot} />
<ExportBtn label="Non-Compliant Devices" exportKey="ccp-noncompliant" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPNonCompliant} />
<ExportBtn label="Trend History" exportKey="ccp-trend" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPTrend} />
<ExportBtn label="Full Report (multi-sheet)" exportKey="ccp-full" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPFull} />
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
"Non-Compliant Devices" fetches per-metric device lists for all verticals may take a moment.
</p>
</ExportCard>
{/* ── Card 9: Remediation Status (Cross-Domain) ── */}
<ExportCard
color="#EC4899" colorRgb="236,72,153"
icon={Shield}
title="Remediation Status Report"
description="Cross-domain view combining CVE entries, linked Jira tickets, Archer exceptions, and Ivanti findings into a single per-CVE/vendor row. Shows overall progress (Not Started, In Progress, Complete) based on ticket and exception statuses."
>
<ExportBtn label="Export Remediation Status (.xlsx)" exportKey="remediation" loading={loading} color="#EC4899" colorRgb="236,72,153" onClick={exportRemediationStatus} />
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
Pulls from CVE database, Jira tickets, Archer tickets, and Ivanti findings cache. Best for leadership status updates.
</p>
</ExportCard>
</div>
</div>
);
}