From 3e2546323e9929f6c68beb305ad5ef7e493dcec6 Mon Sep 17 00:00:00 2001 From: jramos Date: Fri, 13 Mar 2026 12:08:20 -0600 Subject: [PATCH] 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 --- frontend/package.json | 3 +- .../src/components/pages/ReportingPage.js | 141 +++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 324b36d..cd53864 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "xlsx": "^0.18.5" }, "scripts": { "start": "react-scripts start", diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index eb67126..04bd932 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -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 )} + {/* Export dropdown */} +
+ + {exportMenuOpen && ( +
+ {[ + { label: 'CSV (.csv)', action: exportCSV }, + { label: 'Excel (.xlsx)', action: exportXLSX }, + ].map(({ label, action }) => ( + + ))} +
+ )} +