From d3806e8ce33642f197eb35fb7be2217c08f18a43 Mon Sep 17 00:00:00 2001 From: jramos Date: Wed, 11 Mar 2026 11:56:37 -0600 Subject: [PATCH 1/3] Add Reporting page with Ivanti host findings table Backend: - New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note) - Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state - SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs - Separate ivanti_finding_notes table persists user notes by finding ID - Daily auto-sync on startup + 24h interval, manual sync endpoint - Notes capped at 255 chars server-side Frontend (ReportingPage): - Panel 1: Metric graphs placeholder (full width, amber theme) - Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle) - Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes - Notes column: inline editable input, saves on blur via PUT endpoint - Sync button with spinner, last-synced timestamp, error banner Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/add_ivanti_findings_tables.js | 58 +++ backend/routes/ivantiFindings.js | 315 +++++++++++++ backend/server.js | 4 + .../src/components/pages/ReportingPage.js | 420 +++++++++++++++++- 4 files changed, 774 insertions(+), 23 deletions(-) create mode 100644 backend/migrations/add_ivanti_findings_tables.js create mode 100644 backend/routes/ivantiFindings.js diff --git a/backend/migrations/add_ivanti_findings_tables.js b/backend/migrations/add_ivanti_findings_tables.js new file mode 100644 index 0000000..4b3fb83 --- /dev/null +++ b/backend/migrations/add_ivanti_findings_tables.js @@ -0,0 +1,58 @@ +// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting Ivanti findings tables migration...'); + +db.serialize(() => { + // Cache table — single row holding the latest sync result + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_findings_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER DEFAULT 0, + findings_json TEXT DEFAULT '[]', + synced_at DATETIME, + sync_status TEXT DEFAULT 'never', + error_message TEXT + ) + `, (err) => { + if (err) console.error('Error creating findings cache table:', err); + else console.log('✓ ivanti_findings_cache table created'); + }); + + db.run(` + INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status) + VALUES (1, 0, '[]', 'never') + `, (err) => { + if (err) console.error('Error seeding findings cache row:', err); + else console.log('✓ ivanti_findings_cache row seeded'); + }); + + // Notes table — one row per finding, persists across cache refreshes + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_finding_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + finding_id TEXT NOT NULL UNIQUE, + note TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) console.error('Error creating finding notes table:', err); + else console.log('✓ ivanti_finding_notes table created'); + }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id + ON ivanti_finding_notes(finding_id) + `, (err) => { + if (err) console.error('Error creating notes index:', err); + else console.log('✓ finding_id index created'); + }); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js new file mode 100644 index 0000000..b890af9 --- /dev/null +++ b/backend/routes/ivantiFindings.js @@ -0,0 +1,315 @@ +// Ivanti / RiskSense Host Findings Routes +// Caches hostFinding/search results in SQLite with daily auto-sync. +// Notes are stored separately so they survive cache refreshes. + +const express = require('express'); +const https = require('https'); + +const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; +const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; + +const FINDINGS_FILTERS = [ + { + field: 'assetCustomAttributes.1550_host_1.value', + exclusive: false, + operator: 'IN', + orWithPrevious: false, + implicitFilters: [], + value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', + caseSensitive: false + }, + { + field: 'severity', + exclusive: false, + operator: 'RANGE', + orWithPrevious: false, + implicitFilters: [], + value: '8.5,9.9', + caseSensitive: false + }, + { + field: 'generic_state', + exclusive: false, + operator: 'EXACT', + orWithPrevious: false, + implicitFilters: [], + value: 'Open', + caseSensitive: false + } +]; + +// --------------------------------------------------------------------------- +// HTTP helper — mirrors the one in ivantiWorkflows.js +// --------------------------------------------------------------------------- +function ivantiPost(urlPath, body, apiKey, skipTls) { + const bodyStr = JSON.stringify(body); + const fullUrl = new URL(IVANTI_URL_BASE + urlPath); + + return new Promise((resolve, reject) => { + const options = { + hostname: fullUrl.hostname, + path: fullUrl.pathname + fullUrl.search, + method: 'POST', + headers: { + 'accept': '*/*', + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'x-http-client-type': 'browser', + 'content-length': Buffer.byteLength(bodyStr) + }, + rejectUnauthorized: !skipTls, + timeout: 20000 + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }); + + req.on('timeout', () => req.destroy(new Error('Request timed out'))); + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Table init +// --------------------------------------------------------------------------- +function initTables(db) { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_findings_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER DEFAULT 0, + findings_json TEXT DEFAULT '[]', + synced_at DATETIME, + sync_status TEXT DEFAULT 'never', + error_message TEXT + ) + `, (err) => { if (err) return reject(err); }); + + db.run(` + INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status) + VALUES (1, 0, '[]', 'never') + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_finding_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + finding_id TEXT NOT NULL UNIQUE, + note TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id + ON ivanti_finding_notes(finding_id) + `, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Extract only the fields we need from a raw finding object +// --------------------------------------------------------------------------- +function extractFinding(f) { + return { + id: String(f.id), + title: f.title || '', + severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, + vrrGroup: f.vrrGroup || f.severityGroup || '', + hostName: f.host?.hostName || '', + ipAddress: f.host?.ipAddress || '', + 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 || '' + }; +} + +// --------------------------------------------------------------------------- +// Core sync — fetches ALL pages, stores slimmed findings in SQLite +// --------------------------------------------------------------------------- +async function syncFindings(db) { + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + if (!apiKey) { + const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync'; + console.warn('[Ivanti Findings]', errMsg); + await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]); + return; + } + + console.log('[Ivanti Findings] Starting sync...'); + + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + let allFindings = []; + let page = 0; + let totalPages = 1; + + try { + do { + const body = { + filters: FINDINGS_FILTERS, + projection: 'internal', + sort: [{ field: 'severity', direction: 'ASC' }], + page, + size: 100 + }; + + const result = await ivantiPost(urlPath, body, apiKey, skipTls); + + if (result.status === 401) throw new Error('Invalid or missing API key (401)'); + if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access'); + if (result.status === 429) throw new Error('Rate limited (429) — try again later'); + if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`); + + const data = JSON.parse(result.body); + totalPages = data.page?.totalPages || 1; + const findings = data._embedded?.hostFindings || []; + allFindings = allFindings.concat(findings.map(extractFinding)); + + console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`); + page++; + } while (page < totalPages); + + await dbRun(db, + `UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`, + [allFindings.length, JSON.stringify(allFindings)] + ); + + console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); + } catch (err) { + const msg = err.message || 'Unknown error'; + console.error('[Ivanti Findings] Sync failed:', msg); + await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]); + } +} + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- +function scheduleSync(db) { + db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => { + if (err || !row || !row.synced_at) { + syncFindings(db); + } else { + const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z'); + const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); + if (hoursSince >= 24) { + syncFindings(db); + } else { + console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`); + } + } + }); + + setInterval(() => syncFindings(db), SYNC_INTERVAL_MS); +} + +// --------------------------------------------------------------------------- +// DB helpers +// --------------------------------------------------------------------------- +function dbRun(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, (err) => { if (err) reject(err); else resolve(); }); + }); +} + +function readState(db) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1', + (err, row) => { + if (err) return reject(err); + if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null }); + let findings = []; + try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ } + resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message }); + } + ); + }); +} + +function readNotes(db) { + return new Promise((resolve, reject) => { + db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => { + if (err) return reject(err); + const map = {}; + (rows || []).forEach((r) => { map[r.finding_id] = r.note; }); + resolve(map); + }); + }); +} + +async function readStateWithNotes(db) { + const [state, notes] = await Promise.all([readState(db), readNotes(db)]); + state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' })); + return state; +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- +function createIvantiFindingsRouter(db, requireAuth) { + const router = express.Router(); + + initTables(db) + .then(() => scheduleSync(db)) + .catch((err) => console.error('[Ivanti Findings] Init failed:', err)); + + router.use(requireAuth(db)); + + // GET / — cached findings with notes merged in + router.get('/', async (req, res) => { + try { + res.json(await readStateWithNotes(db)); + } catch { + res.status(500).json({ error: 'Database error reading findings' }); + } + }); + + // POST /sync — trigger immediate sync, return fresh state + router.post('/sync', async (req, res) => { + await syncFindings(db); + try { + res.json(await readStateWithNotes(db)); + } catch { + res.status(500).json({ error: 'Sync ran but could not read updated state' }); + } + }); + + // PUT /:findingId/note — save or update a note (max 255 chars enforced here) + router.put('/:findingId/note', (req, res) => { + const { findingId } = req.params; + const note = String(req.body.note || '').slice(0, 255); + + db.run( + `INSERT INTO ivanti_finding_notes (finding_id, note, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`, + [findingId, note], + (err) => { + if (err) return res.status(500).json({ error: 'Failed to save note' }); + res.json({ finding_id: findingId, note }); + } + ); + }); + + return router; +} + +module.exports = createIvantiFindingsRouter; diff --git a/backend/server.js b/backend/server.js index ec9c0a7..02bfd4f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -21,6 +21,7 @@ const createNvdLookupRouter = require('./routes/nvdLookup'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createArcherTicketsRouter = require('./routes/archerTickets'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); +const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const app = express(); const PORT = process.env.PORT || 3001; @@ -183,6 +184,9 @@ app.use('/api/archer-tickets', createArcherTicketsRouter(db)); // Ivanti / RiskSense workflow routes (all authenticated users) app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth)); +// Ivanti / RiskSense host findings routes (all authenticated users) +app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth)); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 831c472..d2b15a3 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,25 +1,399 @@ -import React from 'react'; -import { BarChart2 } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; -export default function ReportingPage() { - return ( -
-
-
- -
-

- Reporting -

-

- Under construction — coming soon -

-
-
- ); +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Column definitions +// --------------------------------------------------------------------------- +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 }, +]; + +// --------------------------------------------------------------------------- +// 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' }; + 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'; + } +} + +function SortIcon({ colKey, sort }) { + 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 [saving, setSaving] = useState(false); + + const save = useCallback(async () => { + if (value === (initialNote || '')) return; // nothing changed + setSaving(true); + try { + await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ note: value }) + }); + } catch (e) { + console.error('Failed to save note:', e); + } finally { + setSaving(false); + } + }, [findingId, value, initialNote]); + + return ( +
+ setValue(e.target.value)} + 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' + }} + onFocus={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 0.6)'; e.target.style.background = 'rgba(14, 165, 233, 0.1)'; }} + /> + {saving && } +
+ ); +} + +// --------------------------------------------------------------------------- +// Main ReportingPage component +// --------------------------------------------------------------------------- +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 applyState = (data) => { + setTotal(data.total ?? 0); + setFindings(data.findings || []); + setSyncedAt(data.synced_at || null); + setSyncStatus(data.sync_status || null); + setSyncError(data.error_message || null); + }; + + const fetchFindings = async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); + const data = await res.json(); + if (res.ok) applyState(data); + } catch (e) { + console.error('Error loading findings:', e); + } finally { + setLoading(false); + } + }; + + const syncFindings = async () => { + setSyncing(true); + try { + 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) { + console.error('Error syncing findings:', e); + } finally { + setSyncing(false); + } + }; + + useEffect(() => { fetchFindings(); }, []); // eslint-disable-line + + // Sort 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) ?? ''; + let cmp = 0; + if (typeof av === 'number' && typeof bv === 'number') { + cmp = av - bv; + } else { + cmp = String(av).localeCompare(String(bv), undefined, { numeric: true }); + } + return sort.dir === 'asc' ? cmp : -cmp; + }); + + const toggleSort = (key) => { + setSort((prev) => + prev.field === key + ? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } + : { field: key, dir: 'asc' } + ); + }; + + const syncedDisplay = syncedAt + ? new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString() + : 'Never synced'; + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + return ( +
+ + {/* ---------------------------------------------------------------- + Panel 1 — Metrics placeholder (full width) + ---------------------------------------------------------------- */} +
+
+ +

+ Metric Graphs +

+
+
+

+ Pie charts & metrics — coming soon +

+
+
+ + {/* ---------------------------------------------------------------- + Panel 2 — Findings table + ---------------------------------------------------------------- */} +
+ {/* Table header row */} +
+
+

+ Host Findings +

+
+ {syncedDisplay} + {syncStatus === 'success' && total !== null && ( + {total} total findings + )} +
+
+ +
+ + {/* Error banner */} + {syncStatus === 'error' && syncError && ( +
+ + {syncError} +
+ )} + + {/* Loading state */} + {loading ? ( +
+ +

Loading findings…

+
+ ) : syncStatus === 'never' ? ( +
+

Click Sync to load findings data

+
+ ) : ( + /* Table */ +
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + {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 */} + + + ); + })} + {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 && } + +
+ + {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 +
+
+ )} +
+
+ ); } From 8697ba4ef3b2ed5dbb054bb1c4572f4b75468806 Mon Sep 17 00:00:00 2001 From: jramos Date: Wed, 11 Mar 2026 12:47:11 -0600 Subject: [PATCH 2/3] Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source Backend: - Extract dueDate from statusEmbedded.dueDate (strip time portion) - Remove discoveredOn and source from extractFinding (not needed) Frontend: - Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future) - Remove Discovered and Source columns - ColumnManager component: gear button opens popover with drag-to-reorder and eye toggle per column; column state persisted to localStorage - Column order/visibility survives page refresh and syncs - SortIcon, TableCell, NoteCell all driven by current visible column list Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiFindings.js | 11 +- .../src/components/pages/ReportingPage.js | 536 ++++++++++++------ 2 files changed, 379 insertions(+), 168 deletions(-) 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 && ( - From 1f36d302eab075c82d7dd1d4d79ae331efd271f8 Mon Sep 17 00:00:00 2001 From: jramos Date: Wed, 11 Mar 2026 13:03:17 -0600 Subject: [PATCH 3/3] feat(reporting): add BU Ownership column and per-column Excel-style filters - buOwnership field extracted from assetCustomAttributes['1550_host_1'][0] and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG) - All columns except Notes get a funnel filter button in the header - FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape overflowX:auto clipping; shows unique value checkboxes with search input, Select All, Clear, and a selected/total count footer - Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value - columnFilters state gates a useMemo filtered array before sorting - Active filter count shown in panel header with amber badge; Clear Filters button appears in the toolbar when any filters are active - Empty Set filter (Clear All) hides all rows, consistent with Excel Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiFindings.js | 6 +- .../src/components/pages/ReportingPage.js | 370 +++++++++++++++--- 2 files changed, 316 insertions(+), 60 deletions(-) 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 ( +
+ ); + } case 'lastFoundOn': return ( {visibleCols.map((col) => { - const def = COLUMN_DEFS[col.key]; - const active = sort.field === col.key; + const def = COLUMN_DEFS[col.key]; + const active = sort.field === col.key; + const isFiltered = !!columnFilters[col.key]; return ( ); @@ -595,7 +835,7 @@ export default function ReportingPage() { {sorted.length === 0 && ( )} @@ -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)} + /> + )} ); }
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
+ {bu ? ( + + {bu.replace('NTS-AEO-', '')} + + ) : ( + + )} + @@ -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 && ( + + )}
toggleSort(col.key) : undefined} style={{ - padding: '0.5rem 0.75rem', - textAlign: 'left', + 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)' + background: 'rgba(15,26,46,0.6)', + position: 'relative', }} > {def?.label || col.key} {def?.sortable && } + {def?.filterable && ( + + )}
- No findings found + {activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}