Replace CCP cross-metric aggregates with per-metric summary views

Add per-metric stats and trend endpoints to vclMultiVertical.js. Refactor
CCPMetricsPage to use a unified MetricSelector that drives StatsBar, TrendChart,
DonutChart, and ForecastBurndownChart for the selected metric only. Remove the
separate Per-Metric Forecast Burndown section (now integrated). Fix trend query
double-counting when multiple uploads exist per vertical per month.

Closes #25
This commit is contained in:
Jordan Ramos
2026-06-08 07:59:56 -06:00
parent c62409a8f6
commit 1f3833989a
2 changed files with 439 additions and 62 deletions

View File

@@ -248,7 +248,7 @@ function TrendChart({ months }) {
<Bar yAxisId="count" dataKey="compliant_count" fill="#10B981" fillOpacity={0.6} name="Compliant Devices" />
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
<ReferenceLine yAxisId="pct" y={months[0]?.target_pct || 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
<ReferenceLine yAxisId="pct" y={months[0]?.target != null ? Math.round(months[0].target * 100) : 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -258,6 +258,7 @@ function TrendChart({ months }) {
// ---------------------------------------------------------------------------
// Aggregated Burndown Chart
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
function AggregatedBurndownChart({ data, loading, error }) {
if (loading) {
return (
@@ -1540,22 +1541,30 @@ function ForecastBurndownChart({ metricId }) {
// ---------------------------------------------------------------------------
export default function CCPMetricsPage() {
const { isAdmin, canWrite } = useAuth();
const [stats, setStats] = useState(null);
const [trend, setTrend] = useState(null);
// Cross-metric aggregate stats — retained only for metric_breakdown (MetricBreakdownPanel)
const [metricBreakdownData, setMetricBreakdownData] = useState(null);
const [metricsData, setMetricsData] = useState(null);
const [burndownData, setBurndownData] = useState(null);
const [burndownLoading, setBurndownLoading] = useState(true);
const [burndownError, setBurndownError] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showUpload, setShowUpload] = useState(false);
const [showManage, setShowManage] = useState(false);
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
const [forecastMetric, setForecastMetric] = useState(null);
// Per-metric CCP Summary state (Task 4.1)
const [selectedCCPMetric, setSelectedCCPMetric] = useState(null);
const [metricStats, setMetricStats] = useState(null);
const [metricTrend, setMetricTrend] = useState(null);
const [metricStatsLoading, setMetricStatsLoading] = useState(false);
const [metricStatsError, setMetricStatsError] = useState(null);
const [metricTrendLoading, setMetricTrendLoading] = useState(false);
const [metricTrendError, setMetricTrendError] = useState(null);
// Request counter for stale response discarding (Task 4.1 / Requirement 8.5)
const metricRequestCounter = useRef(0);
// Drill-down state (metric-first hierarchy: metric → vertical → team)
const [selectedMetric, setSelectedMetric] = useState(null);
const [selectedMetricData, setSelectedMetricData] = useState(null); // eslint-disable-line no-unused-vars
const [_selectedMetricData, setSelectedMetricData] = useState(null);
const [selectedVertical, setSelectedVertical] = useState(null);
const [selectedVerticalData, setSelectedVerticalData] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
@@ -1563,31 +1572,75 @@ export default function CCPMetricsPage() {
const fetchData = useCallback(() => {
setLoading(true);
setError(null);
setBurndownLoading(true);
setBurndownError(null);
Promise.all([
// Retain /vcl-multi/stats only for metric_breakdown field used by MetricBreakdownPanel
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load metrics'); return r.json(); }),
]).then(([statsData, trendData, metricsResult]) => {
setStats(statsData);
setTrend(trendData);
]).then(([statsData, metricsResult]) => {
setMetricBreakdownData(statsData?.metric_breakdown || null);
setMetricsData(metricsResult);
setLoading(false);
}).catch(err => {
setError(err.message);
setLoading(false);
});
// Fetch burndown independently so a failure doesn't block the rest of the page
fetch(`${API_BASE}/compliance/vcl-multi/burndown`, { credentials: 'include' })
.then(r => { if (!r.ok) throw new Error('Failed to load burndown'); return r.json(); })
.then(data => { setBurndownData(data); setBurndownLoading(false); })
.catch(err => { setBurndownError(err.message); setBurndownLoading(false); });
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
// Handle CCP metric selection (Task 4.2)
const handleCCPMetricSelect = useCallback((metricId) => {
setSelectedCCPMetric(metricId);
}, []);
// Per-metric data fetching effect — will be wired in task 5.1
// For now, just reset state when metric changes
useEffect(() => {
if (!selectedCCPMetric) {
setMetricStats(null);
setMetricTrend(null);
setMetricStatsLoading(false);
setMetricStatsError(null);
setMetricTrendLoading(false);
setMetricTrendError(null);
return;
}
const currentRequest = ++metricRequestCounter.current;
setMetricStatsLoading(true);
setMetricStatsError(null);
setMetricTrendLoading(true);
setMetricTrendError(null);
// Fetch per-metric stats
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(selectedCCPMetric)}/stats`, { credentials: 'include' })
.then(r => { if (!r.ok) throw new Error('Failed to load metric stats'); return r.json(); })
.then(data => {
if (currentRequest !== metricRequestCounter.current) return;
setMetricStats(data);
setMetricStatsLoading(false);
})
.catch(err => {
if (currentRequest !== metricRequestCounter.current) return;
setMetricStatsError(err.message);
setMetricStatsLoading(false);
});
// Fetch per-metric trend
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(selectedCCPMetric)}/trend`, { credentials: 'include' })
.then(r => { if (!r.ok) throw new Error('Failed to load metric trend'); return r.json(); })
.then(data => {
if (currentRequest !== metricRequestCounter.current) return;
setMetricTrend(data);
setMetricTrendLoading(false);
})
.catch(err => {
if (currentRequest !== metricRequestCounter.current) return;
setMetricTrendError(err.message);
setMetricTrendLoading(false);
});
}, [selectedCCPMetric]);
const handleUploadComplete = () => {
setShowUpload(false);
fetchData();
@@ -1710,60 +1763,93 @@ export default function CCPMetricsPage() {
</div>
)}
{!loading && !error && stats && (
{!loading && !error && metricsData && (
<>
{/* Stats bar */}
<StatsBar
stats={stats.stats}
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
ncExpanded={showMetricBreakdown}
/>
{/* Metric Selector — drives CCP Summary (Task 4.2) */}
<div style={{ marginBottom: '1.5rem' }}>
<MetricSelector onMetricSelect={handleCCPMetricSelect} selectedMetric={selectedCCPMetric} />
</div>
{/* Metric breakdown (revealed when Non-Compliant is clicked) */}
{showMetricBreakdown && (
<MetricBreakdownPanel metrics={stats.metric_breakdown} />
{/* CCP Summary section — per-metric stats, trend, donut, burndown */}
{selectedCCPMetric && (
<>
{/* Stats bar — driven by per-metric stats */}
{metricStatsLoading ? (
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem', marginBottom: '1.5rem' }}>
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading metric stats...</div>
</div>
) : metricStatsError ? (
<div style={{ ...CARD_STYLE, borderColor: 'rgba(239, 68, 68, 0.3)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', marginBottom: '1.5rem' }}>
<AlertCircle style={{ width: '18px', height: '18px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{metricStatsError}</span>
</div>
) : metricStats ? (
<StatsBar
stats={{
total_devices: metricStats.total_devices || 0,
compliant: metricStats.compliant || 0,
non_compliant: metricStats.non_compliant || 0,
compliance_pct: metricStats.compliance_pct || 0,
target_pct: metricStats.target ? Math.round(metricStats.target * 100) : 0,
}}
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
ncExpanded={showMetricBreakdown}
/>
) : null}
{/* Metric breakdown (revealed when Non-Compliant is clicked) — always cross-metric */}
{showMetricBreakdown && (
<MetricBreakdownPanel metrics={metricBreakdownData} />
)}
{/* Charts row — trend and donut */}
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{/* TrendChart — driven by per-metric trend */}
{metricTrendLoading ? (
<div style={{ ...CARD_STYLE, flex: 2, textAlign: 'center', padding: '2rem' }}>
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading trend data...</div>
</div>
) : metricTrendError ? (
<div style={{ ...CARD_STYLE, flex: 2, display: 'flex', alignItems: 'center', gap: '0.75rem', borderColor: 'rgba(239, 68, 68, 0.3)', padding: '1.25rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{metricTrendError}</span>
</div>
) : (
<TrendChart months={metricTrend?.months} />
)}
{/* DonutChart — driven by per-metric stats donut */}
{metricStatsLoading ? (
<div style={{ ...CARD_STYLE, flex: 1, textAlign: 'center', padding: '2rem' }}>
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
</div>
) : metricStatsError ? (
<div style={{ ...CARD_STYLE, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: '0.8rem' }}>
No donut data available
</div>
) : (
<DonutChart donut={metricStats?.donut} />
)}
</div>
{/* Forecast Burndown — driven by selectedCCPMetric */}
<div style={{ marginBottom: '1.5rem' }}>
<ForecastBurndownChart metricId={selectedCCPMetric} />
</div>
</>
)}
{/* Charts row */}
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<TrendChart months={trend?.months} />
<DonutChart donut={stats.donut} />
</div>
{/* Aggregated burndown forecast */}
<AggregatedBurndownChart
data={burndownData}
loading={burndownLoading}
error={burndownError}
/>
{/* Per-Metric Forecast Burndown */}
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
<h3 style={{ fontSize: '0.85rem', fontWeight: '700', color: '#E2E8F0', margin: '0 0 1rem 0' }}>
Per-Metric Forecast Burndown
</h3>
<MetricSelector onMetricSelect={setForecastMetric} selectedMetric={forecastMetric} />
<div style={{ marginTop: '1rem' }}>
<ForecastBurndownChart metricId={forecastMetric} />
</div>
</div>
{/* Metrics overview table (metric-first model) */}
<MetricTable
metrics={metricsData?.metrics}
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
/>
{/* Last upload info */}
{stats.last_upload_date && (
<div style={{ marginTop: '1rem', fontSize: '0.65rem', color: '#475569', textAlign: 'right' }}>
Last upload: {stats.last_upload_date}
</div>
)}
</>
)}
{!loading && !error && (!stats || !stats.vertical_breakdown || stats.vertical_breakdown.length === 0) && (
{!loading && !error && !metricsData && (
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '3rem', marginTop: '2rem' }}>
<Building2 style={{ width: '48px', height: '48px', color: '#334155', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '1rem', color: '#94A3B8', marginBottom: '0.5rem' }}>No multi-vertical data yet</div>