Add VCL compliance reporting: exec report page, device metadata fields, bulk upload

This commit is contained in:
Jordan Ramos
2026-05-11 15:48:10 -06:00
parent 955036145d
commit d093a3d113
10 changed files with 2626 additions and 9 deletions

View File

@@ -0,0 +1,501 @@
/**
* Property-Based Tests: VCL Compliance Reporting
*
* Feature: vcl-compliance-reporting
*
* Tests the pure helper functions used for VCL compliance reporting computations.
*
* Validates: Requirements 2.4, 2.5, 3.2, 3.3, 5.2, 5.3, 6.1, 6.3, 7.5, 8.2, 8.3, 8.4, 8.7, 9.2, 9.3, 9.6
*/
const fc = require('fast-check');
// Mock db pool before importing anything (avoids DATABASE_URL requirement)
jest.mock('../db', () => ({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
connect: jest.fn(() => Promise.resolve({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
release: jest.fn(),
})),
}));
// Mock dependencies that the route module imports
jest.mock('../helpers/auditLog', () => jest.fn());
jest.mock('../helpers/ivantiApi', () => ({
ivantiFormPost: jest.fn(),
ivantiPost: jest.fn(),
}));
const {
truncateText,
validateRemediationPlan,
computeVCLStats,
formatPct,
categorizeNonCompliant,
rankHeavyHitters,
computeForecastBurndown,
matchByHostname,
computeBulkDiff,
mapColumnHeaders,
isValidDateString,
} = require('../helpers/vclHelpers');
// --- Generators ---
const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 30 });
const validDateArb = fc.record({
year: fc.integer({ min: 2020, max: 2030 }),
month: fc.integer({ min: 1, max: 12 }),
day: fc.integer({ min: 1, max: 28 }), // 1-28 always valid
}).map(({ year, month, day }) =>
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
);
const complianceItemArb = fc.record({
hostname: hostnameArb,
is_compliant: fc.boolean(),
in_scope: fc.constant(true),
});
const nonCompliantItemArb = fc.record({
hostname: hostnameArb,
resolution_date: fc.oneof(fc.constant(null), validDateArb),
});
const verticalArb = fc.record({
vertical: fc.string({ minLength: 1, maxLength: 20 }),
team: fc.string({ minLength: 1, maxLength: 20 }),
non_compliant: fc.integer({ min: 0, max: 1000 }),
});
// --- Property 2: Text Truncation ---
describe('Feature: vcl-compliance-reporting, Property 2: Text Truncation', () => {
/**
* For any string, truncateText(text, 80) should return the original string if its
* length is <= 80, or the first 80 characters followed by "…" if its length exceeds 80.
*
* **Validates: Requirements 2.4**
*/
it('returns original for short strings, truncated + ellipsis for long strings', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 200 }),
fc.integer({ min: 1, max: 100 }),
(text, maxLen) => {
const result = truncateText(text, maxLen);
if (text.length <= maxLen) {
expect(result).toBe(text);
} else {
expect(result).toBe(text.slice(0, maxLen) + '\u2026');
expect(result.length).toBe(maxLen + 1);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 3: Remediation Plan Length Validation ---
describe('Feature: vcl-compliance-reporting, Property 3: Remediation Plan Length Validation', () => {
/**
* For any string, validateRemediationPlan(text) should return valid if and only if
* the string length is <= 2000 characters.
*
* **Validates: Requirements 2.5, 9.4**
*/
it('accepts strings <= 2000 chars, rejects longer', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 3000 }),
(text) => {
const result = validateRemediationPlan(text);
if (text.length <= 2000) {
expect(result.valid).toBe(true);
} else {
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 4: Summary Statistics Computation Invariants ---
describe('Feature: vcl-compliance-reporting, Property 4: Summary Statistics Computation Invariants', () => {
/**
* For any set of compliance items, computeVCLStats produces correct arithmetic:
* non_compliant + compliant = in_scope, and correct percentage.
*
* **Validates: Requirements 3.2, 7.3**
*/
it('non_compliant + compliant = in_scope, correct percentage', () => {
fc.assert(
fc.property(
fc.array(complianceItemArb, { minLength: 0, maxLength: 50 }),
fc.integer({ min: 0, max: 100 }),
(items, targetPct) => {
const stats = computeVCLStats(items, targetPct);
// in_scope items are those with in_scope === true
const in_scope = items.filter(i => i.in_scope).length;
const compliant = items.filter(i => i.is_compliant).length;
expect(stats.non_compliant + stats.compliant).toBe(stats.in_scope);
expect(stats.in_scope).toBe(in_scope);
expect(stats.compliant).toBe(compliant);
if (in_scope > 0) {
expect(stats.compliance_pct).toBe(Math.round((compliant / in_scope) * 100));
} else {
expect(stats.compliance_pct).toBe(0);
}
expect(stats.target_pct).toBe(targetPct);
}
),
{ numRuns: 100 }
);
});
});
// --- Property 5: Percentage Formatting ---
describe('Feature: vcl-compliance-reporting, Property 5: Percentage Formatting', () => {
/**
* For any decimal number between 0 and 1, formatPct produces a string matching /^\d{1,3}%$/.
*
* **Validates: Requirements 3.3**
*/
it('produces correct percentage string matching /^\\d{1,3}%$/', () => {
fc.assert(
fc.property(
fc.double({ min: 0, max: 1, noNaN: true }),
(decimal) => {
const result = formatPct(decimal);
expect(result).toMatch(/^\d{1,3}%$/);
expect(result).toBe(Math.round(decimal * 100) + '%');
}
),
{ numRuns: 100 }
);
});
});
// --- Property 6: Non-Compliant Device Categorization Partition ---
describe('Feature: vcl-compliance-reporting, Property 6: Non-Compliant Device Categorization Partition', () => {
/**
* For any array of non-compliant device objects, categorizeNonCompliant produces
* two groups (blocked, in_progress) where blocked.count + in_progress.count = items.length.
*
* **Validates: Requirements 5.2, 5.3**
*/
it('two groups sum to total', () => {
fc.assert(
fc.property(
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
(items) => {
const result = categorizeNonCompliant(items);
expect(result.blocked.count + result.in_progress.count).toBe(items.length);
if (items.length > 0) {
expect(result.blocked.pct).toBe(Math.round((result.blocked.count / items.length) * 100));
expect(result.in_progress.pct).toBe(Math.round((result.in_progress.count / items.length) * 100));
} else {
expect(result.blocked.pct).toBe(0);
expect(result.in_progress.pct).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 7: Heavy Hitters Descending Sort ---
describe('Feature: vcl-compliance-reporting, Property 7: Heavy Hitters Descending Sort', () => {
/**
* For any array of vertical objects, rankHeavyHitters returns the array sorted
* in non-increasing order by non_compliant.
*
* **Validates: Requirements 6.1, 6.3**
*/
it('sorted non-increasing by non_compliant', () => {
fc.assert(
fc.property(
fc.array(verticalArb, { minLength: 0, maxLength: 30 }),
(verticals) => {
const result = rankHeavyHitters(verticals);
expect(result.length).toBe(verticals.length);
for (let i = 1; i < result.length; i++) {
expect(result[i - 1].non_compliant).toBeGreaterThanOrEqual(result[i].non_compliant);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 8: Forecasted Burndown Projection ---
describe('Feature: vcl-compliance-reporting, Property 8: Forecasted Burndown Projection', () => {
/**
* For any set of non-compliant devices with resolution_date values,
* computeForecastBurndown produces monthly buckets where the sum of all
* monthly forecast counts equals the number of items with non-null resolution_dates.
*
* **Validates: Requirements 7.5**
*/
it('bucket sum = count of items with non-null resolution_dates', () => {
fc.assert(
fc.property(
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
(items) => {
const buckets = computeForecastBurndown(items);
const bucketSum = Object.values(buckets).reduce((sum, count) => sum + count, 0);
const itemsWithDate = items.filter(i => i.resolution_date != null).length;
expect(bucketSum).toBe(itemsWithDate);
}
),
{ numRuns: 100 }
);
});
});
// --- Property 9: Hostname Matching with Unmatched Flagging ---
describe('Feature: vcl-compliance-reporting, Property 9: Hostname Matching with Unmatched Flagging', () => {
/**
* For any array of uploaded rows and a set of existing hostnames,
* matchByHostname produces matched + unmatched = total, and matched hostnames
* all exist in the set.
*
* **Validates: Requirements 8.2, 8.7**
*/
it('matched + unmatched = total, matched hostnames in set', () => {
fc.assert(
fc.property(
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 30 }),
fc.array(hostnameArb, { minLength: 0, maxLength: 20 }),
(rows, existingList) => {
const existingSet = new Set(existingList);
const { matched, unmatched } = matchByHostname(rows, existingSet);
expect(matched.length + unmatched.length).toBe(rows.length);
for (const row of matched) {
expect(existingSet.has(row.hostname)).toBe(true);
}
for (const row of unmatched) {
expect(existingSet.has(row.hostname)).toBe(false);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 10: Bulk Diff Change Detection ---
describe('Feature: vcl-compliance-reporting, Property 10: Bulk Diff Change Detection', () => {
/**
* For any array of matched row pairs, computeBulkDiff flags a row as "changed"
* if and only if at least one field value differs.
*
* **Validates: Requirements 8.3, 8.4**
*/
it('changed iff at least one field differs', () => {
const fieldValueArb = fc.oneof(fc.constant(null), fc.string({ minLength: 1, maxLength: 20 }));
// When uploaded values match current data exactly, status should be 'unchanged'
fc.assert(
fc.property(
fc.array(hostnameArb, { minLength: 1, maxLength: 20 }).chain(hostnames => {
// Ensure unique hostnames to avoid map overwrite issues
const uniqueHostnames = [...new Set(hostnames)];
return fc.tuple(
...uniqueHostnames.map(h =>
fc.record({
hostname: fc.constant(h),
resolution_date: fieldValueArb,
remediation_plan: fieldValueArb,
notes: fieldValueArb,
})
)
);
}),
(matchedRows) => {
// Build currentData with same values as uploaded
const currentData = new Map();
for (const row of matchedRows) {
currentData.set(row.hostname, {
resolution_date: row.resolution_date,
remediation_plan: row.remediation_plan,
notes: row.notes,
});
}
const results = computeBulkDiff(matchedRows, currentData);
for (const r of results) {
expect(r.status).toBe('unchanged');
}
}
),
{ numRuns: 50 }
);
// When at least one field differs, status should be 'changed'
fc.assert(
fc.property(
hostnameArb,
fc.string({ minLength: 1, maxLength: 20 }),
fc.string({ minLength: 1, maxLength: 20 }),
(hostname, oldVal, newVal) => {
fc.pre(oldVal !== newVal);
const matchedRows = [{ hostname, resolution_date: newVal }];
const currentData = new Map();
currentData.set(hostname, { resolution_date: oldVal, remediation_plan: null, notes: null });
const results = computeBulkDiff(matchedRows, currentData);
expect(results[0].status).toBe('changed');
}
),
{ numRuns: 50 }
);
});
});
// --- Property 11: Column Header Mapping ---
describe('Feature: vcl-compliance-reporting, Property 11: Column Header Mapping', () => {
/**
* mapColumnHeaders correctly identifies known columns case-insensitively.
*
* **Validates: Requirements 9.2**
*/
it('identifies known columns case-insensitively', () => {
const knownHeaders = ['Hostname', 'Resolution Date', 'Remediation Plan', 'Notes',
'hostname', 'resolution_date', 'remediation_plan', 'notes',
'HOSTNAME', 'RESOLUTION DATE', 'REMEDIATION PLAN', 'NOTES'];
const caseVariantArb = fc.constantFrom(...knownHeaders);
const unknownHeaderArb = fc.stringMatching(/^[a-z]{5,10}$/).filter(
s => !['hostname', 'notes'].includes(s.toLowerCase())
);
fc.assert(
fc.property(
fc.array(fc.oneof(caseVariantArb, unknownHeaderArb), { minLength: 1, maxLength: 10 }),
(headers) => {
const mapping = mapColumnHeaders(headers);
// Every mapped key should be a known field
const validKeys = new Set(['hostname', 'resolution_date', 'remediation_plan', 'notes']);
for (const key of Object.keys(mapping)) {
expect(validKeys.has(key)).toBe(true);
}
// Check that known headers are mapped correctly
for (let i = 0; i < headers.length; i++) {
const normalized = headers[i].trim().toLowerCase();
if (normalized === 'hostname') {
expect(mapping.hostname).toBeDefined();
}
if (normalized === 'resolution date' || normalized === 'resolution_date') {
expect(mapping.resolution_date).toBeDefined();
}
if (normalized === 'remediation plan' || normalized === 'remediation_plan') {
expect(mapping.remediation_plan).toBeDefined();
}
if (normalized === 'notes') {
expect(mapping.notes).toBeDefined();
}
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 12: Date String Validation ---
describe('Feature: vcl-compliance-reporting, Property 12: Date String Validation', () => {
/**
* isValidDateString rejects invalid calendar dates and non-date strings.
* Returns true only for valid YYYY-MM-DD dates.
*
* **Validates: Requirements 9.3**
*/
it('rejects invalid dates and non-date strings', () => {
// Valid dates should return true
fc.assert(
fc.property(validDateArb, (dateStr) => {
expect(isValidDateString(dateStr)).toBe(true);
}),
{ numRuns: 50 }
);
// Invalid dates should return false
const invalidDateArb = fc.oneof(
fc.constant(null),
fc.constant(''),
fc.constant('not-a-date'),
fc.constant('2026-02-30'),
fc.constant('2026-13-01'),
fc.constant('2026-00-15'),
fc.constant('abcd-ef-gh'),
fc.integer().map(n => String(n)),
fc.string({ minLength: 1, maxLength: 5 }),
);
fc.assert(
fc.property(invalidDateArb, (val) => {
expect(isValidDateString(val)).toBe(false);
}),
{ numRuns: 50 }
);
});
});
// --- Property 13: Row Count Arithmetic Invariant ---
describe('Feature: vcl-compliance-reporting, Property 13: Row Count Arithmetic (matched + unmatched = total)', () => {
/**
* For any bulk upload, matched + unmatched = total input rows.
*
* **Validates: Requirements 9.6**
*/
it('matched + unmatched = total invariant holds', () => {
fc.assert(
fc.property(
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 50 }),
fc.array(hostnameArb, { minLength: 0, maxLength: 30 }),
(rows, existingList) => {
const existingSet = new Set(existingList);
const { matched, unmatched } = matchByHostname(rows, existingSet);
// Core invariant: matched + unmatched = total
expect(matched.length + unmatched.length).toBe(rows.length);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,316 @@
/**
* Unit and Integration Tests: VCL Compliance Reporting
*
* Feature: vcl-compliance-reporting
*
* Tests cover:
* - PATCH /items/:hostname/metadata (happy path, invalid date, plan too long, not found)
* - GET /vcl/stats with no data (zero/empty response)
* - Bulk preview with all unmatched hostnames
* - Bulk preview with mixed valid/invalid rows
* - Integration test for full bulk flow (preview → commit)
* - Trend endpoint with < 2 months (no forecast)
*/
const http = require('http');
const express = require('express');
// Mock auth middleware to bypass real session checks
jest.mock('../middleware/auth', () => ({
requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' };
next();
},
requireGroup: () => (req, res, next) => next(),
}));
// Mock audit log as a no-op
jest.mock('../helpers/auditLog', () => jest.fn());
// Mock ivantiApi to avoid real network calls
jest.mock('../helpers/ivantiApi', () => ({
ivantiFormPost: jest.fn(),
ivantiPost: jest.fn(),
}));
// Mock the db pool
const mockPool = {
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
connect: jest.fn(() => Promise.resolve({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
release: jest.fn(),
})),
};
jest.mock('../db', () => mockPool);
// Mock driftChecker to avoid file system dependencies
jest.mock('../helpers/driftChecker', () => ({
loadConfig: jest.fn(() => ({})),
compareSchemaToDrift: jest.fn(() => null),
reconcileConfig: jest.fn(() => ({ changes: [] })),
}));
const { createComplianceRouter } = require('../routes/compliance');
// --- HTTP helper ---
function request(server, method, path, body) {
return new Promise((resolve, reject) => {
const addr = server.address();
const options = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const rawBody = Buffer.concat(chunks).toString();
let json;
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
resolve({ statusCode: res.statusCode, body: json });
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// --- Setup ---
let app, server;
beforeAll((done) => {
app = express();
app.use(express.json());
// Mock multer upload middleware
const mockUpload = { single: () => (req, res, next) => next() };
const router = createComplianceRouter(mockUpload);
app.use('/api/compliance', router);
server = app.listen(0, '127.0.0.1', done);
});
afterAll((done) => {
server.close(done);
});
beforeEach(() => {
mockPool.query.mockReset();
mockPool.connect.mockReset();
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
});
// --- 18.1: PATCH /items/:hostname/metadata ---
describe('PATCH /items/:hostname/metadata', () => {
it('happy path — updates resolution_date and remediation_plan', async () => {
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 2 });
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
resolution_date: '2026-06-15',
remediation_plan: 'Patch in next maintenance window',
});
expect(res.statusCode).toBe(200);
expect(res.body.updated).toBe(2);
});
it('returns 400 for invalid date format', async () => {
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
resolution_date: 'not-a-date',
});
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('Invalid resolution_date format');
});
it('returns 400 when remediation plan exceeds 2000 characters', async () => {
const longPlan = 'x'.repeat(2001);
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
remediation_plan: longPlan,
});
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('2000 characters');
});
it('returns 404 when hostname not found', async () => {
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
resolution_date: '2026-06-15',
});
expect(res.statusCode).toBe(404);
expect(res.body.error).toBe('Device not found');
});
});
// --- 18.2: GET /vcl/stats with no data ---
describe('GET /vcl/stats with no data', () => {
it('returns zero/empty response when no compliance data exists', async () => {
// First query: active items
mockPool.query.mockResolvedValueOnce({ rows: [] });
// Second query: latest upload
mockPool.query.mockResolvedValueOnce({ rows: [] });
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
expect(res.statusCode).toBe(200);
expect(res.body.stats).toBeDefined();
expect(res.body.stats.total).toBe(0);
expect(res.body.stats.in_scope).toBe(0);
expect(res.body.stats.compliant).toBe(0);
expect(res.body.stats.non_compliant).toBe(0);
expect(res.body.stats.compliance_pct).toBe(0);
expect(res.body.donut).toBeDefined();
expect(res.body.heavy_hitters).toEqual([]);
expect(res.body.vertical_breakdown).toEqual([]);
});
});
// --- 18.3: Bulk preview with all unmatched hostnames ---
describe('POST /vcl/bulk-preview — all unmatched', () => {
it('returns all rows as unmatched when no hostnames exist in DB', async () => {
// Query for existing hostnames returns empty
mockPool.query.mockResolvedValueOnce({ rows: [] });
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
rows: [
{ hostname: 'unknown-1', resolution_date: '2026-06-15' },
{ hostname: 'unknown-2', resolution_date: '2026-07-01' },
{ hostname: 'unknown-3', resolution_date: '2026-08-01' },
],
});
expect(res.statusCode).toBe(200);
expect(res.body.matched).toBe(0);
expect(res.body.unmatched).toBe(3);
expect(res.body.changes).toBe(0);
expect(res.body.unmatched_rows).toEqual(['unknown-1', 'unknown-2', 'unknown-3']);
});
});
// --- 18.4: Bulk preview with mixed valid/invalid rows ---
describe('POST /vcl/bulk-preview — mixed valid/invalid', () => {
it('correctly classifies valid and invalid rows', async () => {
// Query for existing hostnames
mockPool.query
.mockResolvedValueOnce({
rows: [
{ hostname: 'srv-001' },
{ hostname: 'srv-002' },
{ hostname: 'srv-003' },
],
})
// Query for current data (DISTINCT ON)
.mockResolvedValueOnce({
rows: [
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
{ hostname: 'srv-003', resolution_date: null, remediation_plan: null },
],
});
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
rows: [
{ hostname: 'srv-001', resolution_date: '2026-06-15' }, // valid, matched
{ hostname: 'srv-002', resolution_date: 'bad-date' }, // invalid date, matched
{ hostname: 'srv-003', resolution_date: '2026-07-01' }, // valid, matched
{ hostname: 'unknown-1', resolution_date: '2026-08-01' }, // unmatched
],
});
expect(res.statusCode).toBe(200);
expect(res.body.matched).toBe(3);
expect(res.body.unmatched).toBe(1);
expect(res.body.invalid).toBe(1);
expect(res.body.invalid_rows[0].hostname).toBe('srv-002');
expect(res.body.invalid_rows[0].errors[0]).toContain('invalid date');
expect(res.body.unmatched_rows).toEqual(['unknown-1']);
});
});
// --- 18.5: Integration test for full bulk flow ---
describe('Integration: full bulk upload flow (preview → commit)', () => {
it('preview shows changes, commit updates DB', async () => {
// --- Preview phase ---
// Query for existing hostnames
mockPool.query
.mockResolvedValueOnce({
rows: [{ hostname: 'srv-001' }, { hostname: 'srv-002' }],
})
// Query for current data
.mockResolvedValueOnce({
rows: [
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' },
],
});
const previewRes = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
rows: [
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' }, // unchanged
],
});
expect(previewRes.statusCode).toBe(200);
expect(previewRes.body.matched).toBe(2);
expect(previewRes.body.changes).toBe(1); // only srv-001 changed
// --- Commit phase ---
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
mockClient.query
.mockResolvedValueOnce({}) // BEGIN
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
.mockResolvedValueOnce({}); // COMMIT
mockPool.connect.mockResolvedValueOnce(mockClient);
const commitRes = await request(server, 'POST', '/api/compliance/vcl/bulk-commit', {
changes: [
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
],
});
expect(commitRes.statusCode).toBe(200);
expect(commitRes.body.committed).toBe(1);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
});
// --- 18.6: Trend endpoint with < 2 months (no forecast) ---
describe('GET /vcl/trend — fewer than 2 months', () => {
it('returns data without forecast when < 2 months exist', async () => {
mockPool.query.mockResolvedValueOnce({
rows: [
{ snapshot_month: '2026-01', compliant_count: 900, compliance_pct: '82.0' },
],
});
const res = await request(server, 'GET', '/api/compliance/vcl/trend');
expect(res.statusCode).toBe(200);
expect(res.body.months).toHaveLength(1);
expect(res.body.months[0].month).toBe('2026-01');
expect(res.body.months[0].forecast_pct).toBeNull();
expect(res.body.months[0].target_pct).toBe(95);
});
});

View 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,
};

View File

@@ -0,0 +1,38 @@
// Migration: Add VCL reporting columns to compliance_items and create compliance_snapshots table
const pool = require('../db');
async function run() {
console.log('Starting VCL reporting migration...');
try {
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS resolution_date DATE DEFAULT NULL`);
console.log('✓ resolution_date column added (or already exists)');
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS remediation_plan TEXT DEFAULT NULL`);
console.log('✓ remediation_plan column added (or already exists)');
await pool.query(`
CREATE TABLE IF NOT EXISTS compliance_snapshots (
id SERIAL PRIMARY KEY,
snapshot_month TEXT NOT NULL,
vertical TEXT NOT NULL,
total_devices INTEGER NOT NULL DEFAULT 0,
compliant INTEGER NOT NULL DEFAULT 0,
non_compliant INTEGER NOT NULL DEFAULT 0,
compliance_pct NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(snapshot_month, vertical)
)
`);
console.log('✓ compliance_snapshots table created (or already exists)');
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_snapshots_month ON compliance_snapshots(snapshot_month)`);
console.log('✓ idx_compliance_snapshots_month index 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

@@ -16,6 +16,7 @@ const MIGRATIONS_DIR = __dirname;
const POSTGRES_MIGRATIONS = [ 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',
]; ];
async function runAll() { async function runAll() {

View File

@@ -9,6 +9,7 @@ const { spawn } = require('child_process');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker'); const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py'); const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
@@ -147,6 +148,39 @@ async function persistUpload({ items, summary, reportDate, filename, userId }) {
); );
await client.query('COMMIT'); await client.query('COMMIT');
// Task 7: Create/update compliance_snapshots for the current month
try {
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
// Compute per-vertical compliance percentages from current state
const { rows: verticalStats } = await pool.query(
`SELECT team AS vertical,
COUNT(DISTINCT hostname)::int AS total_devices,
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END)::int AS compliant,
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
FROM compliance_items
WHERE team IS NOT NULL
GROUP BY team`
);
for (const vs of verticalStats) {
const total = vs.total_devices;
const compPct = total > 0 ? Math.round((vs.compliant / total) * 100 * 100) / 100 : 0;
await pool.query(
`INSERT INTO compliance_snapshots (snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (snapshot_month, vertical)
DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`,
[currentMonth, vs.vertical, total, vs.compliant, vs.non_compliant, compPct]
);
}
} catch (snapshotErr) {
// Snapshot creation is non-critical — log but don't fail the upload
console.error('[Compliance] Snapshot creation error:', snapshotErr.message);
}
return { uploadId, newCount, recurringCount, resolvedCount }; return { uploadId, newCount, recurringCount, resolvedCount };
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -669,6 +703,425 @@ function createComplianceRouter(upload) {
} }
}); });
// -----------------------------------------------------------------------
// PATCH /items/:hostname/metadata — Update resolution_date / remediation_plan
// -----------------------------------------------------------------------
router.patch('/items/:hostname/metadata', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const hostname = req.params.hostname;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
const { resolution_date, remediation_plan } = req.body;
// Validate resolution_date: must be a valid ISO date string or null
if (resolution_date !== undefined && resolution_date !== null) {
if (!isValidDateString(resolution_date)) {
return res.status(400).json({ error: 'Invalid resolution_date format' });
}
}
// Validate remediation_plan: must be <= 2000 chars or null
if (remediation_plan !== undefined && remediation_plan !== null) {
const planValidation = validateRemediationPlan(remediation_plan);
if (!planValidation.valid) {
return res.status(400).json({ error: planValidation.error });
}
}
try {
// Build dynamic SET clause for provided fields only
const setClauses = [];
const values = [];
let paramIdx = 1;
if (resolution_date !== undefined) {
setClauses.push(`resolution_date = $${paramIdx++}`);
values.push(resolution_date);
}
if (remediation_plan !== undefined) {
setClauses.push(`remediation_plan = $${paramIdx++}`);
values.push(remediation_plan);
}
if (setClauses.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
values.push(hostname);
const result = await pool.query(
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
values
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Device not found' });
}
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'compliance_metadata_update',
entityType: 'compliance_item',
entityId: hostname,
details: { resolution_date, remediation_plan },
ipAddress: req.ip,
});
res.json({ updated: result.rowCount });
} catch (err) {
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
res.status(500).json({ error: 'Failed to update device metadata' });
}
});
// -----------------------------------------------------------------------
// GET /vcl/stats — VCL executive summary statistics
// -----------------------------------------------------------------------
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
router.get('/vcl/stats', async (req, res) => {
try {
// Fetch all active compliance items
const { rows: items } = await pool.query(
`SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant,
true AS in_scope
FROM compliance_items WHERE status = 'active'`
);
// For stats computation, all active items are non-compliant (they are findings)
// We need total in-scope devices (active + resolved from latest upload)
const { rows: latestUploadRows } = await pool.query(
`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1`
);
let allDeviceItems = [];
if (latestUploadRows.length > 0) {
const { rows: allItems } = await pool.query(
`SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant,
true AS in_scope
FROM compliance_items`
);
// 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 items by resolution_date presence
const nonCompliantItems = items.filter(i => i.status === 'active');
const donut = categorizeNonCompliant(nonCompliantItems);
// Heavy hitters: group by team, count non-compliant per team
const teamCounts = {};
for (const item of nonCompliantItems) {
const team = item.team || 'Unknown';
if (!teamCounts[team]) {
teamCounts[team] = { vertical: team, team: team, non_compliant: 0, compliance_date: null, notes: '' };
}
teamCounts[team].non_compliant++;
// Use the latest resolution_date as the team's compliance_date
if (item.resolution_date && (!teamCounts[team].compliance_date || item.resolution_date > teamCounts[team].compliance_date)) {
teamCounts[team].compliance_date = item.resolution_date;
}
}
const heavy_hitters = rankHeavyHitters(Object.values(teamCounts));
// Vertical breakdown with burndown
const verticalBreakdown = [];
for (const team of Object.keys(teamCounts)) {
const teamItems = nonCompliantItems.filter(i => (i.team || 'Unknown') === team);
const teamAllDevices = allDeviceItems.filter(i => (i.team || 'Unknown') === team);
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));
const forecast_burndown = computeForecastBurndown(teamItems.filter(i => i.resolution_date));
const blockers = teamItems.filter(i => !i.resolution_date).length;
verticalBreakdown.push({
vertical: team,
compliance_pct,
team: team,
non_compliant: teamItems.length,
actual_burndown,
forecast_burndown,
blockers,
risk_acceptances: 0,
notes: '',
});
}
res.json({ stats, donut, heavy_hitters, vertical_breakdown: verticalBreakdown });
} catch (err) {
console.error('[Compliance] GET /vcl/stats error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /vcl/trend — Monthly compliance trend with forecast
// -----------------------------------------------------------------------
router.get('/vcl/trend', async (req, res) => {
try {
const { rows: snapshots } = await pool.query(
`SELECT snapshot_month, SUM(compliant)::int AS compliant_count,
CASE WHEN SUM(total_devices) > 0
THEN ROUND((SUM(compliant)::numeric / SUM(total_devices)::numeric) * 100, 1)
ELSE 0 END AS compliance_pct
FROM compliance_snapshots
GROUP BY snapshot_month
ORDER BY snapshot_month ASC`
);
// Build months array with actuals
const months = snapshots.map(s => ({
month: s.snapshot_month,
compliant_count: s.compliant_count,
compliance_pct: parseFloat(s.compliance_pct),
forecast_pct: null,
target_pct: VCL_TARGET_PCT,
}));
// Compute forecast using linear regression if we have 3+ months
if (months.length >= 3) {
const n = months.length;
// Use last data points for regression
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += months[i].compliance_pct;
sumXY += i * months[i].compliance_pct;
sumX2 += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// Project forward 3 months
for (let i = 0; i < 3; i++) {
const futureIdx = n + i;
const forecastPct = Math.min(100, Math.max(0, Math.round((slope * futureIdx + intercept) * 10) / 10));
// Compute the future month string
const lastMonth = months[months.length - 1].month;
const [year, mon] = lastMonth.split('-').map(Number);
const futureDate = new Date(year, mon - 1 + i + 1, 1);
const futureMonth = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, '0')}`;
months.push({
month: futureMonth,
compliant_count: null,
compliance_pct: null,
forecast_pct: forecastPct,
target_pct: VCL_TARGET_PCT,
});
}
// Also add forecast_pct to the last actual month as the starting point
if (months.length > 0 && n > 0) {
months[n - 1].forecast_pct = months[n - 1].compliance_pct;
}
}
res.json({ months });
} catch (err) {
console.error('[Compliance] GET /vcl/trend error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// POST /vcl/bulk-preview — Bulk upload diff preview
// -----------------------------------------------------------------------
router.post('/vcl/bulk-preview', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { rows, headers } = req.body;
// Validate: require rows array
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ error: 'rows array is required' });
}
// Enforce 2000 row limit
if (rows.length === 0) {
return res.status(400).json({ error: 'File contains no data rows' });
}
if (rows.length > 2000) {
return res.status(400).json({ error: 'File exceeds maximum of 2000 rows' });
}
// Map column headers if provided
let columnMapping = {};
if (headers && Array.isArray(headers)) {
columnMapping = mapColumnHeaders(headers);
}
// Require hostname field
const hasHostname = rows.every(r => r.hostname != null && r.hostname !== '');
if (!hasHostname) {
return res.status(400).json({ error: 'File must contain a Hostname column' });
}
// Check for updatable fields (resolution_date, remediation_plan, or notes)
const sampleRow = rows[0] || {};
const updatableFields = ['resolution_date', 'remediation_plan', 'notes'];
const hasUpdatableFields = updatableFields.some(f => f in sampleRow);
if (!hasUpdatableFields && headers) {
// Check via column mapping
const mappedFields = Object.keys(columnMapping).filter(k => k !== 'hostname');
if (mappedFields.length === 0) {
return res.status(400).json({ error: 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' });
}
} else if (!hasUpdatableFields && !headers) {
return res.status(400).json({ error: 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' });
}
try {
// Get existing hostnames from DB
const { rows: existingRows } = await pool.query(
`SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active'`
);
const existingHostnames = new Set(existingRows.map(r => r.hostname));
// Match by hostname
const { matched, unmatched } = matchByHostname(rows, existingHostnames);
// Validate fields on matched rows
const validRows = [];
const invalidRows = [];
for (const row of matched) {
const errors = [];
if (row.resolution_date !== undefined && row.resolution_date !== null && row.resolution_date !== '') {
if (!isValidDateString(row.resolution_date)) {
errors.push('resolution_date: invalid date format');
}
}
if (row.remediation_plan !== undefined && row.remediation_plan !== null) {
const planCheck = validateRemediationPlan(row.remediation_plan);
if (!planCheck.valid) {
errors.push('remediation_plan: ' + planCheck.error);
}
}
if (errors.length > 0) {
invalidRows.push({ hostname: row.hostname, errors });
} else {
validRows.push(row);
}
}
// Get current data for diff computation
const { rows: currentRows } = await pool.query(
`SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
FROM compliance_items WHERE status = 'active' AND hostname = ANY($1)
ORDER BY hostname, id DESC`,
[validRows.map(r => r.hostname)]
);
const currentData = new Map();
for (const row of currentRows) {
currentData.set(row.hostname, {
resolution_date: row.resolution_date ? row.resolution_date.toISOString?.().slice(0, 10) || String(row.resolution_date).slice(0, 10) : null,
remediation_plan: row.remediation_plan || null,
notes: null,
});
}
// Compute diff
const diffResults = computeBulkDiff(validRows, currentData);
const changedRows = diffResults.filter(r => r.status === 'changed');
res.json({
matched: matched.length,
unmatched: unmatched.length,
changes: changedRows.length,
invalid: invalidRows.length,
details: diffResults,
unmatched_rows: unmatched.map(r => r.hostname),
invalid_rows: invalidRows,
});
} catch (err) {
console.error('[Compliance] POST /vcl/bulk-preview error:', err.message);
res.status(500).json({ error: 'Failed to process bulk preview' });
}
});
// -----------------------------------------------------------------------
// POST /vcl/bulk-commit — Commit validated bulk changes
// -----------------------------------------------------------------------
router.post('/vcl/bulk-commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { changes } = req.body;
if (!changes || !Array.isArray(changes) || changes.length === 0) {
return res.status(400).json({ error: 'changes array is required' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
let committedCount = 0;
for (const change of changes) {
const setClauses = [];
const values = [];
let paramIdx = 1;
if (change.resolution_date !== undefined) {
setClauses.push(`resolution_date = $${paramIdx++}`);
values.push(change.resolution_date);
}
if (change.remediation_plan !== undefined) {
setClauses.push(`remediation_plan = $${paramIdx++}`);
values.push(change.remediation_plan);
}
if (change.notes !== undefined) {
// Notes are stored separately in compliance_notes, but we can update a field if it exists
// For now, skip notes in the direct update
}
if (setClauses.length === 0) continue;
values.push(change.hostname);
const result = await client.query(
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
values
);
if (result.rowCount > 0) committedCount++;
}
await client.query('COMMIT');
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'compliance_bulk_update',
entityType: 'compliance_items',
entityId: null,
details: { rows_updated: committedCount, total_changes: changes.length },
ipAddress: req.ip,
});
res.json({ committed: committedCount });
} catch (err) {
await client.query('ROLLBACK');
console.error('[Compliance] POST /vcl/bulk-commit error:', err.message);
res.status(500).json({ error: 'Failed to commit changes' });
} finally {
client.release();
}
});
return router; return router;
} }

View File

@@ -0,0 +1,463 @@
import React, { useState, useRef } from 'react';
import { X, Upload, AlertCircle, Loader, CheckCircle, FileSpreadsheet } from 'lucide-react';
import * as XLSX from 'xlsx';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const RED = '#EF4444';
const AMBER = '#F59E0B';
const EMERALD = '#10B981';
function mapColumnHeaders(headers) {
const mapping = {};
for (const h of headers) {
const lower = h.toLowerCase().trim();
if (lower === 'hostname') mapping.hostname = h;
else if (lower === 'resolution date' || lower === 'resolution_date') mapping.resolution_date = h;
else if (lower === 'remediation plan' || lower === 'remediation_plan') mapping.remediation_plan = h;
else if (lower === 'notes') mapping.notes = h;
}
return mapping;
}
function isValidDateString(str) {
if (!str || typeof str !== 'string' || str.trim() === '') return false;
const d = new Date(str);
if (isNaN(d.getTime())) return false;
// Check it's a real date by comparing parts
const parts = str.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!parts) return false;
const [, y, m, day] = parts;
return d.getFullYear() === parseInt(y) && (d.getMonth() + 1) === parseInt(m) && d.getDate() === parseInt(day);
}
export default function BulkUploadModal({ onClose }) {
const fileRef = useRef(null);
const [step, setStep] = useState('upload'); // upload, preview, committing, done
const [parsedRows, setParsedRows] = useState([]);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [commitLoading, setCommitLoading] = useState(false);
const [error, setError] = useState(null);
const [commitResult, setCommitResult] = useState(null);
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
setError(null);
try {
const data = await file.arrayBuffer();
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(sheet, { defval: '' });
if (jsonData.length === 0) {
setError('File contains no data rows');
return;
}
const headers = Object.keys(jsonData[0]);
const colMap = mapColumnHeaders(headers);
if (!colMap.hostname) {
setError('File must contain a Hostname column');
return;
}
const hasUpdatableFields = colMap.resolution_date || colMap.remediation_plan || colMap.notes;
if (!hasUpdatableFields) {
setError('No updatable fields found (need Resolution Date, Remediation Plan, or Notes)');
return;
}
// Build rows for API
const rows = jsonData.map(row => {
const mapped = { hostname: String(row[colMap.hostname] || '').trim() };
if (colMap.resolution_date) {
const val = String(row[colMap.resolution_date] || '').trim();
mapped.resolution_date = val || null;
}
if (colMap.remediation_plan) {
const val = String(row[colMap.remediation_plan] || '').trim();
mapped.remediation_plan = val || null;
}
if (colMap.notes) {
const val = String(row[colMap.notes] || '').trim();
mapped.notes = val || null;
}
return mapped;
}).filter(r => r.hostname);
// Client-side validation
const validatedRows = rows.map(row => {
const errors = [];
if (row.resolution_date && !isValidDateString(row.resolution_date)) {
errors.push('Invalid date format for Resolution Date');
}
if (row.remediation_plan && row.remediation_plan.length > 2000) {
errors.push('Remediation Plan exceeds 2000 characters');
}
return { ...row, _clientErrors: errors };
});
setParsedRows(validatedRows);
// Call bulk-preview API
setPreviewLoading(true);
setStep('preview');
const res = await fetch(`${API_BASE}/compliance/vcl/bulk-preview`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows: validatedRows.map(({ _clientErrors, ...r }) => r) }),
});
const previewData = await res.json();
if (!res.ok) throw new Error(previewData.error || 'Preview failed');
setPreview(previewData);
} catch (err) {
setError(err.message);
setStep('upload');
} finally {
setPreviewLoading(false);
}
};
const handleCommit = async () => {
if (!preview || !preview.details) return;
setCommitLoading(true);
setError(null);
try {
const changes = preview.details
.filter(d => d.status === 'changed')
.map(d => {
const change = { hostname: d.hostname };
if (d.fields.resolution_date) change.resolution_date = d.fields.resolution_date.new;
if (d.fields.remediation_plan) change.remediation_plan = d.fields.remediation_plan.new;
if (d.fields.notes) change.notes = d.fields.notes.new;
return change;
});
const res = await fetch(`${API_BASE}/compliance/vcl/bulk-commit`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Commit failed');
setCommitResult(data);
setStep('done');
} catch (err) {
setError(err.message);
} finally {
setCommitLoading(false);
}
};
const handleCancel = () => {
setParsedRows([]);
setPreview(null);
setError(null);
onClose();
};
return (
<>
{/* Backdrop */}
<div onClick={handleCancel} style={{
position: 'fixed', inset: 0, background: 'rgba(10,14,39,0.92)',
backdropFilter: 'blur(6px)', zIndex: 60,
}} />
{/* Modal */}
<div style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
width: '90%', maxWidth: '720px', maxHeight: '85vh',
background: 'linear-gradient(135deg, rgba(30,41,59,0.99) 0%, rgba(15,23,42,1) 100%)',
border: `1px solid ${TEAL}30`,
borderRadius: '0.75rem',
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
zIndex: 61,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '1.25rem 1.5rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileSpreadsheet style={{ width: '18px', height: '18px', color: TEAL }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Bulk Upload
</span>
</div>
<button onClick={handleCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem' }}>
{/* Error display */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1rem', marginBottom: '1rem',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem',
color: '#F87171', fontSize: '0.8rem', fontFamily: 'monospace',
}}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
{error}
</div>
)}
{/* Step: Upload */}
{step === 'upload' && (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{
border: '2px dashed rgba(20,184,166,0.3)',
borderRadius: '0.75rem',
padding: '3rem 2rem',
cursor: 'pointer',
transition: 'all 0.15s',
}}
onClick={() => fileRef.current?.click()}
onMouseEnter={e => e.currentTarget.style.borderColor = TEAL}
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(20,184,166,0.3)'}
>
<Upload style={{ width: '32px', height: '32px', color: TEAL, margin: '0 auto 1rem' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.85rem', color: '#CBD5E1', marginBottom: '0.5rem' }}>
Click to select an .xlsx file
</div>
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
Columns: Hostname (required), Resolution Date, Remediation Plan, Notes
</div>
</div>
<input
ref={fileRef}
type="file"
accept=".xlsx"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
)}
{/* Step: Preview loading */}
{step === 'preview' && previewLoading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
<span style={{ marginLeft: '0.75rem', fontFamily: 'monospace', fontSize: '0.8rem', color: '#94A3B8' }}>
Analyzing changes
</span>
</div>
)}
{/* Step: Preview results */}
{step === 'preview' && !previewLoading && preview && (
<div>
{/* Summary counts */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
<SummaryBadge label="Matched" value={preview.matched} color={EMERALD} />
<SummaryBadge label="Unmatched" value={preview.unmatched} color={AMBER} />
<SummaryBadge label="Changes" value={preview.changes} color={TEAL} />
<SummaryBadge label="Invalid" value={preview.invalid} color={RED} />
</div>
{/* Changed rows */}
{preview.details && preview.details.filter(d => d.status === 'changed').length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#475569', marginBottom: '0.5rem' }}>
Changed Rows
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '0.375rem' }}>
{preview.details.filter(d => d.status === 'changed').map((row, i) => (
<div key={i} style={{
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
background: `${TEAL}05`,
}}>
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#E2E8F0', marginBottom: '0.25rem' }}>
{row.hostname}
</div>
{row.fields && Object.entries(row.fields).map(([field, vals]) => (
<div key={field} style={{ display: 'flex', gap: '0.5rem', fontSize: '0.68rem', fontFamily: 'monospace', marginLeft: '0.5rem' }}>
<span style={{ color: '#64748B', minWidth: '100px' }}>{field}:</span>
<span style={{ color: '#F87171', textDecoration: 'line-through' }}>{vals.old || '(empty)'}</span>
<span style={{ color: '#475569' }}></span>
<span style={{ color: EMERALD }}>{vals.new || '(empty)'}</span>
</div>
))}
</div>
))}
</div>
</div>
)}
{/* Unmatched rows */}
{preview.unmatched_rows && preview.unmatched_rows.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: AMBER, marginBottom: '0.5rem' }}>
Unmatched Hostnames ({preview.unmatched_rows.length})
</div>
<div style={{
maxHeight: '100px', overflowY: 'auto',
padding: '0.5rem 0.75rem',
background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.2)',
borderRadius: '0.375rem',
fontFamily: 'monospace', fontSize: '0.7rem', color: '#94A3B8',
}}>
{preview.unmatched_rows.join(', ')}
</div>
</div>
)}
{/* Invalid rows */}
{preview.invalid_rows && preview.invalid_rows.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: RED, marginBottom: '0.5rem' }}>
Invalid Rows ({preview.invalid_rows.length})
</div>
<div style={{
maxHeight: '100px', overflowY: 'auto',
padding: '0.5rem 0.75rem',
background: 'rgba(239,68,68,0.05)',
border: '1px solid rgba(239,68,68,0.2)',
borderRadius: '0.375rem',
}}>
{preview.invalid_rows.map((row, i) => (
<div key={i} style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#F87171', marginBottom: '0.25rem' }}>
{row.hostname}: {(row.errors || []).join('; ')}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step: Done */}
{step === 'done' && commitResult && (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<CheckCircle style={{ width: '40px', height: '40px', color: EMERALD, margin: '0 auto 1rem' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', color: '#E2E8F0', marginBottom: '0.5rem' }}>
Changes Committed
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', color: '#94A3B8' }}>
{commitResult.committed} device(s) updated
</div>
</div>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', justifyContent: 'flex-end', gap: '0.75rem',
padding: '1rem 1.5rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}>
{step === 'preview' && !previewLoading && preview && (
<>
<button onClick={handleCancel} style={{
padding: '0.5rem 1.25rem',
background: 'transparent',
border: '1px solid rgba(100,116,139,0.4)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontFamily: 'monospace', fontSize: '0.75rem',
cursor: 'pointer', transition: 'all 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}
>
Cancel
</button>
<button
onClick={handleCommit}
disabled={commitLoading || preview.changes === 0}
style={{
padding: '0.5rem 1.25rem',
background: preview.changes > 0 ? `${TEAL}18` : 'transparent',
border: `1px solid ${preview.changes > 0 ? TEAL : 'rgba(100,116,139,0.3)'}`,
borderRadius: '0.375rem',
color: preview.changes > 0 ? TEAL : '#475569',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: preview.changes > 0 ? 'pointer' : 'default',
opacity: commitLoading ? 0.6 : 1,
display: 'flex', alignItems: 'center', gap: '0.4rem',
transition: 'all 0.15s',
}}
>
{commitLoading && <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />}
Confirm ({preview.changes} changes)
</button>
</>
)}
{step === 'done' && (
<button onClick={onClose} style={{
padding: '0.5rem 1.25rem',
background: `${TEAL}18`,
border: `1px solid ${TEAL}`,
borderRadius: '0.375rem',
color: TEAL,
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
cursor: 'pointer', transition: 'all 0.15s',
}}>
Close
</button>
)}
{step === 'upload' && (
<button onClick={handleCancel} style={{
padding: '0.5rem 1.25rem',
background: 'transparent',
border: '1px solid rgba(100,116,139,0.4)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontFamily: 'monospace', fontSize: '0.75rem',
cursor: 'pointer', transition: 'all 0.15s',
}}>
Cancel
</button>
)}
</div>
</div>
</>
);
}
function SummaryBadge({ label, value, color }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 0.875rem',
background: `${color}08`,
border: `1px solid ${color}30`,
borderRadius: '0.375rem',
}}>
<span style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color }}>
{value}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</span>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2 } from 'lucide-react'; import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
import ConfirmModal from '../ConfirmModal'; import ConfirmModal from '../ConfirmModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -48,6 +48,31 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const [noteError, setNoteError] = useState(null); const [noteError, setNoteError] = useState(null);
const [pendingConfirm, setPendingConfirm] = useState(null); const [pendingConfirm, setPendingConfirm] = useState(null);
// Metadata fields
const [resolutionDate, setResolutionDate] = useState('');
const [remediationPlan, setRemediationPlan] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaError, setMetaError] = useState(null);
const handleSaveMetadata = async (fields) => {
setMetaSaving(true);
setMetaError(null);
try {
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
} catch (err) {
setMetaError(err.message);
} finally {
setMetaSaving(false);
}
};
const fetchDetail = useCallback(async () => { const fetchDetail = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -60,6 +85,10 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
// Default selected metrics to first active failing metric // Default selected metrics to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active'); const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setSelectedMetrics([firstActive.metric_id]); if (firstActive) setSelectedMetrics([firstActive.metric_id]);
// Populate metadata fields
setResolutionDate(data.resolution_date || '');
setRemediationPlan(data.remediation_plan || '');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -214,6 +243,80 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
</Section> </Section>
)} )}
{/* Resolution Date */}
<Section title="Resolution Date" icon={<Calendar style={{ width: '14px', height: '14px' }} />}>
<input
type="date"
value={resolutionDate}
onChange={e => setResolutionDate(e.target.value)}
onBlur={() => handleSaveMetadata({ resolution_date: resolutionDate || null })}
style={{
width: '100%',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem',
color: '#F8FAFC',
padding: '0.5rem 0.625rem',
fontSize: '0.8rem',
fontFamily: 'monospace',
outline: 'none',
}}
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
/>
</Section>
{/* Remediation Plan */}
<Section title="Remediation Plan" icon={<FileText style={{ width: '14px', height: '14px' }} />}>
<textarea
value={remediationPlan}
onChange={e => {
if (e.target.value.length <= 2000) setRemediationPlan(e.target.value);
}}
placeholder="Describe the remediation plan…"
rows={4}
style={{
width: '100%', resize: 'vertical',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem',
color: '#F8FAFC',
padding: '0.5rem 0.625rem',
fontSize: '0.8rem',
outline: 'none',
boxSizing: 'border-box',
}}
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.4rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: remediationPlan.length > 1900 ? '#F59E0B' : '#475569' }}>
{remediationPlan.length}/2000
</span>
<button
onClick={() => handleSaveMetadata({ remediation_plan: remediationPlan || null })}
disabled={metaSaving}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
padding: '0.3rem 0.6rem',
background: `${TEAL}15`,
border: `1px solid ${TEAL}60`,
borderRadius: '0.25rem',
color: TEAL,
fontSize: '0.68rem', fontFamily: 'monospace', fontWeight: '600',
cursor: metaSaving ? 'wait' : 'pointer',
opacity: metaSaving ? 0.6 : 1,
transition: 'all 0.15s',
}}
>
{metaSaving
? <Loader style={{ width: '11px', height: '11px', animation: 'spin 1s linear infinite' }} />
: <Save style={{ width: '11px', height: '11px' }} />}
Save
</button>
</div>
{metaError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem', fontFamily: 'monospace' }}>{metaError}</div>}
</Section>
{/* Notes */} {/* Notes */}
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow> <Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
{detail.notes.length === 0 && ( {detail.notes.length === 0 && (

View File

@@ -5,6 +5,7 @@ import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel'; import ComplianceDetailPanel from './ComplianceDetailPanel';
import ComplianceChartsPanel from './ComplianceChartsPanel'; import ComplianceChartsPanel from './ComplianceChartsPanel';
import MetricInfoPanel from './MetricInfoPanel'; import MetricInfoPanel from './MetricInfoPanel';
import VCLReportPage from './VCLReportPage';
import metricDefinitionsRaw from '../../data/metricDefinitions.json'; import metricDefinitionsRaw from '../../data/metricDefinitions.json';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -250,6 +251,7 @@ export default function CompliancePage({ onNavigate }) {
const availableTeams = getAvailableTeams(); const availableTeams = getAvailableTeams();
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM'); const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
const [activeTab, setActiveTab] = useState('active'); const [activeTab, setActiveTab] = useState('active');
const [vclView, setVclView] = useState(false);
const [metricFilter, setMetricFilter] = useState(null); const [metricFilter, setMetricFilter] = useState(null);
const [hostSearch, setHostSearch] = useState(''); const [hostSearch, setHostSearch] = useState('');
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null }); const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
@@ -408,6 +410,23 @@ export default function CompliancePage({ onNavigate }) {
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}> onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
<RefreshCw style={{ width: '16px', height: '16px' }} /> <RefreshCw style={{ width: '16px', height: '16px' }} />
</button> </button>
<button
onClick={() => setVclView(!vclView)}
style={{
background: vclView ? `${TEAL}18` : 'transparent',
border: `1px solid ${vclView ? TEAL : 'rgba(20,184,166,0.25)'}`,
color: vclView ? TEAL : '#475569',
padding: '0.5rem 1rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
borderRadius: '0.375rem', transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!vclView) { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}}
onMouseLeave={e => { if (!vclView) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}}
>
VCL Report
</button>
{canWrite() && ( {canWrite() && (
<button onClick={() => setShowUpload(true)} <button onClick={() => setShowUpload(true)}
className="intel-button" className="intel-button"
@@ -426,8 +445,13 @@ export default function CompliancePage({ onNavigate }) {
</div> </div>
</div> </div>
{/* ── VCL Report View ─────────────────────────────────────── */}
{vclView && (
<VCLReportPage />
)}
{/* ── Team tabs ────────────────────────────────────────────── */} {/* ── Team tabs ────────────────────────────────────────────── */}
{availableTeams.length === 0 && !isAdmin() ? ( {!vclView && availableTeams.length === 0 && !isAdmin() ? (
<div style={{ <div style={{
padding: '1.5rem', marginBottom: '1.5rem', padding: '1.5rem', marginBottom: '1.5rem',
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)', borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
@@ -437,7 +461,7 @@ export default function CompliancePage({ onNavigate }) {
}}> }}>
No BU teams assigned to your account. Contact an admin to configure your team access. No BU teams assigned to your account. Contact an admin to configure your team access.
</div> </div>
) : ( ) : !vclView && (
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
{availableTeams.map(team => { {availableTeams.map(team => {
const isActive = activeTeam === team; const isActive = activeTeam === team;
@@ -463,7 +487,7 @@ export default function CompliancePage({ onNavigate }) {
)} )}
{/* ── Metric health cards ──────────────────────────────────── */} {/* ── Metric health cards ──────────────────────────────────── */}
{families.length > 0 ? ( {!vclView && families.length > 0 ? (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}> <div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
Metric Health click to filter Metric Health click to filter
@@ -564,10 +588,10 @@ export default function CompliancePage({ onNavigate }) {
) : null} ) : null}
{/* ── Historical trend charts ──────────────────────────────── */} {/* ── Historical trend charts ──────────────────────────────── */}
<ComplianceChartsPanel /> {!vclView && <ComplianceChartsPanel />}
{/* ── Device table ─────────────────────────────────────────── */} {/* ── Device table ─────────────────────────────────────────── */}
<div style={{ {!vclView && <div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)', background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem', border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
overflow: 'hidden', overflow: 'hidden',
@@ -622,7 +646,7 @@ export default function CompliancePage({ onNavigate }) {
{/* Column headers */} {/* Column headers */}
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr', gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.05)', borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '0.62rem', color: '#334155', fontSize: '0.62rem', color: '#334155',
@@ -632,6 +656,8 @@ export default function CompliancePage({ onNavigate }) {
<span>IP Address</span> <span>IP Address</span>
<span>Type</span> <span>Type</span>
<span>Failing Metrics</span> <span>Failing Metrics</span>
<span>Resolution Date</span>
<span>Remediation Plan</span>
<span>Seen</span> <span>Seen</span>
<span></span> <span></span>
</div> </div>
@@ -659,7 +685,7 @@ export default function CompliancePage({ onNavigate }) {
/> />
)) ))
)} )}
</div> </div>}
{/* ── Detail panel ─────────────────────────────────────────── */} {/* ── Detail panel ─────────────────────────────────────────── */}
{selectedHost && ( {selectedHost && (
@@ -805,12 +831,17 @@ export default function CompliancePage({ onNavigate }) {
} }
function DeviceRow({ device, selected, onClick }) { function DeviceRow({ device, selected, onClick }) {
const truncateText = (text, maxLen = 80) => {
if (!text) return '—';
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
};
return ( return (
<div <div
onClick={onClick} onClick={onClick}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr', gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
padding: '0.625rem 1rem', padding: '0.625rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.04)', borderBottom: '1px solid rgba(255,255,255,0.04)',
cursor: 'pointer', cursor: 'pointer',
@@ -844,6 +875,16 @@ function DeviceRow({ device, selected, onClick }) {
))} ))}
</div> </div>
{/* Resolution Date */}
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
{device.resolution_date || '—'}
</div>
{/* Remediation Plan */}
<div style={{ fontSize: '0.7rem', color: '#94A3B8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={device.remediation_plan || ''}>
{truncateText(device.remediation_plan)}
</div>
{/* Seen count */} {/* Seen count */}
<div> <div>
<SeenBadge count={device.seen_count} /> <SeenBadge count={device.seen_count} />

View File

@@ -0,0 +1,481 @@
import React, { useState, useEffect } from 'react';
import { Loader, AlertCircle, Upload } from 'lucide-react';
import {
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine,
PieChart, Pie, Cell, ResponsiveContainer
} from 'recharts';
import BulkUploadModal from './BulkUploadModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const EMERALD = '#10B981';
const AMBER = '#F59E0B';
const RED = '#EF4444';
// ---------------------------------------------------------------------------
// VCL Stats Bar (Task 11)
// ---------------------------------------------------------------------------
function VCLStatsBar({ stats }) {
if (!stats) return null;
const cards = [
{ label: 'Total Devices', value: stats.total_devices, color: '#CBD5E1' },
{ label: 'In-Scope', value: stats.in_scope, color: '#94A3B8' },
{ label: 'Compliant', value: stats.compliant, color: EMERALD },
{ label: 'Non-Compliant', value: stats.non_compliant, color: RED },
{ label: 'Remediations Req.', value: stats.remediations_required, color: AMBER },
{ label: 'Current %', value: `${stats.compliance_pct}%`, color: TEAL },
{ label: 'Target %', value: `${stats.target_pct}%`, color: AMBER },
];
return (
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{cards.map(card => (
<div key={card.label} style={{
flex: '1 1 0',
minWidth: '120px',
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '0.875rem 1rem',
textAlign: 'center',
}}>
<div style={{
fontFamily: 'monospace', fontSize: '1.25rem', fontWeight: '700',
color: card.color, marginBottom: '0.25rem',
}}>
{card.value}
</div>
<div style={{
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.08em', color: '#475569',
}}>
{card.label}
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Compliance Overview Trend Chart (Task 12)
// ---------------------------------------------------------------------------
function ComplianceOverviewChart({ trendData, targetPct }) {
if (!trendData || trendData.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
No trend data available
</div>
);
}
const AXIS_STYLE = { fill: '#64748B', fontSize: '0.68rem', fontFamily: 'monospace' };
return (
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '1.25rem',
marginBottom: '1.5rem',
}}>
<div style={{
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: '#475569', marginBottom: '1rem',
}}>
Compliance Overview
</div>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={trendData}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
<XAxis dataKey="month" tick={AXIS_STYLE} />
<YAxis yAxisId="count" tick={AXIS_STYLE} />
<YAxis yAxisId="pct" orientation="right" domain={[0, 100]} unit="%" tick={AXIS_STYLE} />
<Tooltip
contentStyle={{
background: 'rgba(15,23,42,0.95)',
border: '1px solid rgba(20,184,166,0.3)',
borderRadius: '0.375rem',
fontFamily: 'monospace',
fontSize: '0.72rem',
color: '#CBD5E1',
}}
/>
<Legend wrapperStyle={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8' }} />
<Bar yAxisId="count" dataKey="compliant_count" fill={EMERALD} fillOpacity={0.7} name="Compliant" />
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
{trendData.length >= 2 && (
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
)}
<ReferenceLine yAxisId="pct" y={targetPct || 95} stroke={AMBER} strokeDasharray="4 4" label={{ value: 'Target', fill: AMBER, fontSize: '0.68rem', fontFamily: 'monospace' }} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
// ---------------------------------------------------------------------------
// Non-Compliant Assets Donut Chart (Task 13)
// ---------------------------------------------------------------------------
function NonCompliantDonutChart({ donut }) {
if (!donut) return null;
const data = [
{ name: 'Blocked', count: donut.blocked?.count || 0, pct: donut.blocked?.pct || 0 },
{ name: 'In-Progress', count: donut.in_progress?.count || 0, pct: donut.in_progress?.pct || 0 },
].filter(d => d.count > 0);
if (data.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
No non-compliant assets
</div>
);
}
const COLORS = [RED, AMBER];
const renderLabel = ({ name, count, pct }) => `${name}: ${count} (${pct}%)`;
return (
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '1.25rem',
}}>
<div style={{
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: '#475569', marginBottom: '1rem',
}}>
Status of Non-Compliant Assets
</div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
innerRadius={60}
outerRadius={90}
dataKey="count"
nameKey="name"
label={renderLabel}
labelLine={{ stroke: '#475569' }}
>
{data.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Legend wrapperStyle={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8' }} />
<Tooltip
contentStyle={{
background: 'rgba(15,23,42,0.95)',
border: '1px solid rgba(20,184,166,0.3)',
borderRadius: '0.375rem',
fontFamily: 'monospace',
fontSize: '0.72rem',
color: '#CBD5E1',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
// ---------------------------------------------------------------------------
// Heavy Hitters Table (Task 14)
// ---------------------------------------------------------------------------
function HeavyHittersTable({ heavyHitters }) {
if (!heavyHitters || heavyHitters.length === 0) {
return (
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
No heavy hitters data
</div>
);
}
const headerStyle = {
padding: '0.625rem 0.75rem',
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.08em', color: TEAL, fontWeight: '600',
borderBottom: '1px solid rgba(20,184,166,0.2)',
textAlign: 'left',
};
const cellStyle = {
padding: '0.625rem 0.75rem',
fontSize: '0.75rem', fontFamily: 'monospace', color: '#CBD5E1',
borderBottom: '1px solid rgba(255,255,255,0.04)',
};
return (
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
overflow: 'hidden',
marginBottom: '1.5rem',
}}>
<div style={{
padding: '1rem 1.25rem 0.5rem',
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: '#475569',
}}>
Heavy Hitters
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={headerStyle}>Vertical / Team</th>
<th style={{ ...headerStyle, textAlign: 'right' }}>Non-Compliant</th>
<th style={headerStyle}>Compliance Date</th>
<th style={headerStyle}>Notes</th>
</tr>
</thead>
<tbody>
{heavyHitters.map((row, i) => (
<tr key={i}>
<td style={cellStyle}>
<div>{row.vertical}</div>
{row.team && <div style={{ fontSize: '0.65rem', color: '#64748B' }}>{row.team}</div>}
</td>
<td style={{ ...cellStyle, textAlign: 'right', color: RED, fontWeight: '600' }}>
{row.non_compliant}
</td>
<td style={cellStyle}>
{row.compliance_date || ''}
</td>
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.notes || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ---------------------------------------------------------------------------
// Vertical Breakdown Table (Task 15)
// ---------------------------------------------------------------------------
function VerticalBreakdownTable({ verticalBreakdown }) {
if (!verticalBreakdown || verticalBreakdown.length === 0) {
return (
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
No vertical breakdown data
</div>
);
}
// Collect all actual burndown months and forecast burndown months
const allActualMonths = new Set();
const allForecastMonths = new Set();
verticalBreakdown.forEach(row => {
if (row.actual_burndown) Object.keys(row.actual_burndown).forEach(m => allActualMonths.add(m));
if (row.forecast_burndown) Object.keys(row.forecast_burndown).forEach(m => allForecastMonths.add(m));
});
const actualMonths = [...allActualMonths].sort();
const forecastMonths = [...allForecastMonths].sort();
const headerStyle = {
padding: '0.5rem 0.5rem',
fontSize: '0.58rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.06em', color: TEAL, fontWeight: '600',
borderBottom: '1px solid rgba(20,184,166,0.2)',
textAlign: 'left', whiteSpace: 'nowrap',
};
const cellStyle = {
padding: '0.5rem 0.5rem',
fontSize: '0.7rem', fontFamily: 'monospace', color: '#CBD5E1',
borderBottom: '1px solid rgba(255,255,255,0.04)',
whiteSpace: 'nowrap',
};
return (
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
overflow: 'auto',
marginBottom: '1.5rem',
}}>
<div style={{
padding: '1rem 1.25rem 0.5rem',
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: '#475569',
}}>
Vertical Breakdown
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '900px' }}>
<thead>
<tr>
<th style={headerStyle}>Vertical</th>
<th style={{ ...headerStyle, textAlign: 'right' }}>Compliance %</th>
<th style={headerStyle}>Team</th>
<th style={{ ...headerStyle, textAlign: 'right' }}>Non-Compliant</th>
{actualMonths.map(m => (
<th key={`a-${m}`} style={{ ...headerStyle, textAlign: 'right', color: '#94A3B8' }}>
{m.slice(5)}
</th>
))}
{forecastMonths.map(m => (
<th key={`f-${m}`} style={{ ...headerStyle, textAlign: 'right', color: AMBER }}>
{m.slice(5)}*
</th>
))}
<th style={{ ...headerStyle, textAlign: 'right' }}>Blockers</th>
<th style={{ ...headerStyle, textAlign: 'right' }}>RAs</th>
<th style={headerStyle}>Notes</th>
</tr>
</thead>
<tbody>
{verticalBreakdown.map((row, i) => (
<tr key={i}>
<td style={cellStyle}>{row.vertical}</td>
<td style={{ ...cellStyle, textAlign: 'right', color: TEAL }}>
{row.compliance_pct}%
</td>
<td style={{ ...cellStyle, color: '#94A3B8' }}>{row.team || ''}</td>
<td style={{ ...cellStyle, textAlign: 'right', color: row.non_compliant > 0 ? RED : '#64748B' }}>
{row.non_compliant}
</td>
{actualMonths.map(m => (
<td key={`a-${m}`} style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
{(row.actual_burndown && row.actual_burndown[m]) || 0}
</td>
))}
{forecastMonths.map(m => (
<td key={`f-${m}`} style={{ ...cellStyle, textAlign: 'right', color: AMBER }}>
{(row.forecast_burndown && row.forecast_burndown[m]) || 0}
</td>
))}
<td style={{ ...cellStyle, textAlign: 'right', color: row.blockers > 0 ? RED : '#64748B' }}>
{row.blockers || 0}
</td>
<td style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
{row.risk_acceptances || 0}
</td>
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.notes || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// VCL Report Page (Task 10)
// ---------------------------------------------------------------------------
export default function VCLReportPage() {
const [stats, setStats] = useState(null);
const [trendData, setTrendData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showBulkUpload, setShowBulkUpload] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [statsRes, trendRes] = await Promise.all([
fetch(`${API_BASE}/compliance/vcl/stats`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/vcl/trend`, { credentials: 'include' }),
]);
if (!statsRes.ok) throw new Error('Failed to load VCL stats');
if (!trendRes.ok) throw new Error('Failed to load VCL trend data');
const statsData = await statsRes.json();
const trendDataJson = await trendRes.json();
setStats(statsData);
setTrendData(trendDataJson.months || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem' }}>
<Loader style={{ width: '32px', height: '32px', color: TEAL, animation: 'spin 1s linear infinite' }} />
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.85rem', fontFamily: 'monospace' }}>
<AlertCircle style={{ width: '18px', height: '18px' }} />
{error}
</div>
);
}
return (
<div style={{ marginTop: '1rem' }}>
{/* Page sub-header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<div style={{
fontSize: '0.72rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: '#475569',
}}>
VCL Executive Report
</div>
<button
onClick={() => setShowBulkUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
padding: '0.5rem 1rem',
background: `${TEAL}15`,
border: `1px solid ${TEAL}60`,
borderRadius: '0.375rem',
color: TEAL,
fontSize: '0.72rem', fontFamily: 'monospace', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = `${TEAL}25`; e.currentTarget.style.borderColor = TEAL; }}
onMouseLeave={e => { e.currentTarget.style.background = `${TEAL}15`; e.currentTarget.style.borderColor = `${TEAL}60`; }}
>
<Upload style={{ width: '14px', height: '14px' }} />
Bulk Upload
</button>
</div>
{/* Stats Bar */}
<VCLStatsBar stats={stats?.stats} />
{/* Charts row */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
<ComplianceOverviewChart trendData={trendData} targetPct={stats?.stats?.target_pct} />
<NonCompliantDonutChart donut={stats?.donut} />
</div>
{/* Heavy Hitters */}
<HeavyHittersTable heavyHitters={stats?.heavy_hitters} />
{/* Vertical Breakdown */}
<VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} />
{/* Bulk Upload Modal */}
{showBulkUpload && (
<BulkUploadModal onClose={() => setShowBulkUpload(false)} />
)}
</div>
);
}