feat(reporting): split FP charts into per-finding and per-ticket donuts

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 12:13:13 -06:00
parent 602c75bf24
commit 7314dc16cb
2 changed files with 72 additions and 33 deletions

View File

@@ -147,10 +147,9 @@ function initTables(db) {
) )
`, (err) => { if (err) return reject(err); }); `, (err) => { if (err) return reject(err); });
// Add fp_workflow_counts_json column if it doesn't exist yet (idempotent migration) // 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_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
// Ignore error — column already exists on subsequent startups db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
});
db.run(` db.run(`
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count) 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 wfDist = f.workflowDistribution || {};
const fpBuckets = [ const fpBuckets = [
...(wfDist.actionableWorkflows || []), ...(wfDist.actionableWorkflows || []),
@@ -298,22 +298,31 @@ function extractFPWorkflowState(f) {
].filter(w => (w.generatedId || '').startsWith('FP#')); ].filter(w => (w.generatedId || '').startsWith('FP#'));
const fpEntry = fpBuckets[0] || null; const fpEntry = fpBuckets[0] || null;
if (!fpEntry) return 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) // Sync FP stats 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 // Produces two separate counts:
// the finding and thus disappear from the open-only cache). // 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) { async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
// Seed counts from open findings (already have workflow.state) const findingCounts = {}; // state → # findings
const counts = {}; const fpIdMap = {}; // FP# id → state (deduplicates across findings)
// Seed from open findings (already extracted, have workflow.id + workflow.state)
openFindings.forEach(f => { openFindings.forEach(f => {
if (!f.workflow) return; if (!f.workflow) return;
const state = f.workflow.state || 'Unknown'; 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) // 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; totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || []; const findings = data._embedded?.hostFindings || [];
findings.forEach(f => { findings.forEach(f => {
const state = extractFPWorkflowState(f); const wf = extractFPWorkflow(f);
if (!state) return; if (!wf) return;
counts[state] = (counts[state] || 0) + 1; 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}`); console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
page++; page++;
} while (page < totalPages); } while (page < totalPages);
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message); 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, await dbRun(db,
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=? WHERE id=1`, `UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
[JSON.stringify(counts)] [JSON.stringify(findingCounts), JSON.stringify(idCounts)]
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message)); ).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) => { router.get('/fp-workflow-counts', async (req, res) => {
try { try {
const row = await new Promise((resolve, reject) => { 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); } (err, row) => { if (err) reject(err); else resolve(row); }
); );
}); });
let counts = {}; let findingCounts = {};
try { counts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {} let idCounts = {};
const total = Object.values(counts).reduce((a, b) => a + b, 0); try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
res.json({ counts, total }); 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 { } catch {
res.status(500).json({ error: 'Database error reading FP workflow counts' }); res.status(500).json({ error: 'Database error reading FP workflow counts' });
} }

View File

@@ -398,7 +398,7 @@ const FP_WORKFLOW_DEFS = [
{ key: 'Unknown', label: 'Unknown', color: '#334155' }, { key: 'Unknown', label: 'Unknown', color: '#334155' },
]; ];
function FPWorkflowDonut({ counts, total }) { function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
const SIZE = 180; const SIZE = 180;
const CX = SIZE / 2; const CX = SIZE / 2;
const CY = SIZE / 2; const CY = SIZE / 2;
@@ -438,7 +438,7 @@ function FPWorkflowDonut({ counts, total }) {
{total.toLocaleString()} {total.toLocaleString()}
</text> </text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}> <text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
FP TOTAL {centerLabel}
</text> </text>
</svg> </svg>
@@ -1076,7 +1076,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 }); const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
const [countsLoading, setCountsLoading] = useState(true); 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 [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
const [columnOrder, setColumnOrder] = useState(loadColumnOrder); const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
const [columnFilters, setColumnFilters] = useState(() => const [columnFilters, setColumnFilters] = useState(() =>
@@ -1117,7 +1117,12 @@ export default function ReportingPage({ filterDate, filterEXC }) {
try { try {
const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' });
const data = await res.json(); 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) { } catch (e) {
console.error('Error loading FP workflow counts:', e); console.error('Error loading FP workflow counts:', e);
} }
@@ -1354,12 +1359,23 @@ export default function ReportingPage({ filterDate, filterEXC }) {
{/* Divider */} {/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} /> <div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* FP Workflow Status donut */} {/* FP Finding Status donut — # of findings per FP workflow state */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
FP Finding Status
</div>
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
</div>
{/* Divider */}
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
{/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
<div style={{ flex: '0 0 auto' }}> <div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}> <div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
FP Workflow Status FP Workflow Status
</div> </div>
<FPWorkflowDonut counts={fpWorkflowCounts.counts} total={fpWorkflowCounts.total} /> <FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
</div> </div>
</div> </div>
</div> </div>