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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user