Compare commits
1 Commits
3e2546323e
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| f24cdb5063 |
@@ -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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user