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 }) => ( + + ))} +
+ )} +