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:
@@ -388,6 +388,135 @@ function computeAggregatedBurndown(devices) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-metric forecast burndown from device records and historical snapshots.
|
||||
*
|
||||
* Pure function — no side effects, no database access. Suitable for property-based testing.
|
||||
*
|
||||
* @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
|
||||
* Active non-compliant devices for the metric
|
||||
* @param {number} totalAssets
|
||||
* Total device count in scope for this metric (from snapshot or summary)
|
||||
* @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
|
||||
* Pre-computed historical data points (up to 4 months)
|
||||
* @returns {{
|
||||
* historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
|
||||
* }}
|
||||
*/
|
||||
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) {
|
||||
// Compute compliance_pct helper
|
||||
function calcCompliancePct(total, nc) {
|
||||
if (total === 0) return 0;
|
||||
return Math.round(((total - nc) / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
// Historical — pass through as-is
|
||||
const historical = (historicalSnapshots || []).map(snap => ({
|
||||
month: snap.month,
|
||||
total_assets: snap.total_assets,
|
||||
non_compliant: snap.non_compliant,
|
||||
compliance_pct: snap.compliance_pct,
|
||||
}));
|
||||
|
||||
// Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets
|
||||
if (!currentDevices || currentDevices.length === 0) {
|
||||
return {
|
||||
historical,
|
||||
forecast: [],
|
||||
current_snapshot: {
|
||||
total_assets: totalAssets,
|
||||
non_compliant: 0,
|
||||
compliant: 0,
|
||||
compliance_pct: 0,
|
||||
blockers: 0,
|
||||
with_dates: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nonCompliant = currentDevices.length;
|
||||
|
||||
// Partition devices into blockers (no resolution_date) and with_dates
|
||||
const blockers = currentDevices.filter(d => d.resolution_date == null).length;
|
||||
const withDates = nonCompliant - blockers;
|
||||
|
||||
// Current snapshot
|
||||
const compliant = totalAssets - nonCompliant;
|
||||
const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant);
|
||||
|
||||
const current_snapshot = {
|
||||
total_assets: totalAssets,
|
||||
non_compliant: nonCompliant,
|
||||
compliant: compliant,
|
||||
compliance_pct: currentCompliancePct,
|
||||
blockers: blockers,
|
||||
with_dates: withDates,
|
||||
};
|
||||
|
||||
// If no devices have resolution dates, return empty forecast
|
||||
if (withDates === 0) {
|
||||
return { historical, forecast: [], current_snapshot };
|
||||
}
|
||||
|
||||
// Determine current month (YYYY-MM)
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth(); // 0-indexed
|
||||
|
||||
function formatMonth(year, month) {
|
||||
return `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const currentMonthStr = formatMonth(currentYear, currentMonth);
|
||||
|
||||
// Bucket devices with resolution dates by their resolution month
|
||||
// Past-due dates (month before current month) are treated as remediated in current month
|
||||
const buckets = {};
|
||||
for (const device of currentDevices) {
|
||||
if (device.resolution_date == null) continue;
|
||||
const resMonth = device.resolution_date.slice(0, 7); // YYYY-MM
|
||||
if (resMonth < currentMonthStr) {
|
||||
// Past-due: treat as remediated in current month
|
||||
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
|
||||
} else {
|
||||
buckets[resMonth] = (buckets[resMonth] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate forecast months starting from current month, up to 12 months max
|
||||
const forecast = [];
|
||||
let remainingNonCompliant = nonCompliant;
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
|
||||
const forecastMonth = (currentMonth + i) % 12;
|
||||
const monthStr = formatMonth(forecastYear, forecastMonth);
|
||||
|
||||
// Decrement by devices remediated in this month
|
||||
if (buckets[monthStr]) {
|
||||
remainingNonCompliant -= buckets[monthStr];
|
||||
}
|
||||
|
||||
const pct = calcCompliancePct(totalAssets, remainingNonCompliant);
|
||||
|
||||
forecast.push({
|
||||
month: monthStr,
|
||||
total_assets: totalAssets,
|
||||
non_compliant: remainingNonCompliant,
|
||||
compliance_pct: pct,
|
||||
});
|
||||
|
||||
// Terminate early if all dated devices are remediated (only blockers remain)
|
||||
if (remainingNonCompliant <= blockers) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { historical, forecast, current_snapshot };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -404,4 +533,5 @@ module.exports = {
|
||||
computeVerticalBurndown,
|
||||
deduplicateByHostname,
|
||||
computeAggregatedBurndown,
|
||||
computeMetricForecastBurndown,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user