221 lines
6.7 KiB
JavaScript
221 lines
6.7 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
truncateText,
|
||
|
|
validateRemediationPlan,
|
||
|
|
isValidDateString,
|
||
|
|
formatPct,
|
||
|
|
computeVCLStats,
|
||
|
|
categorizeNonCompliant,
|
||
|
|
rankHeavyHitters,
|
||
|
|
computeForecastBurndown,
|
||
|
|
matchByHostname,
|
||
|
|
computeBulkDiff,
|
||
|
|
mapColumnHeaders,
|
||
|
|
};
|