From 15ad20746432560c11ce5a42f2604341dafa1fed Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 2 Apr 2026 10:12:04 -0600 Subject: [PATCH] feat(triage): Ivanti findings trend chart + rename Reporting to Vulnerability Triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add time-based open/closed tracking for Ivanti findings (Tier 2 from the reporting recommendations doc) and rename the Reporting page to Vulnerability Triage to better reflect its purpose. Backend — ivantiFindings.js: - Create ivanti_counts_history table (appended on every sync, never overwritten — Option B from design discussion) - INSERT snapshot after each successful syncClosedCount() call - GET /api/ivanti/findings/counts/history endpoint — returns last snapshot per calendar day using ROW_NUMBER window function, so multiple daily syncs collapse to the end-of-day value Frontend: - New IvantiCountsChart component: collapsible dual-line chart (open vs closed) with dark tooltip, delta label showing change since previous day, and graceful no-data states - Chart placed between the donut metrics panel and the findings table on the Vulnerability Triage page - Renamed page: 'reporting' → 'triage' (page ID, nav label, component export, all cross-file references) - ComplianceDetailPanel "View in Reporting" link updated to "View in Triage" and navigates to the correct page ID Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiFindings.js | 44 ++++ frontend/src/App.js | 10 +- frontend/src/components/NavDrawer.js | 2 +- .../components/pages/ComplianceDetailPanel.js | 4 +- .../src/components/pages/IvantiCountsChart.js | 207 ++++++++++++++++++ .../src/components/pages/ReportingPage.js | 8 +- 6 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/pages/IvantiCountsChart.js diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 98ad0e1..1158497 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -175,6 +175,15 @@ function initTables(db) { db.run(` CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id ON ivanti_finding_overrides(finding_id) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_counts_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + open_count INTEGER NOT NULL, + closed_count INTEGER NOT NULL, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) `, (err) => { if (err) reject(err); else resolve(); @@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, [openCount, closedCount] ); + + // Append a snapshot to history — every sync is stored; the history + // endpoint aggregates to last-per-day at query time (Option B). + await dbRun(db, + `INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`, + [openCount, closedCount] + ); + console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`); } catch (err) { console.error('[Ivanti Findings] Failed to fetch closed count:', err.message); @@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); + // GET /counts/history — last snapshot per day, ascending, for the trend chart. + // Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day. + router.get('/counts/history', async (req, res) => { + try { + const rows = await new Promise((resolve, reject) => { + db.all( + `SELECT date, open_count, closed_count FROM ( + SELECT DATE(recorded_at) AS date, + open_count, closed_count, + ROW_NUMBER() OVER ( + PARTITION BY DATE(recorded_at) + ORDER BY recorded_at DESC + ) AS rn + FROM ivanti_counts_history + ) WHERE rn = 1 + ORDER BY date ASC`, + [], + (err, rows) => { if (err) reject(err); else resolve(rows || []); } + ); + }); + res.json({ history: rows }); + } catch (err) { + console.error('[Ivanti Findings] GET /counts/history error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + // GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed) router.get('/fp-workflow-counts', async (req, res) => { try { diff --git a/frontend/src/App.js b/frontend/src/App.js index 7d4a8d3..4fdd12e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -10,7 +10,7 @@ import KnowledgeBaseModal from './components/KnowledgeBaseModal'; import KnowledgeBaseViewer from './components/KnowledgeBaseViewer'; import NavDrawer from './components/NavDrawer'; import CalendarWidget from './components/CalendarWidget'; -import ReportingPage from './components/pages/ReportingPage'; +import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; import CompliancePage from './components/pages/CompliancePage'; @@ -966,14 +966,14 @@ export default function App() { currentPage={currentPage} onNavigate={(page) => { // Clear contextual filters when navigating directly via the nav drawer - if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); } + if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); } setCurrentPage(page); }} /> {/* Scanning line effect */}
-
+
{/* Header */}
@@ -1043,7 +1043,7 @@ export default function App() {
{/* Page content */} - {currentPage === 'reporting' && } + {currentPage === 'triage' && } {currentPage === 'compliance' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } @@ -2231,7 +2231,7 @@ export default function App() { { setCalendarFilter(dateStr); - setCurrentPage('reporting'); + setCurrentPage('triage'); }} />
diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 365976c..49cffd8 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -3,7 +3,7 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-reac const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, - { id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' }, + { id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' }, { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' }, { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js index aafd20e..5c49d77 100644 --- a/frontend/src/components/pages/ComplianceDetailPanel.js +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -333,7 +333,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
{onNavigate && ( )}
diff --git a/frontend/src/components/pages/IvantiCountsChart.js b/frontend/src/components/pages/IvantiCountsChart.js new file mode 100644 index 0000000..7bdbb6a --- /dev/null +++ b/frontend/src/components/pages/IvantiCountsChart.js @@ -0,0 +1,207 @@ +// IvantiCountsChart.js +// Collapsible trend panel for the Vulnerability Triage page. +// Shows open vs closed Ivanti finding counts over time (last sync per day). + +import React, { useState, useEffect, useMemo } from 'react'; +import { + LineChart, Line, + XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ReferenceLine, + ResponsiveContainer, +} from 'recharts'; +import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const AMBER = '#F59E0B'; +const SKY = '#0EA5E9'; +const GREEN = '#10B981'; +const RED = '#EF4444'; + +const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' }; +const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' }; +const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }; + +// --------------------------------------------------------------------------- +// Custom dark tooltip +// --------------------------------------------------------------------------- +function DarkTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + + const openVal = payload.find(p => p.dataKey === 'open_count')?.value; + const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value; + + return ( +
+
+ {label} +
+ {payload.map(p => ( +
+ {p.name} + {p.value} +
+ ))} + {openVal != null && closedVal != null && ( +
+ total + {openVal + closedVal} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shorten YYYY-MM-DD to MM/DD/YY +// --------------------------------------------------------------------------- +function fmtDate(d) { + if (!d) return ''; + const p = d.split('-'); + if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`; + return d; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +export default function IvantiCountsChart() { + const [collapsed, setCollapsed] = useState(false); + const [loading, setLoading] = useState(true); + const [history, setHistory] = useState([]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }); + if (res.ok && !cancelled) { + const d = await res.json(); + setHistory(d.history || []); + } + } catch { /* silent — chart shows no-data state */ } + finally { if (!cancelled) setLoading(false); } + }; + load(); + return () => { cancelled = true; }; + }, []); + + const chartData = useMemo( + () => history.map(r => ({ ...r, date: fmtDate(r.date) })), + [history] + ); + + // Compute a simple delta label for the latest vs previous point + const deltaLabel = useMemo(() => { + if (chartData.length < 2) return null; + const latest = chartData[chartData.length - 1]; + const prev = chartData[chartData.length - 2]; + const delta = latest.open_count - prev.open_count; + if (delta === 0) return { text: 'no change in open', color: '#475569' }; + if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN }; + return { text: `▲ ${delta} open since ${prev.date}`, color: RED }; + }, [chartData]); + + return ( +
+ + {/* ── Header ────────────────────────────────────────────────── */} + + + {!collapsed && ( +
+
+
+ Open vs Closed — end-of-day snapshot per sync day +
+ {chartData.length > 0 && ( +
+ {chartData.length} day{chartData.length !== 1 ? 's' : ''} of data +
+ )} +
+ + {chartData.length < 2 ? ( +
+ {chartData.length === 0 + ? 'Trend data begins accumulating after the first sync — check back tomorrow' + : 'Need at least 2 days of syncs to display a trend'} +
+ ) : ( + + + + + + } /> + + + + + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index bfd12c7..bd34ac4 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; +import IvantiCountsChart from './IvantiCountsChart'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; @@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- -export default function ReportingPage({ filterDate, filterEXC }) { +export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const { canWrite } = useAuth(); const [findings, setFindings] = useState([]); const [total, setTotal] = useState(null); @@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) { + {/* ---------------------------------------------------------------- + Panel 1.5 — Open vs Closed trend over time + ---------------------------------------------------------------- */} + + {/* ---------------------------------------------------------------- Panel 2 — Findings table ---------------------------------------------------------------- */}