feat(reporting): add CSV and XLSX export to findings table

Adds an Export dropdown button to the Reporting page action bar.
Exports respect current filters, sort order, and column visibility.
CSV uses pure JS (UTF-8 BOM for Excel compatibility); XLSX uses SheetJS
with auto-fitted column widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:08:20 -06:00
parent b1a21e8771
commit 3e2546323e
2 changed files with 142 additions and 2 deletions

View File

@@ -12,7 +12,8 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
import * as XLSX from 'xlsx';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2'; const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -94,6 +95,28 @@ function getFilterVal(finding, key) {
return String(getVal(finding, key) ?? ''); return String(getVal(finding, key) ?? '');
} }
// ---------------------------------------------------------------------------
// Export value accessor — plain text representation for CSV/XLSX
// ---------------------------------------------------------------------------
function getExportVal(finding, key) {
switch (key) {
case 'findingId': return finding.id ?? '';
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
case 'title': return finding.title ?? '';
case 'cves': return (finding.cves || []).join(', ');
case 'hostName': return finding.hostName ?? '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return finding.dns ?? '';
case 'dueDate': return finding.dueDate ?? '';
case 'slaStatus': return finding.slaStatus ?? '';
case 'buOwnership': return finding.buOwnership ?? '';
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? '';
default: return '';
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Style helpers // Style helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -725,6 +748,68 @@ export default function ReportingPage({ filterDate }) {
const activeFilterCount = Object.keys(columnFilters).length; const activeFilterCount = Object.keys(columnFilters).length;
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportBtnRef = useRef(null);
// Close export menu on outside click
useEffect(() => {
if (!exportMenuOpen) return;
const handler = (e) => {
if (exportBtnRef.current && !exportBtnRef.current.contains(e.target)) {
setExportMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [exportMenuOpen]);
const buildExportRows = useCallback(() => {
const cols = visibleCols.filter((c) => COLUMN_DEFS[c.key]);
const headers = cols.map((c) => COLUMN_DEFS[c.key].label);
const rows = sorted.map((finding) =>
cols.map((c) => getExportVal(finding, c.key))
);
return [headers, ...rows];
}, [sorted, visibleCols]);
const exportCSV = useCallback(() => {
setExportMenuOpen(false);
const rows = buildExportRows();
const csvContent = rows.map((row) =>
row.map((cell) => {
const s = String(cell ?? '');
// Quote if it contains comma, double-quote, or newline
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}).join(',')
).join('\r\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `findings-export-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [buildExportRows]);
const exportXLSX = useCallback(() => {
setExportMenuOpen(false);
const rows = buildExportRows();
const ws = XLSX.utils.aoa_to_sheet(rows);
// Auto-fit column widths
const colWidths = rows[0].map((_, ci) =>
Math.min(60, Math.max(10, ...rows.map((r) => String(r[ci] ?? '').length)))
);
ws['!cols'] = colWidths.map((w) => ({ wch: w }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Findings');
XLSX.writeFile(wb, `findings-export-${new Date().toISOString().slice(0, 10)}.xlsx`);
}, [buildExportRows]);
const syncedDisplay = syncedAt const syncedDisplay = syncedAt
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}` ? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
: 'Never synced'; : 'Never synced';
@@ -811,6 +896,60 @@ export default function ReportingPage({ filterDate }) {
Clear Filters Clear Filters
</button> </button>
)} )}
{/* Export dropdown */}
<div ref={exportBtnRef} style={{ position: 'relative' }}>
<button
onClick={() => setExportMenuOpen((o) => !o)}
disabled={sorted.length === 0}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem',
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
opacity: sorted.length === 0 ? 0.4 : 1,
}}
>
<Download style={{ width: '11px', height: '11px' }} />
Export
<ChevronDown style={{ width: '10px', height: '10px', marginLeft: '1px' }} />
</button>
{exportMenuOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem', overflow: 'hidden',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
minWidth: '120px',
}}>
{[
{ label: 'CSV (.csv)', action: exportCSV },
{ label: 'Excel (.xlsx)', action: exportXLSX },
].map(({ label, action }) => (
<button
key={label}
onClick={action}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.5rem 0.875rem',
background: 'none', border: 'none',
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
color: '#10B981', cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>
{label}
</button>
))}
</div>
)}
</div>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} /> <ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<button <button
onClick={syncFindings} onClick={syncFindings}