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