diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 07fab42..b1f355d 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -9,6 +9,7 @@ const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; const FINDINGS_FILTERS = [ + // NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount() { field: 'assetCustomAttributes.1550_host_1.value', exclusive: false, @@ -38,6 +39,37 @@ const FINDINGS_FILTERS = [ } ]; +// Same BU + severity filters but for Closed state — used only to fetch the total count +const CLOSED_COUNT_FILTERS = [ + { + field: 'assetCustomAttributes.1550_host_1.value', + exclusive: false, + operator: 'IN', + orWithPrevious: false, + implicitFilters: [], + value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', + caseSensitive: false + }, + { + field: 'severity', + exclusive: false, + operator: 'RANGE', + orWithPrevious: false, + implicitFilters: [], + value: '8.5,9.9', + caseSensitive: false + }, + { + field: 'generic_state', + exclusive: false, + operator: 'EXACT', + orWithPrevious: false, + implicitFilters: [], + value: 'Closed', + caseSensitive: false + } +]; + // --------------------------------------------------------------------------- // HTTP helper — mirrors the one in ivantiWorkflows.js // --------------------------------------------------------------------------- @@ -105,6 +137,20 @@ function initTables(db) { ) `, (err) => { if (err) return reject(err); }); + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_counts_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + open_count INTEGER DEFAULT 0, + closed_count INTEGER DEFAULT 0, + synced_at DATETIME + ) + `, (err) => { if (err) return reject(err); }); + + db.run(` + INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count) + VALUES (1, 0, 0) + `, (err) => { if (err) return reject(err); }); + db.run(` CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id ON ivanti_finding_notes(finding_id) @@ -179,6 +225,42 @@ function extractFinding(f) { }; } +// --------------------------------------------------------------------------- +// Fetch total count of Closed findings from Ivanti (page 0, size 1) +// --------------------------------------------------------------------------- +async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + try { + const body = { + filters: CLOSED_COUNT_FILTERS, + projection: 'internal', + sort: [{ field: 'severity', direction: 'ASC' }], + page: 0, + size: 1 + }; + + const result = await ivantiPost(urlPath, body, apiKey, skipTls); + if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`); + + const data = JSON.parse(result.body); + // RiskSense returns total in page.totalElements or page.total + const closedCount = data.page?.totalElements ?? data.page?.total ?? 0; + + await dbRun(db, + `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, + [openCount, closedCount] + ); + console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`); + } catch (err) { + console.error('[Ivanti Findings] Failed to fetch closed count:', err.message); + // Still update open count so it stays in sync; leave closed_count as-is + await dbRun(db, + `UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`, + [openCount] + ).catch(() => {}); + } +} + // --------------------------------------------------------------------------- // Core sync — fetches ALL pages, stores slimmed findings in SQLite // --------------------------------------------------------------------------- @@ -233,6 +315,7 @@ async function syncFindings(db) { ); console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); + await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls); } catch (err) { const msg = err.message || 'Unknown error'; console.error('[Ivanti Findings] Sync failed:', msg); @@ -296,6 +379,22 @@ function readNotes(db) { }); } +function readCounts(db) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1', + (err, row) => { + if (err) return reject(err); + resolve({ + open: row?.open_count ?? 0, + closed: row?.closed_count ?? 0, + synced_at: row?.synced_at ?? null, + }); + } + ); + }); +} + async function readStateWithNotes(db) { const [state, notes] = await Promise.all([readState(db), readNotes(db)]); state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' })); @@ -333,6 +432,15 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); + // GET /counts — open vs closed totals for pie chart + router.get('/counts', async (req, res) => { + try { + res.json(await readCounts(db)); + } catch { + res.status(500).json({ error: 'Database error reading counts' }); + } + }); + // PUT /:findingId/note — save or update a note (max 255 chars enforced here) router.put('/:findingId/note', (req, res) => { const { findingId } = req.params; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 04bd932..69d1e4f 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -160,6 +160,110 @@ function workflowStyle(state) { } } +// --------------------------------------------------------------------------- +// SVG Donut Chart — Open vs Closed findings +// --------------------------------------------------------------------------- +function polarToCartesian(cx, cy, r, angleDeg) { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) { + // Full circle must be split into two arcs (SVG can't render a 360° arc) + if (Math.abs(endDeg - startDeg) >= 359.9) { + const mid = startDeg + 180; + return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' + + donutArcPath(cx, cy, outerR, innerR, mid, endDeg); + } + const largeArc = endDeg - startDeg > 180 ? 1 : 0; + const s = polarToCartesian(cx, cy, outerR, startDeg); + const e = polarToCartesian(cx, cy, outerR, endDeg); + const si = polarToCartesian(cx, cy, innerR, endDeg); + const ei = polarToCartesian(cx, cy, innerR, startDeg); + return [ + `M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`, + `A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`, + `L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`, + `A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`, + 'Z', + ].join(' '); +} + +function StatusDonut({ open, closed, loading }) { + const SIZE = 180; + const CX = SIZE / 2; + const CY = SIZE / 2; + const OUTER = 72; + const INNER = 48; + + if (loading) { + return ( +
No data — click Sync to load
+- Pie charts & metrics — coming soon -
+