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:
@@ -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 = {
|
module.exports = {
|
||||||
truncateText,
|
truncateText,
|
||||||
validateRemediationPlan,
|
validateRemediationPlan,
|
||||||
@@ -404,4 +533,5 @@ module.exports = {
|
|||||||
computeVerticalBurndown,
|
computeVerticalBurndown,
|
||||||
deduplicateByHostname,
|
deduplicateByHostname,
|
||||||
computeAggregatedBurndown,
|
computeAggregatedBurndown,
|
||||||
|
computeMetricForecastBurndown,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
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 logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
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 API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const TEAL = '#14B8A6';
|
const TEAL = '#14B8A6';
|
||||||
const PURPLE = '#A78BFA';
|
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 (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||||
|
<Loader style={{ animation: 'spin 1s linear infinite', width: '14px', height: '14px', color: PURPLE }} />
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Loading metrics...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (metrics.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0.5rem 0', fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
|
No metrics with active non-compliant devices
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>
|
||||||
|
Metric
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMetric || ''}
|
||||||
|
onChange={e => onMetricSelect(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)',
|
||||||
|
border: `1px solid rgba(167, 139, 250, 0.4)`,
|
||||||
|
borderRadius: '0.4rem',
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metrics.map(m => (
|
||||||
|
<option key={m.metric_id} value={m.metric_id}>
|
||||||
|
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Forecast Burndown Chart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ForecastBurndownChart({ metricId }) {
|
||||||
|
const [chartData, setChartData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const requestCounterRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!metricId) {
|
||||||
|
setChartData(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequest = ++requestCounterRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(metricId)}/forecast-burndown`, { credentials: 'include' })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`Failed to load forecast data (${r.status})`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Discard stale responses
|
||||||
|
if (currentRequest !== requestCounterRef.current) return;
|
||||||
|
setChartData(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (currentRequest !== requestCounterRef.current) return;
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [metricId]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading forecast data...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
...CARD_STYLE,
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||||
|
padding: '1.25rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No metric selected yet
|
||||||
|
if (!metricId || !chartData) return null;
|
||||||
|
|
||||||
|
// Empty data state
|
||||||
|
const historical = chartData.historical || [];
|
||||||
|
const forecast = chartData.forecast || [];
|
||||||
|
if (historical.length === 0 && forecast.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748B' }}>No data available for this metric</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 })),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Determine the divider position (between last historical and first forecast)
|
||||||
|
const hasForecast = forecast.length > 0;
|
||||||
|
const dividerMonth = hasForecast && historical.length > 0
|
||||||
|
? historical[historical.length - 1].month
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Compute max total_assets for left Y-axis domain
|
||||||
|
const maxTotal = Math.max(...combinedData.map(d => d.total_assets || 0), 1);
|
||||||
|
|
||||||
|
// Custom bar shape to apply opacity for forecast data points
|
||||||
|
const renderTotalAssetsBar = (props) => {
|
||||||
|
const { x, y, width, height, payload } = props;
|
||||||
|
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||||
|
return (
|
||||||
|
<rect x={x} y={y} width={width} height={height} fill="#3B82F6" fillOpacity={opacity} rx={2} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNonCompliantBar = (props) => {
|
||||||
|
const { x, y, width, height, payload } = props;
|
||||||
|
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||||
|
return (
|
||||||
|
<rect x={x} y={y} width={width} height={height} fill="#F97316" fillOpacity={opacity} rx={2} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom label for bars (device counts inside bars)
|
||||||
|
const renderTotalLabel = (props) => {
|
||||||
|
const { x, y, width, height, value } = props;
|
||||||
|
if (!value || height < 14) return null;
|
||||||
|
return (
|
||||||
|
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNonCompliantLabel = (props) => {
|
||||||
|
const { x, y, width, height, value } = props;
|
||||||
|
if (!value || height < 14) return null;
|
||||||
|
return (
|
||||||
|
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom dot for the line to apply opacity
|
||||||
|
const renderDot = (props) => {
|
||||||
|
const { cx, cy, payload } = props;
|
||||||
|
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||||
|
return (
|
||||||
|
<circle cx={cx} cy={cy} r={3} fill="#10B981" fillOpacity={opacity} stroke="#10B981" strokeOpacity={opacity} strokeWidth={1} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom label for compliance percentage on the line
|
||||||
|
const renderLineLabel = (props) => {
|
||||||
|
const { x, y, value, index } = props;
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const point = combinedData[index];
|
||||||
|
const opacity = point && point.isForecast ? 0.5 : 1.0;
|
||||||
|
return (
|
||||||
|
<text x={x} y={y - 10} textAnchor="middle" fill="#10B981" fillOpacity={opacity} fontSize={9} fontWeight="600">
|
||||||
|
{value}%
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...CARD_STYLE }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||||
|
Forecast Burndown — {metricId}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<ComposedChart data={combinedData} margin={{ top: 20, right: 40, left: 10, bottom: 5 }}>
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
domain={[0, maxTotal]}
|
||||||
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
|
label={{ value: 'Devices', angle: -90, position: 'insideLeft', style: { fontSize: 10, fill: '#64748B' } }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
|
label={{ value: '%', angle: 90, position: 'insideRight', style: { fontSize: 10, fill: '#64748B' } }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }}
|
||||||
|
labelStyle={{ color: '#94A3B8' }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '0.7rem', color: '#94A3B8' }}
|
||||||
|
/>
|
||||||
|
{dividerMonth && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={dividerMonth}
|
||||||
|
yAxisId="left"
|
||||||
|
stroke={PURPLE}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: 'Forecast →', position: 'top', style: { fontSize: 9, fill: PURPLE } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Bar
|
||||||
|
yAxisId="left"
|
||||||
|
dataKey="total_assets"
|
||||||
|
name="Total Assets"
|
||||||
|
shape={renderTotalAssetsBar}
|
||||||
|
label={renderTotalLabel}
|
||||||
|
barSize={28}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
yAxisId="left"
|
||||||
|
dataKey="non_compliant"
|
||||||
|
name="Non-Compliant"
|
||||||
|
shape={renderNonCompliantBar}
|
||||||
|
label={renderNonCompliantLabel}
|
||||||
|
barSize={28}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="compliance_pct"
|
||||||
|
name="Compliance %"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={renderDot}
|
||||||
|
label={renderLineLabel}
|
||||||
|
activeDot={{ r: 5, fill: '#10B981' }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Page Component
|
// Main Page Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1203,6 +1534,7 @@ export default function CCPMetricsPage() {
|
|||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
||||||
|
const [forecastMetric, setForecastMetric] = useState(null);
|
||||||
|
|
||||||
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
||||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||||
@@ -1388,6 +1720,17 @@ export default function CCPMetricsPage() {
|
|||||||
error={burndownError}
|
error={burndownError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Per-Metric Forecast Burndown */}
|
||||||
|
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.85rem', fontWeight: '700', color: '#E2E8F0', margin: '0 0 1rem 0' }}>
|
||||||
|
Per-Metric Forecast Burndown
|
||||||
|
</h3>
|
||||||
|
<MetricSelector onMetricSelect={setForecastMetric} selectedMetric={forecastMetric} />
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<ForecastBurndownChart metricId={forecastMetric} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Metrics overview table (metric-first model) */}
|
{/* Metrics overview table (metric-first model) */}
|
||||||
<MetricTable
|
<MetricTable
|
||||||
metrics={metricsData?.metrics}
|
metrics={metricsData?.metrics}
|
||||||
|
|||||||
@@ -2197,7 +2197,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', top: 0, right: 0,
|
position: 'fixed', top: 0, right: 0,
|
||||||
height: '100vh', width: '420px',
|
height: '100vh', width: '600px',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||||||
|
|||||||
Reference in New Issue
Block a user