diff --git a/backend/helpers/vclHelpers.js b/backend/helpers/vclHelpers.js
index c0963cc..12eba9a 100644
--- a/backend/helpers/vclHelpers.js
+++ b/backend/helpers/vclHelpers.js
@@ -476,7 +476,11 @@ function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSn
const buckets = {};
for (const device of currentDevices) {
if (device.resolution_date == null) continue;
- const resMonth = device.resolution_date.slice(0, 7); // YYYY-MM
+ // Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
+ const dateStr = device.resolution_date instanceof Date
+ ? device.resolution_date.toISOString().slice(0, 7)
+ : String(device.resolution_date).slice(0, 7);
+ const resMonth = dateStr; // YYYY-MM
if (resMonth < currentMonthStr) {
// Past-due: treat as remediated in current month
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
@@ -485,11 +489,16 @@ function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSn
}
}
- // Generate forecast months starting from current month, up to 12 months max
+ // Generate forecast months starting from NEXT month, up to 12 months max
const forecast = [];
let remainingNonCompliant = nonCompliant;
- for (let i = 0; i < 12; i++) {
+ // Account for devices remediated in the current month (past-due dates bucketed here)
+ if (buckets[currentMonthStr]) {
+ remainingNonCompliant -= buckets[currentMonthStr];
+ }
+
+ for (let i = 1; i <= 12; i++) {
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
const forecastMonth = (currentMonth + i) % 12;
const monthStr = formatMonth(forecastYear, forecastMonth);
diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js
index 7793f7d..1b9f035 100644
--- a/backend/routes/vclMultiVertical.js
+++ b/backend/routes/vclMultiVertical.js
@@ -1568,8 +1568,10 @@ function createVCLMultiVerticalRouter(upload) {
});
}
- // 2. Determine the vertical from active devices (use the first one found)
- const vertical = activeDevices[0].vertical;
+ // 2. Determine the vertical(s) from active devices
+ // Group by vertical to handle metrics that span multiple verticals
+ const verticalSet = new Set(activeDevices.map(d => d.vertical));
+ const vertical = activeDevices[0].vertical; // primary vertical for snapshot lookup
// 3. Compute date range for 3 months of historical snapshots
const now = new Date();
@@ -1578,22 +1580,26 @@ function createVCLMultiVerticalRouter(upload) {
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)
+ // 4. Query historical snapshots for ALL verticals this metric spans
+ const verticals = [...verticalSet];
const { rows: snapshots } = await pool.query(
- `SELECT snapshot_month AS month, total_devices AS total_assets,
- non_compliant, compliance_pct::numeric AS compliance_pct
+ `SELECT snapshot_month AS month,
+ SUM(total_devices)::int AS total_assets,
+ SUM(non_compliant)::int AS non_compliant,
+ ROUND((SUM(compliant)::numeric / NULLIF(SUM(total_devices), 0)) * 100, 1) AS compliance_pct
FROM compliance_snapshots
- WHERE vertical = $1 AND snapshot_month >= $2 AND snapshot_month < $3
+ WHERE vertical = ANY($1) AND snapshot_month >= $2 AND snapshot_month < $3
+ GROUP BY snapshot_month
ORDER BY snapshot_month ASC`,
- [vertical, startMonth, currentMonth]
+ [verticals, startMonth, currentMonth]
);
- // 5. Get total non-compliant devices for the vertical (for ratio computation)
+ // 5. Get total non-compliant devices across all verticals this metric spans
const { rows: verticalNcRows } = await pool.query(
`SELECT COUNT(DISTINCT hostname) AS total_nc
FROM compliance_items
- WHERE vertical = $1 AND status = 'active'`,
- [vertical]
+ WHERE vertical = ANY($1) AND status = 'active'`,
+ [verticals]
);
const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0;
@@ -1601,11 +1607,11 @@ function createVCLMultiVerticalRouter(upload) {
const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size;
// 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2)
+ // Use the metric's own total (from summary) rather than the vertical's total_devices
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)
@@ -1614,26 +1620,44 @@ function createVCLMultiVerticalRouter(upload) {
return {
month: snap.month,
- total_assets: parseInt(snap.total_assets, 10) || 0,
+ total_assets: 0, // Will be filled in after we get the metric's totalAssets
non_compliant: metricNc,
- compliance_pct: parseFloat(snap.compliance_pct) || 0,
+ compliance_pct: 0, // Will be recomputed
};
});
// 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
+ // Get totalAssets from the per-metric summary (vcl_multi_vertical_summary)
+ // This gives us the actual total devices for THIS metric, not the entire vertical
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]
+ const { rows: metricSummaryRows } = await pool.query(
+ `SELECT SUM(total)::int AS total
+ FROM vcl_multi_vertical_summary
+ WHERE metric_id = $1 AND team LIKE 'ALL:%'
+ AND upload_id IN (
+ SELECT id FROM compliance_uploads
+ WHERE vertical IS NOT NULL
+ ORDER BY id DESC
+ LIMIT 20
+ )`,
+ [metricId]
);
- if (latestSnapshotRows.length > 0) {
- totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0;
+ if (metricSummaryRows.length > 0 && metricSummaryRows[0].total) {
+ totalAssets = parseInt(metricSummaryRows[0].total, 10) || 0;
+ }
+
+ // Fallback: if no summary data, use non_compliant count as minimum
+ if (totalAssets === 0) {
+ totalAssets = metricNcCount;
+ }
+
+ // Backfill historical snapshots with the correct per-metric totalAssets and compliance_pct
+ for (const snap of historicalSnapshots) {
+ snap.total_assets = totalAssets;
+ snap.compliance_pct = totalAssets > 0
+ ? Math.round(((totalAssets - snap.non_compliant) / totalAssets) * 1000) / 10
+ : 0;
}
// Current month data point from live data
diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js
index 4c51fd7..0fbc16a 100644
--- a/frontend/src/components/pages/CCPMetricsPage.js
+++ b/frontend/src/components/pages/CCPMetricsPage.js
@@ -1367,8 +1367,8 @@ function ForecastBurndownChart({ metricId }) {
// 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 })),
+ ...historical.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: false })),
+ ...forecast.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: true })),
];
// Determine the divider position (between last historical and first forecast)
@@ -1399,11 +1399,12 @@ function ForecastBurndownChart({ metricId }) {
// Custom label for bars (device counts inside bars)
const renderTotalLabel = (props) => {
- const { x, y, width, height, value } = props;
- if (!value || height < 14) return null;
+ const { x, y, width, height, payload } = props;
+ const total = payload ? payload.total_assets : null;
+ if (!total || height < 14) return null;
return (
- {value}
+ {total}
);
};
@@ -1487,19 +1488,23 @@ function ForecastBurndownChart({ metricId }) {
)}