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()}