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 }) { )}