Add VCL compliance reporting: exec report page, device metadata fields, bulk upload
This commit is contained in:
220
backend/helpers/vclHelpers.js
Normal file
220
backend/helpers/vclHelpers.js
Normal file
@@ -0,0 +1,220 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user