Add aggregated burndown forecast to CCP Metrics overview page
This commit is contained in:
@@ -278,6 +278,116 @@ function computeVerticalBurndown(items) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
|
||||
* A device appearing in multiple metrics counts once.
|
||||
*
|
||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
|
||||
* @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
|
||||
*/
|
||||
function deduplicateByHostname(items) {
|
||||
const map = {};
|
||||
for (const item of items) {
|
||||
const key = item.hostname;
|
||||
if (!map[key]) {
|
||||
map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical };
|
||||
} else {
|
||||
// Keep the earliest non-null resolution_date
|
||||
const existing = map[key];
|
||||
if (item.resolution_date != null) {
|
||||
if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) {
|
||||
existing.resolution_date = item.resolution_date;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes aggregated burndown from a deduplicated array of device objects.
|
||||
* Each device has { hostname, resolution_date, vertical }.
|
||||
*
|
||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
|
||||
* @returns {{
|
||||
* total: number,
|
||||
* blockers: number,
|
||||
* with_dates: number,
|
||||
* monthly: Object<string, number>,
|
||||
* projection: Object<string, { remediated: number, remaining: number }>,
|
||||
* projected_clear_date: string|null,
|
||||
* by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
|
||||
* }}
|
||||
*/
|
||||
function computeAggregatedBurndown(devices) {
|
||||
const total = devices.length;
|
||||
const withDates = devices.filter(d => d.resolution_date != null);
|
||||
const blockerDevices = devices.filter(d => d.resolution_date == null);
|
||||
const blockers = blockerDevices.length;
|
||||
const with_dates = withDates.length;
|
||||
|
||||
// Bucket by month (YYYY-MM)
|
||||
const monthly = {};
|
||||
for (const device of withDates) {
|
||||
const dateStr = typeof device.resolution_date === 'string'
|
||||
? device.resolution_date
|
||||
: device.resolution_date.toISOString().slice(0, 10);
|
||||
const month = dateStr.slice(0, 7);
|
||||
monthly[month] = (monthly[month] || 0) + 1;
|
||||
}
|
||||
|
||||
// Sort monthly keys chronologically
|
||||
const sortedMonths = Object.keys(monthly).sort();
|
||||
const sortedMonthly = {};
|
||||
for (const m of sortedMonths) {
|
||||
sortedMonthly[m] = monthly[m];
|
||||
}
|
||||
|
||||
// Cumulative projection
|
||||
let remaining = total;
|
||||
const projection = {};
|
||||
for (const month of sortedMonths) {
|
||||
remaining -= sortedMonthly[month];
|
||||
projection[month] = { remediated: sortedMonthly[month], remaining };
|
||||
}
|
||||
|
||||
// Projected clear date
|
||||
let projected_clear_date = null;
|
||||
if (blockers === 0 && sortedMonths.length > 0) {
|
||||
projected_clear_date = sortedMonths[sortedMonths.length - 1];
|
||||
}
|
||||
|
||||
// Per-vertical breakdown
|
||||
const verticalMap = {};
|
||||
for (const device of devices) {
|
||||
const v = device.vertical;
|
||||
if (!verticalMap[v]) {
|
||||
verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 };
|
||||
}
|
||||
verticalMap[v].total++;
|
||||
if (device.resolution_date == null) {
|
||||
verticalMap[v].blockers++;
|
||||
} else {
|
||||
verticalMap[v].with_dates++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by total, filter out zero-total entries
|
||||
const by_vertical = Object.values(verticalMap)
|
||||
.filter(v => v.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
total,
|
||||
blockers,
|
||||
with_dates,
|
||||
monthly: sortedMonthly,
|
||||
projection,
|
||||
projected_clear_date,
|
||||
by_vertical,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -292,4 +402,6 @@ module.exports = {
|
||||
mapColumnHeaders,
|
||||
parseVerticalFilename,
|
||||
computeVerticalBurndown,
|
||||
deduplicateByHostname,
|
||||
computeAggregatedBurndown,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user