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:
Jordan Ramos
2026-05-20 16:15:21 -06:00
parent df31cc3c79
commit f9b96e9040
4 changed files with 665 additions and 4 deletions

View File

@@ -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,
};

View File

@@ -7,7 +7,7 @@ const fs = require('fs');
const { spawn } = require('child_process');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant, deduplicateByHostname, computeAggregatedBurndown } = require('../helpers/vclHelpers');
const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant, deduplicateByHostname, computeAggregatedBurndown, computeMetricForecastBurndown } = require('../helpers/vclHelpers');
const logAudit = require('../helpers/auditLog');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
@@ -1483,6 +1483,194 @@ function createVCLMultiVerticalRouter(upload) {
}
});
// -----------------------------------------------------------------------
// GET /metrics-list — Distinct metrics with active non-compliant device counts
// -----------------------------------------------------------------------
/**
* GET /metrics-list
* Returns the list of distinct metrics that have at least one active non-compliant
* device with a non-null vertical. Used by the MetricSelector component.
*
* @method GET
* @route /metrics-list
*
* @response 200
* Array<{ metric_id: string, device_count: number }>
* @response 500 { error: string }
*/
router.get('/metrics-list', async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT metric_id, COUNT(DISTINCT hostname) AS device_count
FROM compliance_items
WHERE status = 'active' AND vertical IS NOT NULL
GROUP BY metric_id
ORDER BY metric_id ASC
`);
res.json(rows.map(r => ({ metric_id: r.metric_id, device_count: parseInt(r.device_count, 10) })));
} catch (err) {
console.error('[VCL Multi] GET /metrics-list error:', err.message);
res.status(500).json({ error: 'Failed to fetch metrics list' });
}
});
// -----------------------------------------------------------------------
// GET /metric/:metricId/forecast-burndown — Per-metric forecast burndown
// -----------------------------------------------------------------------
/**
* GET /metric/:metricId/forecast-burndown
* Returns combined historical + forecast burndown data for a specific metric.
* Historical data is derived from compliance_snapshots using the ratio method.
* Forecast data is computed by the computeMetricForecastBurndown helper.
*
* @method GET
* @route /metric/:metricId/forecast-burndown
* @param {string} metricId — metric identifier (e.g., "2.3.5")
*
* @response 200
* {
* metric_id: string,
* 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 }
* }
* @response 500 { error: string }
*/
router.get('/metric/:metricId/forecast-burndown', async (req, res) => {
const metricId = req.params.metricId;
try {
// 1. Query active devices for this metric
const { rows: activeDevices } = await pool.query(
`SELECT hostname, resolution_date, vertical
FROM compliance_items
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL`,
[metricId]
);
// If no active devices, return empty response
if (activeDevices.length === 0) {
return res.json({
metric_id: metricId,
historical: [],
forecast: [],
current_snapshot: {
total_assets: 0,
non_compliant: 0,
compliant: 0,
compliance_pct: 0,
blockers: 0,
with_dates: 0,
},
});
}
// 2. Determine the vertical from active devices (use the first one found)
const vertical = activeDevices[0].vertical;
// 3. Compute date range for 3 months of historical snapshots
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// 3 months prior to current month
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const startMonth = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`;
// 4. Query historical snapshots for the vertical (3 months prior, excluding current month)
const { rows: snapshots } = await pool.query(
`SELECT snapshot_month AS month, total_devices AS total_assets,
non_compliant, compliance_pct::numeric AS compliance_pct
FROM compliance_snapshots
WHERE vertical = $1 AND snapshot_month >= $2 AND snapshot_month < $3
ORDER BY snapshot_month ASC`,
[vertical, startMonth, currentMonth]
);
// 5. Get total non-compliant devices for the vertical (for ratio computation)
const { rows: verticalNcRows } = await pool.query(
`SELECT COUNT(DISTINCT hostname) AS total_nc
FROM compliance_items
WHERE vertical = $1 AND status = 'active'`,
[vertical]
);
const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0;
// Count metric's non-compliant devices (distinct hostnames)
const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size;
// 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2)
const historicalSnapshots = snapshots.map(snap => {
const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
let metricNc;
if (verticalTotalNc === 0) {
// Requirement 7.3: if vertical's total non_compliant is 0, metric's is 0
metricNc = 0;
} else {
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
metricNc = Math.round(snapshotNc * (metricNcCount / verticalTotalNc));
}
return {
month: snap.month,
total_assets: parseInt(snap.total_assets, 10) || 0,
non_compliant: metricNc,
compliance_pct: parseFloat(snap.compliance_pct) || 0,
};
});
// 7. Include current month as the most recent historical data point (from live data)
// Get totalAssets from the most recent snapshot's total_devices, or from live vertical count
let totalAssets = 0;
const { rows: latestSnapshotRows } = await pool.query(
`SELECT total_devices
FROM compliance_snapshots
WHERE vertical = $1
ORDER BY snapshot_month DESC
LIMIT 1`,
[vertical]
);
if (latestSnapshotRows.length > 0) {
totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0;
}
// Current month data point from live data
const currentMonthNc = metricNcCount;
const currentMonthCompliancePct = totalAssets > 0
? Math.round(((totalAssets - currentMonthNc) / totalAssets) * 1000) / 10
: 0;
historicalSnapshots.push({
month: currentMonth,
total_assets: totalAssets,
non_compliant: currentMonthNc,
compliance_pct: currentMonthCompliancePct,
});
// 8. Prepare currentDevices for the helper (only need hostname and resolution_date)
const currentDevices = activeDevices.map(d => ({
hostname: d.hostname,
resolution_date: d.resolution_date || null,
}));
// 9. Pass data to computeMetricForecastBurndown helper
const result = computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots);
// 10. Return response
res.json({
metric_id: metricId,
historical: result.historical,
forecast: result.forecast,
current_snapshot: result.current_snapshot,
});
} catch (err) {
console.error('[VCL Multi] GET /metric/:metricId/forecast-burndown error:', err.message);
res.status(500).json({ error: 'Failed to compute forecast burndown' });
}
});
return router;
}