Add VCL vertical metadata: inline-editable team fields, JSDoc on compliance routes, stats query rewrite
This commit is contained in:
26
backend/migrations/add_vcl_vertical_metadata.js
Normal file
26
backend/migrations/add_vcl_vertical_metadata.js
Normal 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();
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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, 2–3, 4–6, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user