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:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
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 STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -94,6 +95,28 @@ function getFilterVal(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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -725,6 +748,68 @@ export default function ReportingPage({ filterDate }) {
|
||||
|
||||
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
|
||||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||
: 'Never synced';
|
||||
@@ -811,6 +896,60 @@ export default function ReportingPage({ filterDate }) {
|
||||
Clear Filters
|
||||
</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} />
|
||||
<button
|
||||
onClick={syncFindings}
|
||||
|
||||
Reference in New Issue
Block a user