From 7314dc16cbb1a9552cdecb3336176c6a40cbd523 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 16 Mar 2026 12:13:13 -0600 Subject: [PATCH] feat(reporting): split FP charts into per-finding and per-ticket donuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed the existing FP chart to "FP Finding Status" (counts findings per workflow state) and added a new "FP Workflow Status" chart that counts unique FP# ticket IDs per state — so 10 findings under one FP# ticket counts as 1 ticket, not 10. Backend: extractFPWorkflow now returns { id, state }; syncFPWorkflowCounts builds both a finding-count map and a deduped FP# ID map, storing them in separate columns (fp_workflow_counts_json, fp_id_counts_json). The endpoint returns findingCounts/findingTotal and idCounts/idTotal. Frontend: FPWorkflowDonut accepts a centerLabel prop; both donuts share the same component fed with their respective data slices from the single fetch. Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiFindings.js | 77 ++++++++++++------- .../src/components/pages/ReportingPage.js | 28 +++++-- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 04eae8a..98ad0e1 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -147,10 +147,9 @@ function initTables(db) { ) `, (err) => { if (err) return reject(err); }); - // Add fp_workflow_counts_json column if it doesn't exist yet (idempotent migration) - db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => { - // Ignore error — column already exists on subsequent startups - }); + // Idempotent column additions — errors mean the column already exists, which is fine + db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {}); + db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {}); db.run(` INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count) @@ -284,9 +283,10 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { } // --------------------------------------------------------------------------- -// Extract FP workflow state from a raw (un-extracted) finding +// Extract FP workflow id+state from a raw (un-extracted) finding +// Returns { id, state } or null if no FP# workflow present. // --------------------------------------------------------------------------- -function extractFPWorkflowState(f) { +function extractFPWorkflow(f) { const wfDist = f.workflowDistribution || {}; const fpBuckets = [ ...(wfDist.actionableWorkflows || []), @@ -298,22 +298,31 @@ function extractFPWorkflowState(f) { ].filter(w => (w.generatedId || '').startsWith('FP#')); const fpEntry = fpBuckets[0] || null; if (!fpEntry) return null; - return fpEntry.state || 'Unknown'; + return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' }; } // --------------------------------------------------------------------------- -// Sync FP workflow state counts across ALL findings (open + closed) -// Open findings counts are derived from the already-extracted allFindings array. -// Closed findings are swept page-by-page to capture Approved FPs (which close -// the finding and thus disappear from the open-only cache). +// Sync FP stats across ALL findings (open + closed). +// +// Produces two separate counts: +// findingCounts — number of *findings* per FP workflow state +// idCounts — number of *unique FP# ticket IDs* per state +// (one FP# can cover many findings; this chart counts tickets) +// +// Open findings come from the already-extracted allFindings array. +// Closed findings are swept page-by-page to catch Approved FPs. // --------------------------------------------------------------------------- async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) { - // Seed counts from open findings (already have workflow.state) - const counts = {}; + const findingCounts = {}; // state → # findings + const fpIdMap = {}; // FP# id → state (deduplicates across findings) + + // Seed from open findings (already extracted, have workflow.id + workflow.state) openFindings.forEach(f => { if (!f.workflow) return; const state = f.workflow.state || 'Unknown'; - counts[state] = (counts[state] || 0) + 1; + const id = f.workflow.id || ''; + findingCounts[state] = (findingCounts[state] || 0) + 1; + if (id && !fpIdMap[id]) fpIdMap[id] = state; }); // Sweep closed findings to pick up Approved (and any other closed FP states) @@ -339,24 +348,32 @@ async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) totalPages = data.page?.totalPages || 1; const findings = data._embedded?.hostFindings || []; findings.forEach(f => { - const state = extractFPWorkflowState(f); - if (!state) return; - counts[state] = (counts[state] || 0) + 1; + const wf = extractFPWorkflow(f); + if (!wf) return; + findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1; + if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state; }); console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`); page++; } while (page < totalPages); } catch (err) { console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message); - // Fall through — store whatever counts we have from open findings + // Fall through — store whatever we have from open findings } + // Aggregate unique FP# IDs by state + const idCounts = {}; + Object.values(fpIdMap).forEach(state => { + idCounts[state] = (idCounts[state] || 0) + 1; + }); + await dbRun(db, - `UPDATE ivanti_counts_cache SET fp_workflow_counts_json=? WHERE id=1`, - [JSON.stringify(counts)] + `UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`, + [JSON.stringify(findingCounts), JSON.stringify(idCounts)] ).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message)); - console.log('[Ivanti Findings] FP workflow counts updated:', counts); + console.log('[Ivanti Findings] FP finding counts:', findingCounts); + console.log('[Ivanti Findings] FP workflow ID counts:', idCounts); } // --------------------------------------------------------------------------- @@ -559,18 +576,24 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); - // GET /fp-workflow-counts — FP workflow state distribution (open + closed findings) + // GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed) router.get('/fp-workflow-counts', async (req, res) => { try { const row = await new Promise((resolve, reject) => { - db.get('SELECT fp_workflow_counts_json FROM ivanti_counts_cache WHERE id=1', + db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1', (err, row) => { if (err) reject(err); else resolve(row); } ); }); - let counts = {}; - try { counts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {} - const total = Object.values(counts).reduce((a, b) => a + b, 0); - res.json({ counts, total }); + let findingCounts = {}; + let idCounts = {}; + try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {} + try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {} + res.json({ + findingCounts, + findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0), + idCounts, + idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0), + }); } catch { res.status(500).json({ error: 'Database error reading FP workflow counts' }); } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 99f6424..b443ebc 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -398,7 +398,7 @@ const FP_WORKFLOW_DEFS = [ { key: 'Unknown', label: 'Unknown', color: '#334155' }, ]; -function FPWorkflowDonut({ counts, total }) { +function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; @@ -438,7 +438,7 @@ function FPWorkflowDonut({ counts, total }) { {total.toLocaleString()} - FP TOTAL + {centerLabel} @@ -1076,7 +1076,7 @@ export default function ReportingPage({ filterDate, filterEXC }) { const [syncing, setSyncing] = useState(false); const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 }); const [countsLoading, setCountsLoading] = useState(true); - const [fpWorkflowCounts, setFPWorkflowCounts] = useState({ counts: {}, total: 0 }); + const [fpCounts, setFPCounts] = useState({ findingCounts: {}, findingTotal: 0, idCounts: {}, idTotal: 0 }); const [sort, setSort] = useState({ field: 'severity', dir: 'desc' }); const [columnOrder, setColumnOrder] = useState(loadColumnOrder); const [columnFilters, setColumnFilters] = useState(() => @@ -1117,7 +1117,12 @@ export default function ReportingPage({ filterDate, filterEXC }) { try { const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' }); const data = await res.json(); - if (res.ok) setFPWorkflowCounts({ counts: data.counts || {}, total: data.total || 0 }); + if (res.ok) setFPCounts({ + findingCounts: data.findingCounts || {}, + findingTotal: data.findingTotal || 0, + idCounts: data.idCounts || {}, + idTotal: data.idTotal || 0, + }); } catch (e) { console.error('Error loading FP workflow counts:', e); } @@ -1354,12 +1359,23 @@ export default function ReportingPage({ filterDate, filterEXC }) { {/* Divider */}
- {/* FP Workflow Status donut */} + {/* FP Finding Status donut — # of findings per FP workflow state */} +
+
+ FP Finding Status +
+ +
+ + {/* Divider */} +
+ + {/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
FP Workflow Status
- +