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
538 lines
18 KiB
JavaScript
538 lines
18 KiB
JavaScript
// Pure helper functions for VCL Compliance Reporting
|
|
// No database dependencies — all functions are stateless and testable in isolation.
|
|
|
|
/**
|
|
* Truncates text to maxLen characters with an ellipsis.
|
|
* Returns '' for null/undefined input.
|
|
*/
|
|
function truncateText(text, maxLen = 80) {
|
|
if (text == null) return '';
|
|
if (text.length <= maxLen) return text;
|
|
return text.slice(0, maxLen) + '\u2026';
|
|
}
|
|
|
|
/**
|
|
* Validates that a remediation plan does not exceed 2000 characters.
|
|
* Null/undefined/empty values are considered valid (no plan documented).
|
|
*/
|
|
function validateRemediationPlan(text) {
|
|
if (text == null || text === '') return { valid: true };
|
|
if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' };
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Returns true only for strings parseable as real calendar dates.
|
|
* Rejects null, undefined, empty string, and invalid dates like "2026-02-30".
|
|
*/
|
|
function isValidDateString(str) {
|
|
if (str == null || str === '') return false;
|
|
if (typeof str !== 'string') return false;
|
|
|
|
// Expect YYYY-MM-DD format
|
|
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (!match) return false;
|
|
|
|
const year = parseInt(match[1], 10);
|
|
const month = parseInt(match[2], 10);
|
|
const day = parseInt(match[3], 10);
|
|
|
|
// Month must be 1-12
|
|
if (month < 1 || month > 12) return false;
|
|
|
|
// Create date and verify components match (catches invalid days like Feb 30)
|
|
const date = new Date(year, month - 1, day);
|
|
return (
|
|
date.getFullYear() === year &&
|
|
date.getMonth() === month - 1 &&
|
|
date.getDate() === day
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Formats a decimal as a whole-number percentage string.
|
|
* Returns '0%' for null, undefined, or NaN input.
|
|
*/
|
|
function formatPct(decimal) {
|
|
if (decimal == null || isNaN(decimal)) return '0%';
|
|
return Math.round(decimal * 100) + '%';
|
|
}
|
|
|
|
/**
|
|
* Computes VCL summary statistics from an array of device objects.
|
|
* Each item should have at least { is_compliant: boolean, in_scope: boolean }.
|
|
*/
|
|
function computeVCLStats(items, targetPct) {
|
|
const total = items.length;
|
|
const in_scope = items.filter(item => item.in_scope).length;
|
|
const compliant = items.filter(item => item.is_compliant).length;
|
|
const non_compliant = in_scope - compliant;
|
|
const remediations_required = non_compliant;
|
|
const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;
|
|
|
|
return {
|
|
total,
|
|
in_scope,
|
|
compliant,
|
|
non_compliant,
|
|
remediations_required,
|
|
compliance_pct,
|
|
target_pct: targetPct,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Partitions non-compliant items into "blocked" (no resolution_date) and
|
|
* "in_progress" (resolution_date set). Returns counts and percentages.
|
|
*/
|
|
function categorizeNonCompliant(items) {
|
|
const total = items.length;
|
|
const blocked = items.filter(item => item.resolution_date == null);
|
|
const in_progress = items.filter(item => item.resolution_date != null);
|
|
|
|
return {
|
|
blocked: {
|
|
count: blocked.length,
|
|
pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0,
|
|
},
|
|
in_progress: {
|
|
count: in_progress.length,
|
|
pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sorts verticals by non_compliant count in descending order.
|
|
* Returns a new sorted array (does not mutate input).
|
|
*/
|
|
function rankHeavyHitters(verticalData) {
|
|
return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant);
|
|
}
|
|
|
|
/**
|
|
* Buckets non-compliant items by resolution_date month (YYYY-MM).
|
|
* Items with null resolution_date are skipped.
|
|
* Returns an object like { '2026-05': 3, '2026-06': 7 }.
|
|
*/
|
|
function computeForecastBurndown(items) {
|
|
const buckets = {};
|
|
for (const item of items) {
|
|
if (item.resolution_date == null) continue;
|
|
const dateStr = typeof item.resolution_date === 'string'
|
|
? item.resolution_date
|
|
: item.resolution_date.toISOString().slice(0, 10);
|
|
const month = dateStr.slice(0, 7); // YYYY-MM
|
|
buckets[month] = (buckets[month] || 0) + 1;
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
/**
|
|
* Matches uploaded rows to existing hostnames.
|
|
* Returns { matched: [...], unmatched: [...] }.
|
|
*/
|
|
function matchByHostname(uploadedRows, existingHostnames) {
|
|
const matched = [];
|
|
const unmatched = [];
|
|
for (const row of uploadedRows) {
|
|
if (existingHostnames.has(row.hostname)) {
|
|
matched.push(row);
|
|
} else {
|
|
unmatched.push(row);
|
|
}
|
|
}
|
|
return { matched, unmatched };
|
|
}
|
|
|
|
/**
|
|
* Compares uploaded row values against current DB values.
|
|
* currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }.
|
|
* Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }.
|
|
*/
|
|
function computeBulkDiff(matchedRows, currentData) {
|
|
const results = [];
|
|
const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes'];
|
|
|
|
for (const row of matchedRows) {
|
|
const current = currentData.get(row.hostname) || {};
|
|
const fields = {};
|
|
let hasChange = false;
|
|
|
|
for (const field of COMPARE_FIELDS) {
|
|
if (field in row) {
|
|
const oldVal = current[field] != null ? current[field] : null;
|
|
const newVal = row[field] != null ? row[field] : null;
|
|
if (oldVal !== newVal) {
|
|
fields[field] = { old: oldVal, new: newVal };
|
|
hasChange = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
hostname: row.hostname,
|
|
status: hasChange ? 'changed' : 'unchanged',
|
|
fields,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Maps column header strings to known field names (case-insensitive).
|
|
* Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices.
|
|
*/
|
|
function mapColumnHeaders(headers) {
|
|
const mapping = {};
|
|
const KNOWN_MAPPINGS = {
|
|
hostname: 'hostname',
|
|
'resolution date': 'resolution_date',
|
|
resolution_date: 'resolution_date',
|
|
'remediation plan': 'remediation_plan',
|
|
remediation_plan: 'remediation_plan',
|
|
notes: 'notes',
|
|
};
|
|
|
|
for (let i = 0; i < headers.length; i++) {
|
|
const normalized = headers[i].trim().toLowerCase();
|
|
if (KNOWN_MAPPINGS[normalized]) {
|
|
mapping[KNOWN_MAPPINGS[normalized]] = i;
|
|
}
|
|
}
|
|
|
|
return mapping;
|
|
}
|
|
|
|
/**
|
|
* Extracts vertical code and report date from a filename.
|
|
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
|
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
|
*
|
|
* Examples:
|
|
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
|
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
|
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
|
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
|
*
|
|
* Returns null if the filename does not match the expected pattern.
|
|
*/
|
|
function parseVerticalFilename(filename) {
|
|
// Strip .xlsx extension (case-insensitive)
|
|
const stem = filename.replace(/\.xlsx$/i, '');
|
|
// Match: everything up to the last _YYYY_MM_DD
|
|
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
|
if (!match) return null;
|
|
|
|
const vertical = match[1];
|
|
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
|
|
|
// Validate the date portion is a real date
|
|
if (!isValidDateString(date)) return null;
|
|
|
|
return { vertical, date };
|
|
}
|
|
|
|
/**
|
|
* Computes per-vertical burndown forecast from non-compliant items.
|
|
* Returns breakdown of items with/without resolution dates and monthly projections.
|
|
*/
|
|
function computeVerticalBurndown(items) {
|
|
const total = items.length;
|
|
const withDates = items.filter(i => i.resolution_date != null);
|
|
const blockers = items.filter(i => i.resolution_date == null);
|
|
|
|
// Bucket by month
|
|
const monthly = {};
|
|
for (const item of withDates) {
|
|
const dateStr = typeof item.resolution_date === 'string'
|
|
? item.resolution_date
|
|
: item.resolution_date.toISOString().slice(0, 10);
|
|
const month = dateStr.slice(0, 7); // YYYY-MM
|
|
monthly[month] = (monthly[month] || 0) + 1;
|
|
}
|
|
|
|
// Cumulative projection — how many remain after each month
|
|
let remaining = total;
|
|
const projection = {};
|
|
for (const month of Object.keys(monthly).sort()) {
|
|
remaining -= monthly[month];
|
|
projection[month] = { remediated: monthly[month], remaining };
|
|
}
|
|
|
|
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
|
let projectedClearDate = null;
|
|
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
|
const sortedMonths = Object.keys(projection).sort();
|
|
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
|
}
|
|
|
|
return {
|
|
total,
|
|
blockers: blockers.length,
|
|
with_dates: withDates.length,
|
|
monthly,
|
|
projection,
|
|
projected_clear_date: projectedClearDate,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
isValidDateString,
|
|
formatPct,
|
|
computeVCLStats,
|
|
categorizeNonCompliant,
|
|
rankHeavyHitters,
|
|
computeForecastBurndown,
|
|
matchByHostname,
|
|
computeBulkDiff,
|
|
mapColumnHeaders,
|
|
parseVerticalFilename,
|
|
computeVerticalBurndown,
|
|
deduplicateByHostname,
|
|
computeAggregatedBurndown,
|
|
computeMetricForecastBurndown,
|
|
};
|