Add per-metric forecast burndown chart to CCP Metrics page
New feature: combined historical + forecast burndown chart with metric selector on the CCP Metrics page. Shows stacked bars (total assets vs non-compliant) with a compliance percentage trend line. A bold divider separates actual historical data from projected future remediation. Forecast assumes constant asset count and on-schedule remediation plans. Backend: - computeMetricForecastBurndown helper in vclHelpers.js (pure function) - GET /api/compliance/vcl-multi/metrics-list endpoint - GET /api/compliance/vcl-multi/metric/:metricId/forecast-burndown endpoint Frontend: - MetricSelector dropdown with device counts per metric - ForecastBurndownChart using recharts ComposedChart (Bar + Line + ReferenceLine) - Forecast bars render at 50% opacity to distinguish from actuals - Race condition handling for rapid metric switching - Queue panel width increased from 420px to 600px Closes #18
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path (e.g. '/api') instead of absolute URL with localhost. The fallback 'http://localhost:3001/api' should be a relative path since Express serves both API and frontend on the same port in production.
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const PURPLE = '#A78BFA';
|
||||
@@ -1187,6 +1186,338 @@ function DataManagementPanel({ onClose, onDataChanged }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric Selector (Forecast Burndown)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricSelector({ onMetricSelect, selectedMetric }) {
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metrics-list`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to load metrics (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setMetrics(data || []);
|
||||
setLoading(false);
|
||||
// Auto-select first metric on initial load
|
||||
if (data && data.length > 0 && !selectedMetric) {
|
||||
onMetricSelect(data[0].metric_id);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '14px', height: '14px', color: PURPLE }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Loading metrics...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '0.5rem 0', fontSize: '0.75rem', color: '#64748B' }}>
|
||||
No metrics with active non-compliant devices
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<label style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>
|
||||
Metric
|
||||
</label>
|
||||
<select
|
||||
value={selectedMetric || ''}
|
||||
onChange={e => onMetricSelect(e.target.value)}
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: `1px solid rgba(167, 139, 250, 0.4)`,
|
||||
borderRadius: '0.4rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
color: '#E2E8F0',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{metrics.map(m => (
|
||||
<option key={m.metric_id} value={m.metric_id}>
|
||||
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forecast Burndown Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
function ForecastBurndownChart({ metricId }) {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const requestCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!metricId) {
|
||||
setChartData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRequest = ++requestCounterRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(metricId)}/forecast-burndown`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to load forecast data (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Discard stale responses
|
||||
if (currentRequest !== requestCounterRef.current) return;
|
||||
setChartData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (currentRequest !== requestCounterRef.current) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [metricId]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, 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 forecast data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
...CARD_STYLE,
|
||||
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '1.25rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No metric selected yet
|
||||
if (!metricId || !chartData) return null;
|
||||
|
||||
// Empty data state
|
||||
const historical = chartData.historical || [];
|
||||
const forecast = chartData.forecast || [];
|
||||
if (historical.length === 0 && forecast.length === 0) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748B' }}>No data available for this metric</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine historical and forecast into a single array with isForecast flag
|
||||
const combinedData = [
|
||||
...historical.map(d => ({ ...d, isForecast: false })),
|
||||
...forecast.map(d => ({ ...d, isForecast: true })),
|
||||
];
|
||||
|
||||
// Determine the divider position (between last historical and first forecast)
|
||||
const hasForecast = forecast.length > 0;
|
||||
const dividerMonth = hasForecast && historical.length > 0
|
||||
? historical[historical.length - 1].month
|
||||
: null;
|
||||
|
||||
// Compute max total_assets for left Y-axis domain
|
||||
const maxTotal = Math.max(...combinedData.map(d => d.total_assets || 0), 1);
|
||||
|
||||
// Custom bar shape to apply opacity for forecast data points
|
||||
const renderTotalAssetsBar = (props) => {
|
||||
const { x, y, width, height, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<rect x={x} y={y} width={width} height={height} fill="#3B82F6" fillOpacity={opacity} rx={2} />
|
||||
);
|
||||
};
|
||||
|
||||
const renderNonCompliantBar = (props) => {
|
||||
const { x, y, width, height, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<rect x={x} y={y} width={width} height={height} fill="#F97316" fillOpacity={opacity} rx={2} />
|
||||
);
|
||||
};
|
||||
|
||||
// Custom label for bars (device counts inside bars)
|
||||
const renderTotalLabel = (props) => {
|
||||
const { x, y, width, height, value } = props;
|
||||
if (!value || height < 14) return null;
|
||||
return (
|
||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||
{value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNonCompliantLabel = (props) => {
|
||||
const { x, y, width, height, value } = props;
|
||||
if (!value || height < 14) return null;
|
||||
return (
|
||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||
{value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom dot for the line to apply opacity
|
||||
const renderDot = (props) => {
|
||||
const { cx, cy, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<circle cx={cx} cy={cy} r={3} fill="#10B981" fillOpacity={opacity} stroke="#10B981" strokeOpacity={opacity} strokeWidth={1} />
|
||||
);
|
||||
};
|
||||
|
||||
// Custom label for compliance percentage on the line
|
||||
const renderLineLabel = (props) => {
|
||||
const { x, y, value, index } = props;
|
||||
if (value === undefined || value === null) return null;
|
||||
const point = combinedData[index];
|
||||
const opacity = point && point.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<text x={x} y={y - 10} textAnchor="middle" fill="#10B981" fillOpacity={opacity} fontSize={9} fontWeight="600">
|
||||
{value}%
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Forecast Burndown — {metricId}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={combinedData} margin={{ top: 20, right: 40, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={[0, maxTotal]}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
label={{ value: 'Devices', angle: -90, position: 'insideLeft', style: { fontSize: 10, fill: '#64748B' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
label={{ value: '%', angle: 90, position: 'insideRight', style: { fontSize: 10, fill: '#64748B' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }}
|
||||
labelStyle={{ color: '#94A3B8' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '0.7rem', color: '#94A3B8' }}
|
||||
/>
|
||||
{dividerMonth && (
|
||||
<ReferenceLine
|
||||
x={dividerMonth}
|
||||
yAxisId="left"
|
||||
stroke={PURPLE}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: 'Forecast →', position: 'top', style: { fontSize: 9, fill: PURPLE } }}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="total_assets"
|
||||
name="Total Assets"
|
||||
shape={renderTotalAssetsBar}
|
||||
label={renderTotalLabel}
|
||||
barSize={28}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="non_compliant"
|
||||
name="Non-Compliant"
|
||||
shape={renderNonCompliantBar}
|
||||
label={renderNonCompliantLabel}
|
||||
barSize={28}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="compliance_pct"
|
||||
name="Compliance %"
|
||||
stroke="#10B981"
|
||||
strokeWidth={2}
|
||||
dot={renderDot}
|
||||
label={renderLineLabel}
|
||||
activeDot={{ r: 5, fill: '#10B981' }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1203,6 +1534,7 @@ export default function CCPMetricsPage() {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
||||
const [forecastMetric, setForecastMetric] = useState(null);
|
||||
|
||||
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
@@ -1388,6 +1720,17 @@ export default function CCPMetricsPage() {
|
||||
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}
|
||||
|
||||
@@ -2197,7 +2197,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, right: 0,
|
||||
height: '100vh', width: '420px',
|
||||
height: '100vh', width: '600px',
|
||||
zIndex: 9999,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||||
|
||||
Reference in New Issue
Block a user