diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index b890af9..d43cd34 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -120,6 +120,10 @@ function initTables(db) { // Extract only the fields we need from a raw finding object // --------------------------------------------------------------------------- function extractFinding(f) { + // statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part + const rawDueDate = f.statusEmbedded?.dueDate || ''; + const dueDate = rawDueDate ? rawDueDate.split('T')[0] : ''; + return { id: String(f.id), title: f.title || '', @@ -130,11 +134,8 @@ function extractFinding(f) { dns: f.dns || f.host?.fqdn || '', status: f.status || '', slaStatus: f.slaStatus || '', - discoveredOn: f.discoveredOn || '', - lastFoundOn: f.lastFoundOn || '', - source: f.scannerPrettyName || f.scannerName || f.source || '', - pluginFamily: f.pluginFamily || '', - findingType: f.findingType || '' + dueDate, + lastFoundOn: f.lastFoundOn || '' }; } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index d2b15a3..101c79f 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,61 +1,130 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; +const STORAGE_KEY = 'steam_findings_columns_v1'; // --------------------------------------------------------------------------- -// Column definitions +// Column definitions — source of truth for labels, sort behaviour, rendering // --------------------------------------------------------------------------- -const COLUMNS = [ - { key: 'severity', label: 'Severity', accessor: (f) => f.severity, sortable: true }, - { key: 'title', label: 'Title', accessor: (f) => f.title, sortable: true }, - { key: 'hostName', label: 'Host', accessor: (f) => f.hostName, sortable: true }, - { key: 'ipAddress', label: 'IP Address', accessor: (f) => f.ipAddress, sortable: true }, - { key: 'dns', label: 'DNS', accessor: (f) => f.dns, sortable: true }, - { key: 'slaStatus', label: 'SLA', accessor: (f) => f.slaStatus, sortable: true }, - { key: 'discoveredOn',label: 'Discovered', accessor: (f) => f.discoveredOn,sortable: true }, - { key: 'lastFoundOn', label: 'Last Found', accessor: (f) => f.lastFoundOn, sortable: true }, - { key: 'source', label: 'Source', accessor: (f) => f.source, sortable: true }, - { key: 'note', label: 'Notes', accessor: (f) => f.note, sortable: false }, +const COLUMN_DEFS = { + severity: { label: 'Severity', sortable: true }, + title: { label: 'Title', sortable: true }, + hostName: { label: 'Host', sortable: true }, + ipAddress: { label: 'IP Address', sortable: true }, + dns: { label: 'DNS', sortable: true }, + dueDate: { label: 'Due Date', sortable: true }, + slaStatus: { label: 'SLA', sortable: true }, + lastFoundOn: { label: 'Last Found', sortable: true }, + note: { label: 'Notes', sortable: false }, +}; + +const DEFAULT_COLUMN_ORDER = [ + { key: 'severity', visible: true }, + { key: 'title', visible: true }, + { key: 'hostName', visible: true }, + { key: 'ipAddress', visible: true }, + { key: 'dns', visible: true }, + { key: 'dueDate', visible: true }, + { key: 'slaStatus', visible: true }, + { key: 'lastFoundOn', visible: true }, + { key: 'note', visible: true }, ]; // --------------------------------------------------------------------------- -// Helpers +// Persist / load column config +// --------------------------------------------------------------------------- +function loadColumnOrder() { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (saved && Array.isArray(saved)) { + // Keep saved order/visibility; add any new default columns at the end + const savedKeys = new Set(saved.map((c) => c.key)); + const merged = saved.filter((c) => COLUMN_DEFS[c.key]); // drop removed cols + DEFAULT_COLUMN_ORDER.forEach((d) => { + if (!savedKeys.has(d.key)) merged.push({ ...d }); + }); + return merged; + } + } catch { /* ignore */ } + return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c })); +} + +function saveColumnOrder(order) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ } +} + +// --------------------------------------------------------------------------- +// Sort accessor by column key +// --------------------------------------------------------------------------- +function getVal(finding, key) { + switch (key) { + case 'severity': return finding.severity ?? 0; + case 'title': return finding.title ?? ''; + 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 'lastFoundOn': return finding.lastFoundOn ?? ''; + case 'note': return finding.note ?? ''; + default: return ''; + } +} + +// --------------------------------------------------------------------------- +// Style helpers // --------------------------------------------------------------------------- function severityColor(vrrGroup) { switch ((vrrGroup || '').toUpperCase()) { - case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' }; - case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' }; - case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' }; + case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' }; + case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' }; + case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' }; default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' }; } } function slaColor(slaStatus) { switch ((slaStatus || '').toUpperCase()) { - case 'OVERDUE': return '#EF4444'; - case 'AT_RISK': return '#F59E0B'; - case 'OK': return '#10B981'; - default: return '#64748B'; + case 'OVERDUE': return '#EF4444'; + case 'AT_RISK': return '#F59E0B'; + case 'WITHIN_SLA': return '#10B981'; + default: return '#64748B'; } } +function dueDateColor(dueDate) { + if (!dueDate) return '#64748B'; + const today = new Date(); + const due = new Date(dueDate); + const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24)); + if (diffDays < 0) return '#EF4444'; // overdue + if (diffDays <= 30) return '#F59E0B'; // due soon + return '#94A3B8'; +} + function SortIcon({ colKey, sort }) { - if (sort.field !== colKey) return ; + if (sort.field !== colKey) return ; return sort.dir === 'asc' - ? - : ; + ? + : ; } // --------------------------------------------------------------------------- // NoteCell — inline editable, saves on blur // --------------------------------------------------------------------------- function NoteCell({ findingId, initialNote }) { - const [value, setValue] = useState(initialNote || ''); + const [value, setValue] = useState(initialNote || ''); const [saving, setSaving] = useState(false); + const lastSaved = useRef(initialNote || ''); + + useEffect(() => { + setValue(initialNote || ''); + lastSaved.current = initialNote || ''; + }, [initialNote]); const save = useCallback(async () => { - if (value === (initialNote || '')) return; // nothing changed + if (value === lastSaved.current) return; setSaving(true); try { await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, { @@ -64,12 +133,13 @@ function NoteCell({ findingId, initialNote }) { credentials: 'include', body: JSON.stringify({ note: value }) }); + lastSaved.current = value; } catch (e) { console.error('Failed to save note:', e); } finally { setSaving(false); } - }, [findingId, value, initialNote]); + }, [findingId, value]); return (
@@ -81,27 +151,216 @@ function NoteCell({ findingId, initialNote }) { onBlur={save} placeholder="Add note…" style={{ - width: '100%', - minWidth: '160px', - background: 'rgba(14, 165, 233, 0.05)', - border: '1px solid rgba(14, 165, 233, 0.2)', - borderRadius: '4px', - padding: '4px 8px', - color: '#CBD5E1', - fontSize: '0.75rem', - fontFamily: 'inherit', - outline: 'none', - boxSizing: 'border-box' + width: '100%', minWidth: '160px', + background: 'rgba(14,165,233,0.05)', + border: '1px solid rgba(14,165,233,0.2)', + borderRadius: '4px', padding: '4px 8px', + color: '#CBD5E1', fontSize: '0.75rem', + fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} - onFocus={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 0.6)'; e.target.style.background = 'rgba(14, 165, 233, 0.1)'; }} + onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }} + onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }} /> - {saving && } + {saving && }
); } // --------------------------------------------------------------------------- -// Main ReportingPage component +// ColumnManager — popover with drag-to-reorder and show/hide toggles +// --------------------------------------------------------------------------- +function ColumnManager({ columnOrder, onChange }) { + const [open, setOpen] = useState(false); + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + const panelRef = useRef(null); + const btnRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const toggleVisible = (key) => { + const updated = columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c); + onChange(updated); + }; + + const handleDragStart = (idx) => setDragIdx(idx); + const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); }; + const handleDrop = (idx) => { + if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; } + const updated = [...columnOrder]; + const [moved] = updated.splice(dragIdx, 1); + updated.splice(idx, 0, moved); + onChange(updated); + setDragIdx(null); + setOverIdx(null); + }; + + const visibleCount = columnOrder.filter((c) => c.visible).length; + + return ( +
+ + + {open && ( +
+
+ Drag to reorder · click to toggle +
+ {columnOrder.map((col, idx) => { + const def = COLUMN_DEFS[col.key]; + const isDragging = dragIdx === idx; + const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx; + return ( +
handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={() => handleDrop(idx)} + onDragEnd={() => { setDragIdx(null); setOverIdx(null); }} + style={{ + display: 'flex', alignItems: 'center', gap: '0.5rem', + padding: '0.4rem 0.5rem', + borderRadius: '0.25rem', + cursor: 'grab', + opacity: isDragging ? 0.4 : 1, + background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent', + borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent', + transition: 'background 0.1s' + }} + > + + + {def?.label || col.key} + + +
+ ); + })} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Render a single table cell by column key +// --------------------------------------------------------------------------- +function TableCell({ colKey, finding }) { + switch (colKey) { + case 'severity': { + const sc = severityColor(finding.vrrGroup); + return ( + + + {finding.severity?.toFixed(2)} + {finding.vrrGroup} + + + ); + } + case 'title': + return ( + + + {finding.title} + + + ); + case 'hostName': + case 'ipAddress': + return ( + + {finding[colKey] || '—'} + + ); + case 'dns': + return ( + + + {finding.dns || '—'} + + + ); + case 'dueDate': { + const color = dueDateColor(finding.dueDate); + return ( + + {finding.dueDate || '—'} + + ); + } + case 'slaStatus': + return ( + + {finding.slaStatus || '—'} + + ); + case 'lastFoundOn': + return ( + + {finding.lastFoundOn || '—'} + + ); + case 'note': + return ( + + + + ); + default: + return —; + } +} + +// --------------------------------------------------------------------------- +// Main ReportingPage // --------------------------------------------------------------------------- export default function ReportingPage() { const [findings, setFindings] = useState([]); @@ -112,6 +371,13 @@ export default function ReportingPage() { const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); + const [columnOrder, setColumnOrder] = useState(loadColumnOrder); + + // Persist column changes + const updateColumns = useCallback((newOrder) => { + setColumnOrder(newOrder); + saveColumnOrder(newOrder); + }, []); const applyState = (data) => { setTotal(data.total ?? 0); @@ -152,12 +418,13 @@ export default function ReportingPage() { useEffect(() => { fetchFindings(); }, []); // eslint-disable-line - // Sort findings + // Visible columns in current order + const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]); + + // Sorted findings const sorted = [...findings].sort((a, b) => { - const col = COLUMNS.find((c) => c.key === sort.field); - if (!col) return 0; - const av = col.accessor(a) ?? ''; - const bv = col.accessor(b) ?? ''; + const av = getVal(a, sort.field); + const bv = getVal(b, sort.field); let cmp = 0; if (typeof av === 'number' && typeof bv === 'number') { cmp = av - bv; @@ -176,7 +443,7 @@ export default function ReportingPage() { }; const syncedDisplay = syncedAt - ? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString() + ? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}` : 'Never synced'; // ------------------------------------------------------------------------- @@ -186,7 +453,7 @@ export default function ReportingPage() {
{/* ---------------------------------------------------------------- - Panel 1 — Metrics placeholder (full width) + Panel 1 — Metrics placeholder ---------------------------------------------------------------- */}
-
+

Pie charts & metrics — coming soon

@@ -226,7 +487,7 @@ export default function ReportingPage() { padding: '1.5rem', boxShadow: '0 4px 16px rgba(0,0,0,0.4)' }}> - {/* Table header row */} + {/* Panel header */}

@@ -235,28 +496,33 @@ export default function ReportingPage() {
{syncedDisplay} {syncStatus === 'success' && total !== null && ( - {total} total findings + {total} findings )}

- + + {/* Action buttons */} +
+ + +
{/* Error banner */} @@ -267,7 +533,7 @@ export default function ReportingPage() {
)} - {/* Loading state */} + {/* Content */} {loading ? (
@@ -278,41 +544,40 @@ export default function ReportingPage() {

Click Sync to load findings data

) : ( - /* Table */
- {COLUMNS.map((col) => ( - - ))} + {visibleCols.map((col) => { + const def = COLUMN_DEFS[col.key]; + const active = sort.field === col.key; + return ( + + ); + })} {sorted.map((finding, idx) => { - const sc = severityColor(finding.vrrGroup); const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)'; return ( e.currentTarget.style.background = 'rgba(14,165,233,0.05)'} onMouseLeave={(e) => e.currentTarget.style.background = rowBg} > - {/* Severity */} - - - {/* Title */} - - - {/* Host */} - - - {/* IP */} - - - {/* DNS */} - - - {/* SLA */} - - - {/* Discovered */} - - - {/* Last Found */} - - - {/* Source */} - - - {/* Notes */} - + {visibleCols.map((col) => ( + + ))} ); })} {sorted.length === 0 && ( -
toggleSort(col.key) : undefined} - style={{ - padding: '0.5rem 0.75rem', - textAlign: 'left', - fontFamily: 'monospace', - fontSize: '0.68rem', - fontWeight: '600', - color: sort.field === col.key ? '#0EA5E9' : '#64748B', - textTransform: 'uppercase', - letterSpacing: '0.08em', - whiteSpace: 'nowrap', - cursor: col.sortable ? 'pointer' : 'default', - userSelect: 'none', - background: 'rgba(15,26,46,0.6)' - }} - > - - {col.label} - {col.sortable && } - - toggleSort(col.key) : undefined} + style={{ + padding: '0.5rem 0.75rem', + textAlign: 'left', + fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', + color: active ? '#0EA5E9' : '#64748B', + textTransform: 'uppercase', letterSpacing: '0.08em', + whiteSpace: 'nowrap', + cursor: def?.sortable ? 'pointer' : 'default', + userSelect: 'none', + background: 'rgba(15,26,46,0.6)' + }} + > + + {def?.label || col.key} + {def?.sortable && } + +
- - {finding.severity?.toFixed(2)} - {finding.vrrGroup} - - - - {finding.title} - - - {finding.hostName || '—'} - - {finding.ipAddress || '—'} - - - {finding.dns || '—'} - - - - {finding.slaStatus || '—'} - - - {finding.discoveredOn || '—'} - - {finding.lastFoundOn || '—'} - - {finding.source || '—'} - - -
+ No findings found