diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 6e2541a..04eae8a 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -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) => { diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 142f4dc..99f6424 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -398,28 +398,13 @@ const FP_WORKFLOW_DEFS = [ { key: 'Unknown', label: 'Unknown', color: '#334155' }, ]; -function FPWorkflowDonut({ findings }) { +function FPWorkflowDonut({ counts, total }) { const SIZE = 180; const CX = SIZE / 2; const CY = SIZE / 2; const OUTER = 72; const INNER = 48; - const counts = useMemo(() => { - const map = {}; - FP_WORKFLOW_DEFS.forEach(d => { map[d.key] = 0; }); - findings.forEach((f) => { - if (!f.workflow) return; - const state = f.workflow.state || ''; - const def = FP_WORKFLOW_DEFS.find(d => d.key.toLowerCase() === state.toLowerCase()); - const key = def ? def.key : 'Unknown'; - map[key] = (map[key] || 0) + 1; - }); - return map; - }, [findings]); - - const total = Object.values(counts).reduce((a, b) => a + b, 0); - if (total === 0) { return (