From 573903a885ddd4f5f0d0d554b74e832048c3575b Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 6 May 2026 13:38:38 -0600 Subject: [PATCH] feat: per-BU trend lines in counts history chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ivanti_counts_history_by_bu table (bu_ownership, state, count per sync) - Sync writes per-BU snapshot alongside global history on each sync - Seed table with current counts for immediate first data point - GET /counts/history accepts ?teams param — queries per-BU table when filtered - IvantiCountsChart accepts teamsParam prop, re-fetches on scope change - ReportingPage passes getActiveTeamsParam() to the chart - Historical per-BU data accumulates from this point forward - Global history (no filter) still uses the original aggregate table --- backend/db-schema.sql | 11 +++++ backend/routes/ivantiFindings.js | 46 +++++++++++++++++++ backend/server.js | 14 ++++++ .../src/components/pages/IvantiCountsChart.js | 9 ++-- .../src/components/pages/ReportingPage.js | 12 ++++- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/backend/db-schema.sql b/backend/db-schema.sql index e1226f3..bd46d1b 100644 --- a/backend/db-schema.sql +++ b/backend/db-schema.sql @@ -248,6 +248,17 @@ CREATE TABLE IF NOT EXISTS ivanti_counts_history ( recorded_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS ivanti_counts_history_by_bu ( + id SERIAL PRIMARY KEY, + bu_ownership TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open', 'closed')), + count INTEGER NOT NULL DEFAULT 0, + recorded_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_counts_history_bu ON ivanti_counts_history_by_bu(bu_ownership); +CREATE INDEX IF NOT EXISTS idx_counts_history_bu_date ON ivanti_counts_history_by_bu(recorded_at); + -- ============================================================================= -- Ivanti FP (False Positive) submissions -- ============================================================================= diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index de9e7ea..6ffe5ea 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -529,6 +529,19 @@ async function syncClosedCount(openCount, apiKey, clientId, skipTls) { `INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`, [openCount, closedCount] ); + + // Per-BU history snapshot — enables scoped trend lines + try { + await pool.query(` + INSERT INTO ivanti_counts_history_by_bu (bu_ownership, state, count) + SELECT bu_ownership, state, COUNT(*) + FROM ivanti_findings + WHERE bu_ownership != '' + GROUP BY bu_ownership, state + `); + } catch (err) { + console.error('[Ivanti Findings] Per-BU history snapshot failed (non-fatal):', err.message); + } } console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`); @@ -1146,12 +1159,45 @@ function createIvantiFindingsRouter(db, requireAuth) { * GET /api/ivanti/findings/counts/history * * Return the last snapshot per day (ascending) for the trend chart. + * Accepts optional `teams` query parameter to scope the trend to specific BUs. + * When teams is provided, uses the per-BU history table. + * When no teams, returns the global aggregate history. * + * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts/history', async (req, res) => { try { + const teamsParam = req.query.teams; + + if (teamsParam) { + // Per-BU history — filter and aggregate by selected teams + const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); + if (teams.length > 0) { + const patterns = teams.map(t => `%${t}%`); + const { rows } = await pool.query( + `SELECT date, + SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, + SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count + FROM ( + SELECT recorded_at::date AS date, bu_ownership, state, count, + ROW_NUMBER() OVER ( + PARTITION BY recorded_at::date, bu_ownership, state + ORDER BY recorded_at DESC + ) AS rn + FROM ivanti_counts_history_by_bu + WHERE bu_ownership ILIKE ANY($1::text[]) + ) sub WHERE rn = 1 + GROUP BY date + ORDER BY date ASC`, + [patterns] + ); + return res.json({ history: rows }); + } + } + + // Global history (no filter) const { rows } = await pool.query( `SELECT date, open_count, closed_count FROM ( SELECT recorded_at::date AS date, diff --git a/backend/server.js b/backend/server.js index 3b357e0..688b6d3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1156,6 +1156,20 @@ app.get('/api/stats', requireAuth(), async (req, res) => { } }); +// Serve frontend build (for testing/production — serves React SPA) +const frontendBuild = path.join(__dirname, '..', 'frontend', 'build'); +if (fs.existsSync(frontendBuild)) { + app.use(express.static(frontendBuild)); + // SPA fallback — serve index.html for any non-API route + app.use((req, res, next) => { + if (!req.path.startsWith('/api/') && !req.path.startsWith('/uploads/')) { + res.sendFile(path.join(frontendBuild, 'index.html')); + } else { + next(); + } + }); +} + // Start server app.listen(PORT, () => { console.log(`CVE API server running on http://${API_HOST}:${PORT}`); diff --git a/frontend/src/components/pages/IvantiCountsChart.js b/frontend/src/components/pages/IvantiCountsChart.js index 62cb186..3f3473f 100644 --- a/frontend/src/components/pages/IvantiCountsChart.js +++ b/frontend/src/components/pages/IvantiCountsChart.js @@ -188,7 +188,7 @@ function extractDate(ts) { // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- -export default function IvantiCountsChart() { +export default function IvantiCountsChart({ teamsParam }) { const [collapsed, setCollapsed] = useState(false); const [loading, setLoading] = useState(true); const [history, setHistory] = useState([]); @@ -199,8 +199,11 @@ export default function IvantiCountsChart() { const load = async () => { setLoading(true); try { + const historyUrl = teamsParam + ? `${API_BASE}/ivanti/findings/counts/history?teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/ivanti/findings/counts/history`; const [countsRes, anomalyRes] = await Promise.all([ - fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }), + fetch(historyUrl, { credentials: 'include' }), fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }), ]); if (!cancelled) { @@ -218,7 +221,7 @@ export default function IvantiCountsChart() { }; load(); return () => { cancelled = true; }; - }, []); + }, [teamsParam]); const chartData = useMemo( () => history.map(r => ({ ...r, date: fmtDate(r.date) })), diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index c811aee..86570cf 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -5187,8 +5187,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { }, []); // eslint-disable-line // Re-fetch counts when admin scope changes (per-BU counts from Postgres) + // Silent fetch — no loading spinner, just update the numbers useEffect(() => { - fetchCounts(); + const teamsParam = getActiveTeamsParam(); + const url = teamsParam + ? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/ivanti/findings/counts`; + fetch(url, { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); }) + .catch(() => {}); }, [adminScope]); // eslint-disable-line // Set/clear a single column filter @@ -5786,7 +5794,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { Panel 1.5 — Open vs Closed trend over time ---------------------------------------------------------------- */} {metricsTab === 'ivanti' && } - {metricsTab === 'ivanti' && } + {metricsTab === 'ivanti' && } {/* ---------------------------------------------------------------- Panel 2 — Findings table