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:
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user