Files
cve-dashboard/frontend/src/components/pages/ComplianceChartsPanel.js
jramos b111273e5a feat(compliance): add time-based trend charts to Compliance page
Add 6 Recharts charts in a collapsible Historical Trends panel on the
Compliance page, covering all Tier-1 recommendations from the reporting
design doc.

Backend — 5 new API endpoints:
  - GET /api/compliance/trends        — active totals + per-team counts per upload
  - GET /api/compliance/mttr          — mean days to resolution per team
  - GET /api/compliance/top-recurring — most persistent active findings by seen_count
  - GET /api/compliance/category-trend — category breakdown per upload (future use)
  - GET /api/archer-tickets/status-trend — ticket pipeline by creation date + status

Frontend — new ComplianceChartsPanel component:
  - Active Findings Over Time (multi-line: total + per-team dashed)
  - Change per Report Cycle (stacked bar: new/recurring + resolved)
  - Team Compliance Health (multi-line per team)
  - Mean Time to Resolution (horizontal bar per team)
  - Most Persistent Findings (horizontal bar top-10 by seen_count)
  - Archer Exception Pipeline (stacked bar by date + status)

All charts degrade gracefully to a no-data placeholder until uploads
accumulate. Panel is collapsible to stay out of the way when not needed.
Adds recharts dependency to frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:49:32 -06:00

425 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ComplianceChartsPanel.js
// Tier-1 time-based compliance charts using Recharts.
// Charts rendered: Active Findings Over Time, Change per Cycle,
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
import React, { useState, useEffect, useMemo } from 'react';
import {
LineChart, Line,
BarChart, Bar,
XAxis, YAxis, CartesianGrid,
Tooltip, Legend,
ResponsiveContainer,
} from 'recharts';
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const TEAM_COLORS = {
'STEAM': '#0EA5E9',
'ACCESS-ENG': '#F59E0B',
'ACCESS-OPS': '#8B5CF6',
'INTELDEV': '#10B981',
};
const ARCHER_STATUS_COLORS = {
'Draft': '#475569',
'Open': '#0EA5E9',
'Under Review': '#F59E0B',
'Accepted': '#10B981',
};
// ---------------------------------------------------------------------------
// Shared style tokens
// ---------------------------------------------------------------------------
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
// ---------------------------------------------------------------------------
// Custom dark tooltip
// ---------------------------------------------------------------------------
function DarkTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
return (
<div style={{
background: 'rgba(10,17,32,0.97)',
border: '1px solid rgba(20,184,166,0.3)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.7rem',
minWidth: '130px',
}}>
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
{label}
</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ opacity: 0.8 }}>{p.name}</span>
<span style={{ fontWeight: '700' }}>
{typeof p.value === 'number'
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
: p.value}
</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Chart card wrapper
// ---------------------------------------------------------------------------
function ChartCard({ title, subtitle, children }) {
return (
<div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '1rem 1.125rem 0.875rem',
}}>
<div style={{ marginBottom: '0.75rem' }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
{title}
</div>
{subtitle && (
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
{subtitle}
</div>
)}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty / no-data state
// ---------------------------------------------------------------------------
function NoData({ msg }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '160px', color: '#334155',
fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(20,184,166,0.1)',
borderRadius: '0.375rem',
}}>
{msg || 'No data yet — upload compliance reports to populate this chart'}
</div>
);
}
// ---------------------------------------------------------------------------
// Shorten a YYYY-MM-DD string to MM/DD/YY
// ---------------------------------------------------------------------------
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
// ---------------------------------------------------------------------------
// Chart 1 — Active Findings Over Time (line, total + per team)
// ---------------------------------------------------------------------------
function ActiveTrendChart({ data }) {
if (data.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Line
type="monotone" dataKey="total_active" name="Total"
stroke={TEAL} strokeWidth={2}
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={1.5}
dot={false} strokeDasharray="5 3"
activeDot={{ r: 4, fill: color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
// ---------------------------------------------------------------------------
function DeltaChart({ data }) {
if (data.length === 0) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 3 — Team Health Multi-Line
// ---------------------------------------------------------------------------
function TeamTrendChart({ data }) {
if (data.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={2}
dot={{ r: 3, fill: color, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 4 — MTTR by Team (horizontal bar)
// ---------------------------------------------------------------------------
function MttrChart({ data }) {
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
<Tooltip content={<DarkTooltip />} />
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
// ---------------------------------------------------------------------------
function RecurringChart({ data }) {
if (data.length === 0) return <NoData />;
const top10 = data.slice(0, 10).map(r => ({
...r,
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
}));
return (
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
]} />
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
// ---------------------------------------------------------------------------
function ArcherPipelineChart({ data }) {
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
<Bar
key={status}
dataKey={status} name={status} stackId="s"
fill={color} fillOpacity={0.85}
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
export default function ComplianceChartsPanel() {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [trends, setTrends] = useState([]);
const [mttr, setMttr] = useState([]);
const [recurring, setRecurring] = useState([]);
const [archerRaw, setArcherRaw] = useState([]);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const [tRes, mRes, rRes, aRes] = await Promise.all([
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
]);
if (cancelled) return;
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
} catch { /* silent — charts will show no-data state */ }
finally { if (!cancelled) setLoading(false); }
};
load();
return () => { cancelled = true; };
}, []);
// Format trend rows — add short date label
const formattedTrends = useMemo(
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
[trends]
);
// Pivot archer raw rows → one object per date
const archerByDate = useMemo(() => {
if (!archerRaw.length) return [];
const map = {};
archerRaw.forEach(r => {
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
map[r.date][r.status] = r.count;
});
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
}, [archerRaw]);
return (
<div style={{ marginBottom: '1.5rem' }}>
{/* ── Section header / collapse toggle ──────────────────────── */}
<button
onClick={() => setCollapsed(c => !c)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0 0 0.625rem 0',
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
marginBottom: collapsed ? 0 : '0.875rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
<span style={{
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
}}>
Historical Trends
</span>
{loading && (
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
)}
</div>
{collapsed
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
}
</button>
{!collapsed && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
gap: '1rem',
}}>
{/* 1. Active findings over time */}
<ChartCard
title="Active Findings Over Time"
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
>
<ActiveTrendChart data={formattedTrends} />
</ChartCard>
{/* 2. New / Recurring / Resolved delta per cycle */}
<ChartCard
title="Change per Report Cycle"
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
>
<DeltaChart data={formattedTrends} />
</ChartCard>
{/* 3. Team health multi-line */}
<ChartCard
title="Team Compliance Health"
subtitle="Active findings per team per cycle — lower is better"
>
<TeamTrendChart data={formattedTrends} />
</ChartCard>
{/* 4. MTTR per team */}
<ChartCard
title="Mean Time to Resolution"
subtitle="Average calendar days between first-seen and resolved, by team"
>
<MttrChart data={mttr} />
</ChartCard>
{/* 5. Most persistent / recurring findings */}
<ChartCard
title="Most Persistent Findings"
subtitle="Active items with the highest recurrence count (top 10)"
>
<RecurringChart data={recurring} />
</ChartCard>
{/* 6. Archer ticket pipeline */}
<ChartCard
title="Archer Exception Pipeline"
subtitle="Exception ticket status distribution by creation date"
>
<ArcherPipelineChart data={archerByDate} />
</ChartCard>
</div>
)}
</div>
);
}