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>
This commit is contained in:
@@ -9,6 +9,7 @@ const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
|||||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const FINDINGS_FILTERS = [
|
const FINDINGS_FILTERS = [
|
||||||
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||||
{
|
{
|
||||||
field: 'assetCustomAttributes.1550_host_1.value',
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
exclusive: false,
|
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
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,6 +137,20 @@ function initTables(db) {
|
|||||||
)
|
)
|
||||||
`, (err) => { if (err) return reject(err); });
|
`, (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(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
ON ivanti_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
|
// 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`);
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Unknown error';
|
const msg = err.message || 'Unknown error';
|
||||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
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) {
|
async function readStateWithNotes(db) {
|
||||||
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
||||||
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
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)
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
router.put('/:findingId/note', (req, res) => {
|
router.put('/:findingId/note', (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
|
|||||||
@@ -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 }) {
|
function SortIcon({ colKey, sort }) {
|
||||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
return sort.dir === 'asc'
|
return sort.dir === 'asc'
|
||||||
@@ -644,6 +748,8 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
const [syncError, setSyncError] = useState(null);
|
const [syncError, setSyncError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [syncing, setSyncing] = 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 [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||||
const [columnFilters, setColumnFilters] = useState(() =>
|
const [columnFilters, setColumnFilters] = useState(() =>
|
||||||
@@ -665,6 +771,19 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
setSyncError(data.error_message || null);
|
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 () => {
|
const fetchFindings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -683,7 +802,10 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) applyState(data);
|
if (res.ok) {
|
||||||
|
applyState(data);
|
||||||
|
fetchCounts(); // refresh counts after sync
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error syncing findings:', e);
|
console.error('Error syncing findings:', e);
|
||||||
} finally {
|
} 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
|
// Set/clear a single column filter
|
||||||
const setColFilter = useCallback((colKey, vals) => {
|
const setColFilter = useCallback((colKey, vals) => {
|
||||||
@@ -837,10 +962,18 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
Metric Graphs
|
Metric Graphs
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</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)' }}>
|
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
||||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
{/* Open vs Closed donut */}
|
||||||
Pie charts & metrics — coming soon
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user