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>
This commit is contained in:
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
@@ -0,0 +1,424 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-re
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
@@ -323,6 +324,9 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
<ComplianceChartsPanel />
|
||||
|
||||
{/* ── Device table ─────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
|
||||
Reference in New Issue
Block a user