fix(reporting): source FP workflow status chart from DB instead of open-findings cache

The FP Workflow Status donut was reading from the in-memory open findings
array, so Approved FPs (which close the finding and remove it from the
open cache) were invisible.

Backend: during each sync, compute FP workflow state counts from open
findings then sweep all pages of closed findings to capture Approved
(and any other closed-state) FP workflows. Counts are stored in a new
fp_workflow_counts_json column on ivanti_counts_cache and exposed via
GET /api/ivanti/findings/fp-workflow-counts.

Frontend: FPWorkflowDonut now receives counts/total props from the new
endpoint (fetched on load and refreshed after manual sync) instead of
deriving them from the findings prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 11:43:57 -06:00
parent 706ef19872
commit 602c75bf24
2 changed files with 115 additions and 18 deletions

View File

@@ -147,6 +147,11 @@ 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
});
db.run(`
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
VALUES (1, 0, 0)
@@ -278,6 +283,82 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
}
}
// ---------------------------------------------------------------------------
// Extract FP workflow state from a raw (un-extracted) finding
// ---------------------------------------------------------------------------
function extractFPWorkflowState(f) {
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
...(wfDist.approvedWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const fpEntry = fpBuckets[0] || null;
if (!fpEntry) return null;
return 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).
// ---------------------------------------------------------------------------
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
// Seed counts from open findings (already have workflow.state)
const counts = {};
openFindings.forEach(f => {
if (!f.workflow) return;
const state = f.workflow.state || 'Unknown';
counts[state] = (counts[state] || 0) + 1;
});
// Sweep closed findings to pick up Approved (and any other closed FP states)
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
let page = 0;
let totalPages = 1;
try {
do {
const body = {
filters: CLOSED_COUNT_FILTERS,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
break;
}
const data = JSON.parse(result.body);
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;
});
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
}
await dbRun(db,
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=? WHERE id=1`,
[JSON.stringify(counts)]
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
console.log('[Ivanti Findings] FP workflow counts updated:', counts);
}
// ---------------------------------------------------------------------------
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
// ---------------------------------------------------------------------------
@@ -333,6 +414,7 @@ async function syncFindings(db) {
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
} catch (err) {
const msg = err.message || 'Unknown error';
console.error('[Ivanti Findings] Sync failed:', msg);
@@ -477,6 +559,23 @@ function createIvantiFindingsRouter(db, requireAuth) {
}
});
// GET /fp-workflow-counts — FP workflow state distribution (open + closed findings)
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',
(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 });
} catch {
res.status(500).json({ error: 'Database error reading FP workflow counts' });
}
});
// PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {