diff --git a/backend/server.js b/backend/server.js
index 02bfd4f..e2b9c83 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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)
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { cve_id, vendor, severity, description, published_date } = req.body;
diff --git a/frontend/src/components/pages/ExportsPage.js b/frontend/src/components/pages/ExportsPage.js
index afbf95b..42da1ea 100644
--- a/frontend/src/components/pages/ExportsPage.js
+++ b/frontend/src/components/pages/ExportsPage.js
@@ -1,25 +1,460 @@
-import React from 'react';
-import { Download } from 'lucide-react';
+import React, { useState, useCallback } from 'react';
+import * as XLSX from 'xlsx';
+import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
-export default function ExportsPage() {
- return (
-
-
-
-
-
-
- Exports
-
-
- Under construction — coming soon
-
-
-
- );
+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 (
+
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+ {children}
+
+
+ );
+}
+
+function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabled }) {
+ const isLoading = loading === exportKey;
+ return (
+
+ );
+}
+
+function Toggle({ label, checked, onChange, color, colorRgb }) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+
+ {/* Page header */}
+
+
+
+ Exports
+
+
+
+ {/* Error banner */}
+ {error && (
+
+
+
{error}
+
+
+ )}
+
+ {/* Card grid */}
+
+
+ {/* ── Card 1: Ivanti Findings ── */}
+
+
+
+
+
+
+
+
+ "By Business Unit" creates one sheet per BU in a single workbook.
+
+
+
+ {/* ── Card 2: FP Workflow Summary ── */}
+
+
+
+
+ {/* ── Card 3: CVE Database ── */}
+
+
+
+ Status
+
+
+
+ exportCVEs('csv')} />
+ exportCVEs('xlsx')} />
+
+
+
+
+ {/* ── Card 4: Archer Tickets ── */}
+
+
+
+
+ {/* ── Card 5: Compliance Report ── */}
+
+
+
+
+
+
+
+
+
+ );
}