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
899 lines
44 KiB
JavaScript
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>
|
|
);
|
|
}
|