diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index d43cd34..5fa7e94 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -124,6 +124,9 @@ function extractFinding(f) { const rawDueDate = f.statusEmbedded?.dueDate || ''; const dueDate = rawDueDate ? rawDueDate.split('T')[0] : ''; + // BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"] + const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || ''; + return { id: String(f.id), title: f.title || '', @@ -135,7 +138,8 @@ function extractFinding(f) { status: f.status || '', slaStatus: f.slaStatus || '', dueDate, - lastFoundOn: f.lastFoundOn || '' + lastFoundOn: f.lastFoundOn || '', + buOwnership }; } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 101c79f..43712cb 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff } from 'lucide-react'; +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'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v1'; @@ -8,15 +9,16 @@ const STORAGE_KEY = 'steam_findings_columns_v1'; // Column definitions — source of truth for labels, sort behaviour, rendering // --------------------------------------------------------------------------- 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 }, + severity: { label: 'Severity', sortable: true, filterable: true }, + title: { label: 'Title', sortable: true, filterable: true }, + hostName: { label: 'Host', sortable: true, filterable: true }, + ipAddress: { label: 'IP Address', sortable: true, filterable: true }, + dns: { label: 'DNS', sortable: true, filterable: true }, + dueDate: { label: 'Due Date', sortable: true, filterable: true }, + slaStatus: { label: 'SLA', sortable: true, filterable: true }, + buOwnership: { label: 'BU', sortable: true, filterable: true }, + lastFoundOn: { label: 'Last Found', sortable: true, filterable: true }, + note: { label: 'Notes', sortable: false, filterable: false }, }; const DEFAULT_COLUMN_ORDER = [ @@ -27,6 +29,7 @@ const DEFAULT_COLUMN_ORDER = [ { key: 'dns', visible: true }, { key: 'dueDate', visible: true }, { key: 'slaStatus', visible: true }, + { key: 'buOwnership', visible: true }, { key: 'lastFoundOn', visible: true }, { key: 'note', visible: true }, ]; @@ -38,9 +41,8 @@ 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 + const merged = saved.filter((c) => COLUMN_DEFS[c.key]); DEFAULT_COLUMN_ORDER.forEach((d) => { if (!savedKeys.has(d.key)) merged.push({ ...d }); }); @@ -66,12 +68,21 @@ function getVal(finding, key) { case 'dns': return finding.dns ?? ''; case 'dueDate': return finding.dueDate ?? ''; case 'slaStatus': return finding.slaStatus ?? ''; + case 'buOwnership': return finding.buOwnership ?? ''; case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'note': return finding.note ?? ''; default: return ''; } } +// --------------------------------------------------------------------------- +// Filter accessor — severity filters by vrrGroup label, not numeric value +// --------------------------------------------------------------------------- +function getFilterVal(finding, key) { + if (key === 'severity') return finding.vrrGroup || ''; + return String(getVal(finding, key) ?? ''); +} + // --------------------------------------------------------------------------- // Style helpers // --------------------------------------------------------------------------- @@ -98,8 +109,8 @@ function dueDateColor(dueDate) { 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 + if (diffDays < 0) return '#EF4444'; + if (diffDays <= 30) return '#F59E0B'; return '#94A3B8'; } @@ -170,27 +181,23 @@ function NoteCell({ findingId, initialNote }) { // 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); + 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); - } + 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); + onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c)); }; const handleDragStart = (idx) => setDragIdx(idx); @@ -245,7 +252,7 @@ function ColumnManager({ columnOrder, onChange }) { Drag to reorder · click to toggle {columnOrder.map((col, idx) => { - const def = COLUMN_DEFS[col.key]; + const def = COLUMN_DEFS[col.key]; const isDragging = dragIdx === idx; const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx; return ( @@ -258,9 +265,7 @@ function ColumnManager({ columnOrder, onChange }) { onDragEnd={() => { setDragIdx(null); setOverIdx(null); }} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', - padding: '0.4rem 0.5rem', - borderRadius: '0.25rem', - cursor: 'grab', + 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', @@ -275,10 +280,7 @@ function ColumnManager({ columnOrder, onChange }) { onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }} > - {col.visible - ? - : - } + {col.visible ? : } ); @@ -289,6 +291,149 @@ function ColumnManager({ columnOrder, onChange }) { ); } +// --------------------------------------------------------------------------- +// FilterDropdown — portal-based so it escapes overflow:auto clipping +// --------------------------------------------------------------------------- +function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) { + const [pos, setPos] = useState({ top: 0, left: 0 }); + const [search, setSearch] = useState(''); + const panelRef = useRef(null); + const inputRef = useRef(null); + + // Compute fixed position from anchor button's viewport rect + useEffect(() => { + if (!anchorEl) return; + const r = anchorEl.getBoundingClientRect(); + setPos({ top: r.bottom + 4, left: r.left }); + setTimeout(() => inputRef.current?.focus(), 0); + }, [anchorEl]); + + // Close on outside click + useEffect(() => { + const handler = (e) => { + if (panelRef.current && !panelRef.current.contains(e.target) && + !(anchorEl && anchorEl.contains(e.target))) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [anchorEl, onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + // Unique values from the full (unfiltered) findings list + const allValues = useMemo(() => { + const vals = new Set(); + findings.forEach((f) => { + const v = getFilterVal(f, colKey).trim(); + if (v) vals.add(v); + }); + return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + }, [findings, colKey]); + + const displayed = search.trim() + ? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase())) + : allValues; + + const isChecked = (val) => !activeFilter || activeFilter.has(val); + const activeCount = activeFilter ? activeFilter.size : allValues.length; + + const toggle = (val) => { + let next; + if (!activeFilter) { + next = new Set(allValues); + next.delete(val); + } else { + next = new Set(activeFilter); + if (next.has(val)) next.delete(val); else next.add(val); + } + // If all values selected again, remove the filter entirely + onFilterChange(next.size >= allValues.length ? null : next); + }; + + return ReactDOM.createPortal( +
+ {/* Search */} + setSearch(e.target.value)} + placeholder="Search values…" + style={{ + width: '100%', marginBottom: '0.375rem', + background: 'rgba(14,165,233,0.05)', + border: '1px solid rgba(14,165,233,0.2)', + borderRadius: '0.25rem', padding: '0.3rem 0.5rem', + color: '#CBD5E1', fontSize: '0.72rem', + fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box', + }} + /> + + {/* Select All / Clear */} +
+ + +
+ + {/* Value checkboxes */} +
+ {displayed.length === 0 ? ( +
No values
+ ) : displayed.map((val) => ( + + ))} +
+ + {/* Status footer */} +
+ {activeCount} / {allValues.length} selected +
+
, + document.body + ); +} + // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- @@ -342,6 +487,31 @@ function TableCell({ colKey, finding }) { {finding.slaStatus || '—'} ); + case 'buOwnership': { + const bu = finding.buOwnership || ''; + const isSteam = bu.toUpperCase().includes('STEAM'); + return ( + + {bu ? ( + + {bu.replace('NTS-AEO-', '')} + + ) : ( + + )} + + ); + } case 'lastFoundOn': return ( @@ -363,17 +533,19 @@ function TableCell({ colKey, finding }) { // Main ReportingPage // --------------------------------------------------------------------------- export default function ReportingPage() { - const [findings, setFindings] = useState([]); - const [total, setTotal] = useState(null); - const [syncedAt, setSyncedAt] = useState(null); - const [syncStatus, setSyncStatus] = useState(null); - const [syncError, setSyncError] = useState(null); - const [loading, setLoading] = useState(false); - const [syncing, setSyncing] = useState(false); - const [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); - const [columnOrder, setColumnOrder] = useState(loadColumnOrder); + const [findings, setFindings] = useState([]); + const [total, setTotal] = useState(null); + const [syncedAt, setSyncedAt] = useState(null); + const [syncStatus, setSyncStatus] = useState(null); + const [syncError, setSyncError] = useState(null); + const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); + const [columnOrder, setColumnOrder] = useState(loadColumnOrder); + const [columnFilters, setColumnFilters] = useState({}); + const [openFilter, setOpenFilter] = useState(null); + const filterBtnRefs = useRef({}); - // Persist column changes const updateColumns = useCallback((newOrder) => { setColumnOrder(newOrder); saveColumnOrder(newOrder); @@ -390,7 +562,7 @@ export default function ReportingPage() { const fetchFindings = async () => { setLoading(true); try { - const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); + const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); const data = await res.json(); if (res.ok) applyState(data); } catch (e) { @@ -403,10 +575,7 @@ export default function ReportingPage() { const syncFindings = async () => { setSyncing(true); try { - const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { - method: 'POST', - credentials: 'include' - }); + const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' }); const data = await res.json(); if (res.ok) applyState(data); } catch (e) { @@ -418,11 +587,35 @@ export default function ReportingPage() { useEffect(() => { fetchFindings(); }, []); // eslint-disable-line + // Set/clear a single column filter + const setColFilter = useCallback((colKey, vals) => { + setColumnFilters((prev) => { + if (!vals) { + const next = { ...prev }; + delete next[colKey]; + return next; + } + return { ...prev, [colKey]: vals }; + }); + }, []); + + // Apply all active column filters to produce the visible row set + const filtered = useMemo(() => { + const active = Object.entries(columnFilters); + if (active.length === 0) return findings; + return findings.filter((f) => + active.every(([key, vals]) => { + if (!vals || vals.size === 0) return false; + return vals.has(getFilterVal(f, key).trim()); + }) + ); + }, [findings, columnFilters]); + // Visible columns in current order const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]); - // Sorted findings - const sorted = [...findings].sort((a, b) => { + // Sort filtered results + const sorted = useMemo(() => [...filtered].sort((a, b) => { const av = getVal(a, sort.field); const bv = getVal(b, sort.field); let cmp = 0; @@ -432,7 +625,7 @@ export default function ReportingPage() { cmp = String(av).localeCompare(String(bv), undefined, { numeric: true }); } return sort.dir === 'asc' ? cmp : -cmp; - }); + }), [filtered, sort]); const toggleSort = (key) => { setSort((prev) => @@ -442,6 +635,8 @@ export default function ReportingPage() { ); }; + const activeFilterCount = Object.keys(columnFilters).length; + const syncedDisplay = syncedAt ? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}` : 'Never synced'; @@ -496,13 +691,38 @@ export default function ReportingPage() {
{syncedDisplay} {syncStatus === 'success' && total !== null && ( - {total} findings + + {activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings + {activeFilterCount > 0 && ( + + ({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active) + + )} + )}
{/* Action buttons */}
+ {activeFilterCount > 0 && ( + + )} + )} ); @@ -595,7 +835,7 @@ export default function ReportingPage() { {sorted.length === 0 && ( - No findings found + {activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'} )} @@ -604,6 +844,18 @@ export default function ReportingPage() {
)} + + {/* Filter dropdown — rendered via portal at document.body */} + {openFilter && COLUMN_DEFS[openFilter]?.filterable && ( + setColFilter(openFilter, vals)} + onClose={() => setOpenFilter(null)} + /> + )} ); }