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 && (
+
+ )}
)}
+
+ {/* Filter dropdown — rendered via portal at document.body */}
+ {openFilter && COLUMN_DEFS[openFilter]?.filterable && (
+ setColFilter(openFilter, vals)}
+ onClose={() => setOpenFilter(null)}
+ />
+ )}
);
}
|