1 Commits

Author SHA1 Message Date
f24cdb5063 feat(reporting): add Open vs Closed donut chart to Metrics panel
Backend: adds ivanti_counts_cache table, fetches Closed count (page 0,
size 1) from Ivanti after each Open sync, and exposes GET /counts endpoint.

Frontend: replaces the Metrics placeholder with an SVG donut chart showing
Open vs Closed proportions with counts and percentages. Counts are fetched
on mount and refreshed after manual sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:23:05 -06:00
2 changed files with 247 additions and 6 deletions

View File

@@ -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;

View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
</div>
);
}
const total = open + closed;
if (total === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data click Sync to load</p>
</div>
);
}
const openDeg = (open / total) * 360;
const segments = [
{ label: 'Open', count: open, color: '#0EA5E9', start: 0, end: openDeg },
{ label: 'Closed', count: closed, color: '#475569', start: openDeg, end: 360 },
].filter((s) => s.count > 0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
{/* Gap ring behind slices */}
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
{segments.map((seg) => (
<path
key={seg.label}
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
fill={seg.color}
opacity={0.88}
/>
))}
{/* Center total */}
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
{total.toLocaleString()}
</text>
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
TOTAL
</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{segments.map((seg) => (
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
<div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{seg.label}
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
{seg.count.toLocaleString()}
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
({((seg.count / total) * 100).toFixed(1)}%)
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
function SortIcon({ colKey, sort }) {
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
return sort.dir === 'asc'
@@ -644,6 +748,8 @@ export default function ReportingPage({ filterDate }) {
const [syncError, setSyncError] = useState(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
const [countsLoading, setCountsLoading] = useState(true);
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
const [columnFilters, setColumnFilters] = useState(() =>
@@ -665,6 +771,19 @@ export default function ReportingPage({ filterDate }) {
setSyncError(data.error_message || null);
};
const fetchCounts = async () => {
setCountsLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
} catch (e) {
console.error('Error loading status counts:', e);
} finally {
setCountsLoading(false);
}
};
const fetchFindings = async () => {
setLoading(true);
try {
@@ -683,7 +802,10 @@ export default function ReportingPage({ filterDate }) {
try {
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
const data = await res.json();
if (res.ok) applyState(data);
if (res.ok) {
applyState(data);
fetchCounts(); // refresh counts after sync
}
} catch (e) {
console.error('Error syncing findings:', e);
} finally {
@@ -691,7 +813,10 @@ export default function ReportingPage({ filterDate }) {
}
};
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
useEffect(() => {
fetchFindings();
fetchCounts();
}, []); // eslint-disable-line
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
@@ -837,10 +962,18 @@ export default function ReportingPage({ filterDate }) {
Metric Graphs
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '120px', border: '1px dashed rgba(245,158,11,0.2)', borderRadius: '0.375rem', background: 'rgba(245,158,11,0.03)' }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Pie charts &amp; metrics coming soon
</p>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
{/* Open vs Closed donut */}
<div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Open vs Closed
</div>
<StatusDonut
open={statusCounts.open}
closed={statusCounts.closed}
loading={countsLoading}
/>
</div>
</div>
</div>