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 = {};
|
const buckets = {};
|
||||||
for (const device of currentDevices) {
|
for (const device of currentDevices) {
|
||||||
if (device.resolution_date == null) continue;
|
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) {
|
if (resMonth < currentMonthStr) {
|
||||||
// Past-due: treat as remediated in current month
|
// Past-due: treat as remediated in current month
|
||||||
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
|
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 = [];
|
const forecast = [];
|
||||||
let remainingNonCompliant = nonCompliant;
|
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 forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
|
||||||
const forecastMonth = (currentMonth + i) % 12;
|
const forecastMonth = (currentMonth + i) % 12;
|
||||||
const monthStr = formatMonth(forecastYear, forecastMonth);
|
const monthStr = formatMonth(forecastYear, forecastMonth);
|
||||||
|
|||||||
@@ -1568,8 +1568,10 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Determine the vertical from active devices (use the first one found)
|
// 2. Determine the vertical(s) from active devices
|
||||||
const vertical = activeDevices[0].vertical;
|
// 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
|
// 3. Compute date range for 3 months of historical snapshots
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -1578,22 +1580,26 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||||
const startMonth = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`;
|
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(
|
const { rows: snapshots } = await pool.query(
|
||||||
`SELECT snapshot_month AS month, total_devices AS total_assets,
|
`SELECT snapshot_month AS month,
|
||||||
non_compliant, compliance_pct::numeric AS compliance_pct
|
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
|
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`,
|
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(
|
const { rows: verticalNcRows } = await pool.query(
|
||||||
`SELECT COUNT(DISTINCT hostname) AS total_nc
|
`SELECT COUNT(DISTINCT hostname) AS total_nc
|
||||||
FROM compliance_items
|
FROM compliance_items
|
||||||
WHERE vertical = $1 AND status = 'active'`,
|
WHERE vertical = ANY($1) AND status = 'active'`,
|
||||||
[vertical]
|
[verticals]
|
||||||
);
|
);
|
||||||
const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0;
|
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;
|
const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size;
|
||||||
|
|
||||||
// 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2)
|
// 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 historicalSnapshots = snapshots.map(snap => {
|
||||||
const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
|
const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
|
||||||
let metricNc;
|
let metricNc;
|
||||||
if (verticalTotalNc === 0) {
|
if (verticalTotalNc === 0) {
|
||||||
// Requirement 7.3: if vertical's total non_compliant is 0, metric's is 0
|
|
||||||
metricNc = 0;
|
metricNc = 0;
|
||||||
} else {
|
} else {
|
||||||
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
|
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
|
||||||
@@ -1614,26 +1620,44 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
month: snap.month,
|
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,
|
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)
|
// 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;
|
let totalAssets = 0;
|
||||||
const { rows: latestSnapshotRows } = await pool.query(
|
const { rows: metricSummaryRows } = await pool.query(
|
||||||
`SELECT total_devices
|
`SELECT SUM(total)::int AS total
|
||||||
FROM compliance_snapshots
|
FROM vcl_multi_vertical_summary
|
||||||
WHERE vertical = $1
|
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
||||||
ORDER BY snapshot_month DESC
|
AND upload_id IN (
|
||||||
LIMIT 1`,
|
SELECT id FROM compliance_uploads
|
||||||
[vertical]
|
WHERE vertical IS NOT NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 20
|
||||||
|
)`,
|
||||||
|
[metricId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (latestSnapshotRows.length > 0) {
|
if (metricSummaryRows.length > 0 && metricSummaryRows[0].total) {
|
||||||
totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0;
|
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
|
// 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
|
// Combine historical and forecast into a single array with isForecast flag
|
||||||
const combinedData = [
|
const combinedData = [
|
||||||
...historical.map(d => ({ ...d, isForecast: false })),
|
...historical.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: false })),
|
||||||
...forecast.map(d => ({ ...d, isForecast: true })),
|
...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)
|
// 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)
|
// Custom label for bars (device counts inside bars)
|
||||||
const renderTotalLabel = (props) => {
|
const renderTotalLabel = (props) => {
|
||||||
const { x, y, width, height, value } = props;
|
const { x, y, width, height, payload } = props;
|
||||||
if (!value || height < 14) return null;
|
const total = payload ? payload.total_assets : null;
|
||||||
|
if (!total || height < 14) return null;
|
||||||
return (
|
return (
|
||||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||||
{value}
|
{total}
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1487,19 +1488,23 @@ function ForecastBurndownChart({ metricId }) {
|
|||||||
)}
|
)}
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
dataKey="total_assets"
|
dataKey="non_compliant"
|
||||||
name="Total Assets"
|
name="Non-Compliant"
|
||||||
shape={renderTotalAssetsBar}
|
stackId="devices"
|
||||||
label={renderTotalLabel}
|
fill="#F97316"
|
||||||
barSize={28}
|
shape={renderNonCompliantBar}
|
||||||
|
label={renderNonCompliantLabel}
|
||||||
|
barSize={36}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
dataKey="non_compliant"
|
dataKey="compliant"
|
||||||
name="Non-Compliant"
|
name="Compliant"
|
||||||
shape={renderNonCompliantBar}
|
stackId="devices"
|
||||||
label={renderNonCompliantLabel}
|
fill="#3B82F6"
|
||||||
barSize={28}
|
shape={renderTotalAssetsBar}
|
||||||
|
label={renderTotalLabel}
|
||||||
|
barSize={36}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
|
|||||||
Reference in New Issue
Block a user