Add VCL vertical metadata: inline-editable team fields, JSDoc on compliance routes, stats query rewrite

This commit is contained in:
Jordan Ramos
2026-05-13 07:57:41 -06:00
parent 0d29a1b84e
commit 9eec63ea42
4 changed files with 549 additions and 104 deletions

View File

@@ -0,0 +1,26 @@
// Migration: Create vcl_vertical_metadata table for editable team-level notes, RAs, and compliance dates
const pool = require('../db');
async function run() {
console.log('Starting vcl_vertical_metadata migration...');
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS vcl_vertical_metadata (
id SERIAL PRIMARY KEY,
team TEXT NOT NULL UNIQUE,
notes TEXT DEFAULT '',
risk_acceptances INTEGER DEFAULT 0,
compliance_date TEXT DEFAULT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
console.log('✓ vcl_vertical_metadata table created (or already exists)');
} catch (err) {
console.error('Migration error:', err.message);
process.exit(1);
}
console.log('Migration complete.');
process.exit(0);
}
run();

View File

@@ -17,6 +17,7 @@ const POSTGRES_MIGRATIONS = [
'add_decom_workflow_type.js', 'add_decom_workflow_type.js',
'add_fp_submissions_dismissed.js', 'add_fp_submissions_dismissed.js',
'add_vcl_reporting_columns.js', 'add_vcl_reporting_columns.js',
'add_vcl_vertical_metadata.js',
]; ];
async function runAll() { async function runAll() {

View File

@@ -258,7 +258,17 @@ function createComplianceRouter(upload) {
// All compliance routes require authentication // All compliance routes require authentication
router.use(requireAuth()); router.use(requireAuth());
// POST /preview /**
* POST /preview
* Uploads an xlsx file, parses it, computes a diff against current active items,
* performs schema drift detection, and stores parsed data in a temp file for later commit.
*
* @body multipart/form-data — field "file" (xlsx spreadsheet, max 10MB)
* @response 200 { drift, drift_error, schema, diff: { new_count, recurring_count, resolved_count }, tempFile, filename, report_date, total_items }
* @response 400 { error } — no file, wrong extension, or upload error
* @response 422 { error } — parser returned an error
* @response 500 { error } — config load failure or parse failure
*/
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => { router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
upload.single('file')(req, res, async (uploadErr) => { upload.single('file')(req, res, async (uploadErr) => {
if (uploadErr) return res.status(400).json({ error: uploadErr.message }); if (uploadErr) return res.status(400).json({ error: uploadErr.message });
@@ -320,7 +330,15 @@ function createComplianceRouter(upload) {
}); });
}); });
// POST /reconcile-config /**
* POST /reconcile-config
* Applies schema drift reconciliation to the compliance config file based on detected drift findings.
*
* @body { drift: object, schema?: object }
* @response 200 { changes: Array<{ action, key, value, detail }>, message }
* @response 400 { error } — missing drift or no findings to reconcile
* @response 500 { error } — reconciliation failure
*/
router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => { router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => {
const { drift, schema } = req.body; const { drift, schema } = req.body;
if (!drift || typeof drift !== 'object') return res.status(400).json({ error: 'drift report is required in request body' }); if (!drift || typeof drift !== 'object') return res.status(400).json({ error: 'drift report is required in request body' });
@@ -341,7 +359,16 @@ function createComplianceRouter(upload) {
} }
}); });
// POST /commit /**
* POST /commit
* Commits a previously previewed compliance upload to the database. Resolves items no longer
* present, upserts recurring/new items, and creates a compliance snapshot for the current month.
*
* @body { tempFile: string, filename?: string, report_date?: string }
* @response 200 { upload: { id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count } }
* @response 400 { error } — missing/invalid tempFile or expired preview session
* @response 500 { error } — commit failure
*/
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { tempFile, filename, report_date } = req.body; const { tempFile, filename, report_date } = req.body;
if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' }); if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' });
@@ -372,7 +399,13 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /uploads /**
* GET /uploads
* Returns all compliance upload records ordered by most recent first.
*
* @response 200 { uploads: Array<{ id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count }> }
* @response 500 { error } — database error
*/
router.get('/uploads', async (req, res) => { router.get('/uploads', async (req, res) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -386,7 +419,17 @@ function createComplianceRouter(upload) {
} }
}); });
// POST /rollback/:uploadId /**
* POST /rollback/:uploadId
* Rolls back the most recent compliance upload — deletes new items introduced by that upload,
* reactivates items it resolved, and removes the upload record.
*
* @param uploadId — numeric ID of the upload to roll back (must be the latest)
* @response 200 { message, rolled_back: { upload_id, filename, report_date, items_deleted, items_reactivated } }
* @response 400 { error } — invalid ID or not the latest upload
* @response 404 { error } — upload not found
* @response 500 { error } — rollback failure
*/
router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => { router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => {
const uploadId = parseInt(req.params.uploadId, 10); const uploadId = parseInt(req.params.uploadId, 10);
if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' }); if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' });
@@ -440,7 +483,15 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /summary /**
* GET /summary
* Returns the summary data from the most recent compliance upload, optionally filtered by team.
*
* @query team — optional, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV
* @response 200 { entries: Array, overall_scores: object, upload: { id, report_date, uploaded_at } | null }
* @response 400 { error } — invalid team
* @response 500 { error } — database error
*/
router.get('/summary', async (req, res) => { router.get('/summary', async (req, res) => {
const team = req.query.team; const team = req.query.team;
if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
@@ -465,7 +516,16 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /items /**
* GET /items
* Returns compliance items grouped by hostname for a given team and status.
*
* @query team — required, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV
* @query status — optional, "active" (default) or "resolved"
* @response 200 { devices: Array<{ hostname, ip_address, device_type, team, status, failing_metrics, seen_count, first_seen, last_seen, resolved_on, has_notes }>, team, status }
* @response 400 { error } — missing/invalid team or invalid status
* @response 500 { error } — database error
*/
router.get('/items', async (req, res) => { router.get('/items', async (req, res) => {
const { team, status = 'active' } = req.query; const { team, status = 'active' } = req.query;
if (!team) return res.status(400).json({ error: 'team is required' }); if (!team) return res.status(400).json({ error: 'team is required' });
@@ -495,7 +555,16 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /items/:hostname /**
* GET /items/:hostname
* Returns detailed information for a single device including all metrics and notes.
*
* @param hostname — the device hostname
* @response 200 { hostname, ip_address, device_type, team, metrics: Array<{ metric_id, metric_desc, category, status, ... }>, notes: Array<{ id, metric_id, note, group_id, created_at, created_by }> }
* @response 400 { error } — invalid hostname
* @response 404 { error } — device not found
* @response 500 { error } — database error
*/
router.get('/items/:hostname', async (req, res) => { router.get('/items/:hostname', async (req, res) => {
const hostname = req.params.hostname; const hostname = req.params.hostname;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
@@ -503,6 +572,7 @@ function createComplianceRouter(upload) {
try { try {
const { rows: metricRows } = await pool.query( const { rows: metricRows } = await pool.query(
`SELECT ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json, `SELECT ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
ci.resolution_date, ci.remediation_plan,
fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at, lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at, ru.report_date AS resolved_on fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at, lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at, ru.report_date AS resolved_on
FROM compliance_items ci FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
@@ -522,14 +592,25 @@ function createComplianceRouter(upload) {
); );
const identity = metricRows.find(r => r.status === 'active') || metricRows[0]; const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', metrics, notes }); // Return resolution_date and remediation_plan from the first active item (or any item)
const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null;
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes });
} catch (err) { } catch (err) {
console.error('[Compliance] GET /items/:hostname error:', err.message); console.error('[Compliance] GET /items/:hostname error:', err.message);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
} }
}); });
// POST /notes /**
* POST /notes
* Creates one or more compliance notes for a device, linked to specific metric IDs.
* All notes in a single call share a group_id for batch operations.
*
* @body { hostname: string, metric_id?: string, metric_ids?: string[], note: string }
* @response 201 { notes: Array<{ id, hostname, metric_id, note, group_id, created_at, created_by }> }
* @response 400 { error } — invalid hostname, missing/invalid metric_id(s), or empty note
* @response 500 { error } — save failure
*/
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, metric_ids, note } = req.body; const { hostname, metric_id, metric_ids, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) return res.status(400).json({ error: 'Invalid hostname format' }); if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) return res.status(400).json({ error: 'Invalid hostname format' });
@@ -585,7 +666,16 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /notes/:hostname/:metricId /**
* GET /notes/:hostname/:metricId
* Returns all notes for a specific device and metric combination.
*
* @param hostname — the device hostname
* @param metricId — the metric identifier
* @response 200 { notes: Array<{ id, note, created_at, created_by }> }
* @response 400 { error } — invalid hostname or metricId
* @response 500 { error } — database error
*/
router.get('/notes/:hostname/:metricId', async (req, res) => { router.get('/notes/:hostname/:metricId', async (req, res) => {
const { hostname, metricId } = req.params; const { hostname, metricId } = req.params;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
@@ -604,7 +694,19 @@ function createComplianceRouter(upload) {
} }
}); });
// DELETE /notes/:id /**
* DELETE /notes/:id
* Deletes a compliance note by ID. Only the note author or an Admin can delete.
* Optionally deletes all notes in the same group when ?group=true.
*
* @param id — numeric note ID
* @query group — optional, "true" to delete all notes sharing the same group_id
* @response 200 { deleted: number }
* @response 400 { error } — invalid note ID
* @response 403 { error } — not the author and not Admin
* @response 404 { error } — note not found
* @response 500 { error } — delete failure
*/
router.delete('/notes/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.delete('/notes/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const noteId = parseInt(req.params.id, 10); const noteId = parseInt(req.params.id, 10);
if (isNaN(noteId)) return res.status(400).json({ error: 'Invalid note ID' }); if (isNaN(noteId)) return res.status(400).json({ error: 'Invalid note ID' });
@@ -636,7 +738,13 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /trends /**
* GET /trends
* Returns historical compliance upload trends with per-team breakdowns for charting.
*
* @response 200 { trends: Array<{ report_date, new_count, recurring_count, resolved_count, total_active, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }> }
* @response 500 { error } — database error
*/
router.get('/trends', async (req, res) => { router.get('/trends', async (req, res) => {
try { try {
const { rows: uploads } = await pool.query( const { rows: uploads } = await pool.query(
@@ -661,7 +769,13 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /mttr /**
* GET /mttr
* Returns aging bucket distribution of active compliance items (1 cycle, 23, 46, 7+) with per-team counts.
*
* @response 200 { aging: Array<{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }> }
* @response 500 { error } — database error
*/
router.get('/mttr', async (req, res) => { router.get('/mttr', async (req, res) => {
try { try {
const { rows } = await pool.query(`SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active'`); const { rows } = await pool.query(`SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active'`);
@@ -674,7 +788,13 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /top-recurring /**
* GET /top-recurring
* Returns waterfall chart data computed from upload history (start, new, recurring, resolved, end per upload).
*
* @response 200 { waterfall: Array<{ date, start, new_count, recurring_count, resolved_count, end }> }
* @response 500 { error } — database error
*/
router.get('/top-recurring', async (req, res) => { router.get('/top-recurring', async (req, res) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -688,7 +808,13 @@ function createComplianceRouter(upload) {
} }
}); });
// GET /category-trend /**
* GET /category-trend
* Returns per-upload category breakdown counts for trend charting.
*
* @response 200 { categoryTrend: Array<{ report_date, category, count }> }
* @response 500 { error } — database error
*/
router.get('/category-trend', async (req, res) => { router.get('/category-trend', async (req, res) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -703,9 +829,17 @@ function createComplianceRouter(upload) {
} }
}); });
// ----------------------------------------------------------------------- /**
// PATCH /items/:hostname/metadata — Update resolution_date / remediation_plan * PATCH /items/:hostname/metadata
// ----------------------------------------------------------------------- * Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname.
*
* @param hostname — the device hostname
* @body { resolution_date?: string|null, remediation_plan?: string|null }
* @response 200 { updated: number }
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, or no fields provided
* @response 404 { error } — device not found
* @response 500 { error } — update failure
*/
router.patch('/items/:hostname/metadata', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.patch('/items/:hostname/metadata', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const hostname = req.params.hostname; const hostname = req.params.hostname;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
@@ -748,7 +882,7 @@ function createComplianceRouter(upload) {
values.push(hostname); values.push(hostname);
const result = await pool.query( const result = await pool.query(
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`, `UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
values values
); );
@@ -773,95 +907,144 @@ function createComplianceRouter(upload) {
} }
}); });
// ----------------------------------------------------------------------- /**
// GET /vcl/stats — VCL executive summary statistics * GET /vcl/stats
// ----------------------------------------------------------------------- * Returns VCL executive summary statistics including device counts, compliance percentage,
* non-compliant asset categorization (donut), heavy hitters by team, and vertical breakdown with burndown.
*
* @response 200 { stats: { total_devices, in_scope, compliant, non_compliant, remediations_required, compliance_pct, target_pct }, donut: { blocked: { count, pct }, in_progress: { count, pct } }, heavy_hitters: Array<{ vertical, team, non_compliant, compliance_date, notes }>, vertical_breakdown: Array<{ vertical, compliance_pct, team, non_compliant, actual_burndown, forecast_burndown, blockers, risk_acceptances, notes }> }
* @response 500 { error } — database error
*/
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95; const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
router.get('/vcl/stats', async (req, res) => { router.get('/vcl/stats', async (req, res) => {
try { try {
// Fetch all active compliance items // Compute device-level stats using DISTINCT hostname
const { rows: items } = await pool.query( // A device is "compliant" if it has NO active findings
`SELECT hostname, team, status, resolution_date, remediation_plan, const { rows: statsRows } = await pool.query(`
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant, SELECT
true AS in_scope COUNT(DISTINCT hostname) AS total_devices,
FROM compliance_items WHERE status = 'active'` COUNT(DISTINCT hostname) AS in_scope,
); COUNT(DISTINCT CASE
WHEN hostname NOT IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
THEN hostname END) AS compliant,
COUNT(DISTINCT CASE
WHEN hostname IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
THEN hostname END) AS non_compliant
FROM compliance_items
`);
// For stats computation, all active items are non-compliant (they are findings) const raw = statsRows[0] || {};
// We need total in-scope devices (active + resolved from latest upload) const total_devices = parseInt(raw.total_devices) || 0;
const { rows: latestUploadRows } = await pool.query( const in_scope = parseInt(raw.in_scope) || 0;
`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1` const compliant = parseInt(raw.compliant) || 0;
); const non_compliant = parseInt(raw.non_compliant) || 0;
const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;
let allDeviceItems = []; const stats = {
if (latestUploadRows.length > 0) { total_devices,
const { rows: allItems } = await pool.query( in_scope,
`SELECT hostname, team, status, resolution_date, remediation_plan, compliant,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant, non_compliant,
true AS in_scope remediations_required: non_compliant,
FROM compliance_items` compliance_pct,
); target_pct: VCL_TARGET_PCT,
// Deduplicate by hostname — a device is compliant if it has no active findings };
const deviceMap = new Map();
for (const item of allItems) {
const existing = deviceMap.get(item.hostname);
if (!existing) {
deviceMap.set(item.hostname, { ...item, is_compliant: item.status !== 'active', in_scope: true });
} else if (item.status === 'active') {
existing.is_compliant = false;
}
}
allDeviceItems = Array.from(deviceMap.values());
}
const stats = computeVCLStats(allDeviceItems, VCL_TARGET_PCT); // Donut: categorize non-compliant DEVICES by resolution_date presence
// A device is "blocked" if it has no resolution_date on any of its active findings
// A device is "in_progress" if at least one active finding has a resolution_date
const { rows: donutRows } = await pool.query(`
SELECT
hostname,
MAX(resolution_date) AS resolution_date
FROM compliance_items
WHERE status = 'active'
GROUP BY hostname
`);
const donut = categorizeNonCompliant(donutRows);
// Donut: categorize non-compliant items by resolution_date presence // Heavy hitters: group by team, count non-compliant DEVICES per team
const nonCompliantItems = items.filter(i => i.status === 'active'); const { rows: teamRows } = await pool.query(`
const donut = categorizeNonCompliant(nonCompliantItems); SELECT
COALESCE(team, 'Unknown') AS team,
// Heavy hitters: group by team, count non-compliant per team COUNT(DISTINCT hostname) AS non_compliant,
const teamCounts = {}; MAX(resolution_date) AS compliance_date
for (const item of nonCompliantItems) { FROM compliance_items
const team = item.team || 'Unknown'; WHERE status = 'active'
if (!teamCounts[team]) { GROUP BY team
teamCounts[team] = { vertical: team, team: team, non_compliant: 0, compliance_date: null, notes: '' }; ORDER BY COUNT(DISTINCT hostname) DESC
} `);
teamCounts[team].non_compliant++; const heavy_hitters = teamRows.map(r => ({
// Use the latest resolution_date as the team's compliance_date vertical: r.team,
if (item.resolution_date && (!teamCounts[team].compliance_date || item.resolution_date > teamCounts[team].compliance_date)) { team: r.team,
teamCounts[team].compliance_date = item.resolution_date; non_compliant: parseInt(r.non_compliant),
} compliance_date: r.compliance_date ? r.compliance_date.toISOString().slice(0, 10) : null,
} notes: '',
const heavy_hitters = rankHeavyHitters(Object.values(teamCounts)); }));
// Vertical breakdown with burndown // Vertical breakdown with burndown
const verticalBreakdown = []; const verticalBreakdown = [];
for (const team of Object.keys(teamCounts)) { for (const teamRow of teamRows) {
const teamItems = nonCompliantItems.filter(i => (i.team || 'Unknown') === team); const team = teamRow.team;
const teamAllDevices = allDeviceItems.filter(i => (i.team || 'Unknown') === team); const teamNonCompliant = parseInt(teamRow.non_compliant);
const teamTotal = teamAllDevices.length;
const teamCompliant = teamAllDevices.filter(i => i.is_compliant).length;
const compliance_pct = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0;
const actual_burndown = computeForecastBurndown(teamItems.filter(i => i.resolution_date)); // Get total devices for this team (all statuses)
const forecast_burndown = computeForecastBurndown(teamItems.filter(i => i.resolution_date)); const { rows: teamTotalRows } = await pool.query(
const blockers = teamItems.filter(i => !i.resolution_date).length; `SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $1`,
[team]
);
const teamTotal = parseInt(teamTotalRows[0]?.total) || 0;
const teamCompliant = teamTotal - teamNonCompliant;
const compliance_pct_team = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0;
// Forecast burndown from resolution_dates
const { rows: forecastItems } = await pool.query(
`SELECT resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL`,
[team]
);
const forecast_burndown = computeForecastBurndown(forecastItems);
const blockers = teamNonCompliant - forecastItems.length;
verticalBreakdown.push({ verticalBreakdown.push({
vertical: team, vertical: team,
compliance_pct, compliance_pct: compliance_pct_team,
team: team, team: team,
non_compliant: teamItems.length, non_compliant: teamNonCompliant,
actual_burndown, actual_burndown: {},
forecast_burndown, forecast_burndown,
blockers, blockers: blockers > 0 ? blockers : 0,
risk_acceptances: 0, risk_acceptances: 0,
notes: '', notes: '',
}); });
} }
// Merge vertical metadata (notes, risk_acceptances, compliance_date)
try {
const { rows: metaRows } = await pool.query(`SELECT team, notes, risk_acceptances, compliance_date FROM vcl_vertical_metadata`);
const metaMap = {};
metaRows.forEach(r => { metaMap[r.team] = r; });
for (const hh of heavy_hitters) {
const meta = metaMap[hh.vertical] || metaMap[hh.team];
if (meta) {
hh.notes = meta.notes || '';
hh.compliance_date = meta.compliance_date || hh.compliance_date;
}
}
for (const vb of verticalBreakdown) {
const meta = metaMap[vb.vertical] || metaMap[vb.team];
if (meta) {
vb.notes = meta.notes || '';
vb.risk_acceptances = meta.risk_acceptances || 0;
vb.compliance_date = meta.compliance_date || null;
}
}
} catch (metaErr) {
// Non-critical — continue without metadata
console.error('[Compliance] VCL metadata merge error:', metaErr.message);
}
res.json({ stats, donut, heavy_hitters, vertical_breakdown: verticalBreakdown }); res.json({ stats, donut, heavy_hitters, vertical_breakdown: verticalBreakdown });
} catch (err) { } catch (err) {
console.error('[Compliance] GET /vcl/stats error:', err.message); console.error('[Compliance] GET /vcl/stats error:', err.message);
@@ -869,9 +1052,14 @@ function createComplianceRouter(upload) {
} }
}); });
// ----------------------------------------------------------------------- /**
// GET /vcl/trend — Monthly compliance trend with forecast * GET /vcl/trend
// ----------------------------------------------------------------------- * Returns monthly compliance trend data with actual percentages and linear regression forecast.
* Forecast is computed when 3+ months of historical data exist, projecting 3 months forward.
*
* @response 200 { months: Array<{ month, compliant_count, compliance_pct, forecast_pct, target_pct }> }
* @response 500 { error } — database error
*/
router.get('/vcl/trend', async (req, res) => { router.get('/vcl/trend', async (req, res) => {
try { try {
const { rows: snapshots } = await pool.query( const { rows: snapshots } = await pool.query(
@@ -940,9 +1128,16 @@ function createComplianceRouter(upload) {
} }
}); });
// ----------------------------------------------------------------------- /**
// POST /vcl/bulk-preview — Bulk upload diff preview * POST /vcl/bulk-preview
// ----------------------------------------------------------------------- * Accepts parsed bulk upload rows, matches hostnames against active devices, validates fields,
* and returns a diff preview showing matched/unmatched/changed/invalid row counts.
*
* @body { rows: Array<{ hostname, resolution_date?, remediation_plan?, notes? }>, headers?: string[] }
* @response 200 { matched, unmatched, changes, invalid, details: Array<{ hostname, status, fields? }>, unmatched_rows: string[], invalid_rows: Array<{ hostname, errors }> }
* @response 400 { error } — missing rows, exceeds 2000 rows, no Hostname column, or no updatable fields
* @response 500 { error } — processing failure
*/
router.post('/vcl/bulk-preview', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/vcl/bulk-preview', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { rows, headers } = req.body; const { rows, headers } = req.body;
@@ -1057,9 +1252,16 @@ function createComplianceRouter(upload) {
} }
}); });
// ----------------------------------------------------------------------- /**
// POST /vcl/bulk-commit — Commit validated bulk changes * POST /vcl/bulk-commit
// ----------------------------------------------------------------------- * Commits validated bulk changes to compliance items in a single transaction.
* Updates resolution_date and/or remediation_plan for each hostname provided.
*
* @body { changes: Array<{ hostname, resolution_date?, remediation_plan?, notes? }> }
* @response 200 { committed: number }
* @response 400 { error } — missing or empty changes array
* @response 500 { error } — transaction failure (full rollback)
*/
router.post('/vcl/bulk-commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/vcl/bulk-commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { changes } = req.body; const { changes } = req.body;
@@ -1122,6 +1324,88 @@ function createComplianceRouter(upload) {
} }
}); });
// -----------------------------------------------------------------------
// VCL Vertical Metadata endpoints
// -----------------------------------------------------------------------
/**
* GET /vcl/vertical-metadata
* Returns all rows from vcl_vertical_metadata.
*
* @response 200 { metadata: Array<{ id, team, notes, risk_acceptances, compliance_date, updated_at }> }
* @response 500 { error } — database error
*/
router.get('/vcl/vertical-metadata', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, team, notes, risk_acceptances, compliance_date, updated_at FROM vcl_vertical_metadata ORDER BY team`
);
res.json({ metadata: rows });
} catch (err) {
console.error('[Compliance] GET /vcl/vertical-metadata error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
/**
* PATCH /vcl/vertical-metadata/:team
* Upserts notes, risk_acceptances, and/or compliance_date for a team.
*
* @param team — the team/vertical name
* @body { notes?: string, risk_acceptances?: number, compliance_date?: string|null }
* @response 200 { success: true }
* @response 400 { error } — no fields provided or invalid values
* @response 500 { error } — database error
*/
router.patch('/vcl/vertical-metadata/:team', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const team = req.params.team;
if (!team || team.length > 100) return res.status(400).json({ error: 'Invalid team' });
const { notes, risk_acceptances, compliance_date } = req.body;
if (notes === undefined && risk_acceptances === undefined && compliance_date === undefined) {
return res.status(400).json({ error: 'No fields to update' });
}
if (risk_acceptances !== undefined && risk_acceptances !== null) {
if (typeof risk_acceptances !== 'number' || risk_acceptances < 0 || !Number.isInteger(risk_acceptances)) {
return res.status(400).json({ error: 'risk_acceptances must be a non-negative integer' });
}
}
if (compliance_date !== undefined && compliance_date !== null && compliance_date !== '') {
if (typeof compliance_date !== 'string' || compliance_date.length > 50) {
return res.status(400).json({ error: 'compliance_date must be a string (max 50 chars)' });
}
}
try {
// Build the upsert dynamically
const upsertNotes = notes !== undefined ? notes : '';
const upsertRAs = risk_acceptances !== undefined ? risk_acceptances : 0;
const upsertDate = compliance_date !== undefined ? (compliance_date || null) : null;
// Use ON CONFLICT to insert or update only the provided fields
const updateParts = [];
if (notes !== undefined) updateParts.push('notes = EXCLUDED.notes');
if (risk_acceptances !== undefined) updateParts.push('risk_acceptances = EXCLUDED.risk_acceptances');
if (compliance_date !== undefined) updateParts.push('compliance_date = EXCLUDED.compliance_date');
updateParts.push('updated_at = NOW()');
await pool.query(
`INSERT INTO vcl_vertical_metadata (team, notes, risk_acceptances, compliance_date, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (team) DO UPDATE SET ${updateParts.join(', ')}`,
[team, upsertNotes, upsertRAs, upsertDate]
);
res.json({ success: true });
} catch (err) {
console.error('[Compliance] PATCH /vcl/vertical-metadata error:', err.message);
res.status(500).json({ error: 'Failed to update vertical metadata' });
}
});
return router; return router;
} }

View File

@@ -183,10 +183,89 @@ function NonCompliantDonutChart({ donut }) {
); );
} }
// ---------------------------------------------------------------------------
// Inline Editable Cell Component
// ---------------------------------------------------------------------------
function EditableCell({ value, type, onSave, style, placeholder }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value || '');
useEffect(() => {
setDraft(value || '');
}, [value]);
const handleSave = () => {
setEditing(false);
const newValue = type === 'number' ? (draft === '' ? 0 : parseInt(draft, 10)) : draft;
if (newValue !== value) {
onSave(newValue);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setDraft(value || '');
setEditing(false);
}
};
if (editing) {
const inputStyle = {
background: 'rgba(15,23,42,0.9)',
border: `1px solid ${TEAL}`,
borderRadius: '0.25rem',
color: '#CBD5E1',
fontFamily: 'monospace',
fontSize: '0.7rem',
padding: '0.25rem 0.4rem',
width: '100%',
outline: 'none',
boxShadow: `0 0 0 1px ${TEAL}40`,
};
return (
<input
type={type || 'text'}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
style={{ ...inputStyle, ...style }}
autoFocus
placeholder={placeholder}
/>
);
}
const displayValue = type === 'number' ? (value || 0) : (value || '');
return (
<span
onClick={() => setEditing(true)}
style={{
cursor: 'pointer',
display: 'inline-block',
minWidth: '30px',
minHeight: '1.2em',
padding: '0.1rem 0.25rem',
borderRadius: '0.2rem',
transition: 'background 0.15s',
...(style || {}),
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(20,184,166,0.08)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
title="Click to edit"
>
{displayValue || <span style={{ color: '#475569', fontStyle: 'italic' }}>{placeholder || '—'}</span>}
</span>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Heavy Hitters Table (Task 14) // Heavy Hitters Table (Task 14)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function HeavyHittersTable({ heavyHitters }) { function HeavyHittersTable({ heavyHitters, onMetadataUpdate }) {
if (!heavyHitters || heavyHitters.length === 0) { if (!heavyHitters || heavyHitters.length === 0) {
return ( return (
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}> <div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
@@ -244,10 +323,20 @@ function HeavyHittersTable({ heavyHitters }) {
{row.non_compliant} {row.non_compliant}
</td> </td>
<td style={cellStyle}> <td style={cellStyle}>
{row.compliance_date || ''} <EditableCell
value={row.compliance_date || ''}
type="text"
placeholder="e.g. Q3 2026"
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { compliance_date: val || null })}
/>
</td> </td>
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.notes || ''} <EditableCell
value={row.notes || ''}
type="text"
placeholder="Add notes"
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { notes: val })}
/>
</td> </td>
</tr> </tr>
))} ))}
@@ -260,7 +349,7 @@ function HeavyHittersTable({ heavyHitters }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Vertical Breakdown Table (Task 15) // Vertical Breakdown Table (Task 15)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function VerticalBreakdownTable({ verticalBreakdown }) { function VerticalBreakdownTable({ verticalBreakdown, onMetadataUpdate }) {
if (!verticalBreakdown || verticalBreakdown.length === 0) { if (!verticalBreakdown || verticalBreakdown.length === 0) {
return ( return (
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}> <div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
@@ -357,10 +446,21 @@ function VerticalBreakdownTable({ verticalBreakdown }) {
{row.blockers || 0} {row.blockers || 0}
</td> </td>
<td style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}> <td style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
{row.risk_acceptances || 0} <EditableCell
value={row.risk_acceptances || 0}
type="number"
placeholder="0"
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { risk_acceptances: val })}
style={{ textAlign: 'right', width: '50px' }}
/>
</td> </td>
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis' }}> <td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.notes || ''} <EditableCell
value={row.notes || ''}
type="text"
placeholder="Add notes"
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { notes: val })}
/>
</td> </td>
</tr> </tr>
))} ))}
@@ -381,6 +481,40 @@ export default function VCLReportPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [showBulkUpload, setShowBulkUpload] = useState(false); const [showBulkUpload, setShowBulkUpload] = useState(false);
const handleMetadataUpdate = async (team, fields) => {
try {
const res = await fetch(`${API_BASE}/compliance/vcl/vertical-metadata/${encodeURIComponent(team)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(fields),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error('Failed to update metadata:', data.error || res.statusText);
return;
}
// Optimistically update local state
setStats(prev => {
if (!prev) return prev;
const updated = { ...prev };
if (updated.heavy_hitters) {
updated.heavy_hitters = updated.heavy_hitters.map(hh =>
(hh.vertical === team || hh.team === team) ? { ...hh, ...fields } : hh
);
}
if (updated.vertical_breakdown) {
updated.vertical_breakdown = updated.vertical_breakdown.map(vb =>
(vb.vertical === team || vb.team === team) ? { ...vb, ...fields } : vb
);
}
return updated;
});
} catch (err) {
console.error('Metadata update error:', err);
}
};
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -467,10 +601,10 @@ export default function VCLReportPage() {
</div> </div>
{/* Heavy Hitters */} {/* Heavy Hitters */}
<HeavyHittersTable heavyHitters={stats?.heavy_hitters} /> <HeavyHittersTable heavyHitters={stats?.heavy_hitters} onMetadataUpdate={handleMetadataUpdate} />
{/* Vertical Breakdown */} {/* Vertical Breakdown */}
<VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} /> <VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} onMetadataUpdate={handleMetadataUpdate} />
{/* Bulk Upload Modal */} {/* Bulk Upload Modal */}
{showBulkUpload && ( {showBulkUpload && (