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
This commit is contained in:
Jordan Ramos
2026-05-22 14:15:06 -06:00
parent e2fae896dc
commit 33e449f520

View File

@@ -132,6 +132,42 @@ async function fetchAtlasStatus() {
return res.json(); 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) { async function fetchAtlasAndFindings(teamsParam) {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]); const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.) // Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
@@ -430,6 +466,234 @@ export default function ExportsPage() {
toMultiXLSX(sheets, `atlas-full-report-${dateStr()}.xlsx`); 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 ---- // ---- Render ----
if (!canExport()) { if (!canExport()) {
@@ -581,6 +845,53 @@ export default function ExportsPage() {
</p> </p>
</ExportCard> </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>
</div> </div>
); );