feat(exports): build Exports page with 5 export cards
Replaces the placeholder with a fully functional exports page. Backend: - Add GET /api/cves/compliance endpoint reading from cve_document_status view Frontend (ExportsPage.js): 1. Ivanti Host Findings — 4 sub-exports: - Full dump (all findings, all columns) - Pending Action (no FP# and no EXC in notes) - Overdue SLA (past due date or OVERDUE SLA status) - By Business Unit (multi-sheet XLSX, one sheet per BU) 2. FP Workflow Summary — one row per unique FP# ticket ID with state, finding count, affected hosts, BUs, and CVEs 3. CVE Database — status filter dropdown + CSV and XLSX format options 4. Archer Tickets — full EXC ticket list with linked CVEs and URLs 5. Document Compliance Report — per CVE/vendor doc coverage with "missing only" toggle to generate a gap list All exports are lazy (data fetched on click), per-button loading states, global dismissable error banner, auto-fit column widths in XLSX outputs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -302,6 +302,17 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Compliance export — reads from cve_document_status view
|
||||||
|
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||||
|
db.all('SELECT * FROM cve_document_status ORDER BY cve_id, vendor', [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching compliance data:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||||
|
|||||||
@@ -1,24 +1,459 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Download } from 'lucide-react';
|
import * as XLSX from 'xlsx';
|
||||||
|
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
export default function ExportsPage() {
|
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() {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings`, { 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||||
background: 'rgba(139, 92, 246, 0.1)',
|
border: `1px solid rgba(${colorRgb},0.2)`,
|
||||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
borderLeft: `3px solid ${color}`,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1.5rem',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
}}>
|
}}>
|
||||||
<Download style={{ width: '36px', height: '36px', color: '#8B5CF6' }} />
|
<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>
|
</div>
|
||||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
|
||||||
Exports
|
{description}
|
||||||
</h2>
|
|
||||||
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
|
||||||
Under construction — coming soon
|
|
||||||
</p>
|
</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 [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();
|
||||||
|
toXLSX(
|
||||||
|
[FINDING_HEADERS, ...findings.map(findingRow)],
|
||||||
|
'All Findings',
|
||||||
|
`findings-full-${dateStr()}.xlsx`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportPending = () => run('ivanti-pending', async () => {
|
||||||
|
const findings = await fetchFindings();
|
||||||
|
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
|
||||||
|
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportOverdue = () => run('ivanti-overdue', async () => {
|
||||||
|
const findings = await fetchFindings();
|
||||||
|
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-${dateStr()}.xlsx`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportByBU = () => run('ivanti-bu', async () => {
|
||||||
|
const findings = await fetchFindings();
|
||||||
|
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();
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user