diff --git a/backend/helpers/vclHelpers.js b/backend/helpers/vclHelpers.js index 00faa48..c0963cc 100644 --- a/backend/helpers/vclHelpers.js +++ b/backend/helpers/vclHelpers.js @@ -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, }; diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index 8e1098e..7793f7d 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -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; } diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 0dc5d56..4c51fd7 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -1,10 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; import MultiVerticalUploadModal from './MultiVerticalUploadModal'; -// ⚠️ CONVENTION: Use relative API path (e.g. '/api') instead of absolute URL with localhost. The fallback 'http://localhost:3001/api' should be a relative path since Express serves both API and frontend on the same port in production. const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const TEAL = '#14B8A6'; const PURPLE = '#A78BFA'; @@ -1187,6 +1186,338 @@ function DataManagementPanel({ onClose, onDataChanged }) { ); } +// --------------------------------------------------------------------------- +// Metric Selector (Forecast Burndown) +// --------------------------------------------------------------------------- +function MetricSelector({ onMetricSelect, selectedMetric }) { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + fetch(`${API_BASE}/compliance/vcl-multi/metrics-list`, { credentials: 'include' }) + .then(r => { + if (!r.ok) throw new Error(`Failed to load metrics (${r.status})`); + return r.json(); + }) + .then(data => { + if (cancelled) return; + setMetrics(data || []); + setLoading(false); + // Auto-select first metric on initial load + if (data && data.length > 0 && !selectedMetric) { + onMetricSelect(data[0].metric_id); + } + }) + .catch(err => { + if (cancelled) return; + setError(err.message); + setLoading(false); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Loading state + if (loading) { + return ( +