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:
@@ -132,6 +132,42 @@ async function fetchAtlasStatus() {
|
||||
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.)
|
||||
@@ -430,6 +466,234 @@ export default function ExportsPage() {
|
||||
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()) {
|
||||
@@ -581,6 +845,53 @@ export default function ExportsPage() {
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user