Fix forecast burndown chart data issues
- Fix Date object handling for resolution_date from PostgreSQL - Fix totalAssets using per-metric summary (vcl_multi_vertical_summary) instead of vertical-level compliance_snapshots total_devices - Fix duplicate current month in chart (forecast starts from next month) - Fix multi-vertical metrics summing across all relevant verticals - Fix bar stacking: orange (non-compliant) on bottom, blue (compliant) on top, both sharing same baseline (stacked to total) - Add fill props to Bar components for correct legend colors - Backfill historical snapshots with per-metric totalAssets
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||
{value}
|
||||
{total}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
@@ -1487,19 +1488,23 @@ function ForecastBurndownChart({ metricId }) {
|
||||
)}
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="total_assets"
|
||||
name="Total Assets"
|
||||
shape={renderTotalAssetsBar}
|
||||
label={renderTotalLabel}
|
||||
barSize={28}
|
||||
dataKey="non_compliant"
|
||||
name="Non-Compliant"
|
||||
stackId="devices"
|
||||
fill="#F97316"
|
||||
shape={renderNonCompliantBar}
|
||||
label={renderNonCompliantLabel}
|
||||
barSize={36}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="non_compliant"
|
||||
name="Non-Compliant"
|
||||
shape={renderNonCompliantBar}
|
||||
label={renderNonCompliantLabel}
|
||||
barSize={28}
|
||||
dataKey="compliant"
|
||||
name="Compliant"
|
||||
stackId="devices"
|
||||
fill="#3B82F6"
|
||||
shape={renderTotalAssetsBar}
|
||||
label={renderTotalLabel}
|
||||
barSize={36}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
|
||||
Reference in New Issue
Block a user