Add VCL compliance reporting: exec report page, device metadata fields, bulk upload
This commit is contained in:
501
backend/__tests__/vcl-compliance-reporting.property.test.js
Normal file
501
backend/__tests__/vcl-compliance-reporting.property.test.js
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
316
backend/__tests__/vcl-compliance-reporting.test.js
Normal file
316
backend/__tests__/vcl-compliance-reporting.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
220
backend/helpers/vclHelpers.js
Normal file
220
backend/helpers/vclHelpers.js
Normal file
@@ -0,0 +1,220 @@
|
||||
// Pure helper functions for VCL Compliance Reporting
|
||||
// No database dependencies — all functions are stateless and testable in isolation.
|
||||
|
||||
/**
|
||||
* Truncates text to maxLen characters with an ellipsis.
|
||||
* Returns '' for null/undefined input.
|
||||
*/
|
||||
function truncateText(text, maxLen = 80) {
|
||||
if (text == null) return '';
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '\u2026';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a remediation plan does not exceed 2000 characters.
|
||||
* Null/undefined/empty values are considered valid (no plan documented).
|
||||
*/
|
||||
function validateRemediationPlan(text) {
|
||||
if (text == null || text === '') return { valid: true };
|
||||
if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only for strings parseable as real calendar dates.
|
||||
* Rejects null, undefined, empty string, and invalid dates like "2026-02-30".
|
||||
*/
|
||||
function isValidDateString(str) {
|
||||
if (str == null || str === '') return false;
|
||||
if (typeof str !== 'string') return false;
|
||||
|
||||
// Expect YYYY-MM-DD format
|
||||
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return false;
|
||||
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10);
|
||||
const day = parseInt(match[3], 10);
|
||||
|
||||
// Month must be 1-12
|
||||
if (month < 1 || month > 12) return false;
|
||||
|
||||
// Create date and verify components match (catches invalid days like Feb 30)
|
||||
const date = new Date(year, month - 1, day);
|
||||
return (
|
||||
date.getFullYear() === year &&
|
||||
date.getMonth() === month - 1 &&
|
||||
date.getDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a decimal as a whole-number percentage string.
|
||||
* Returns '0%' for null, undefined, or NaN input.
|
||||
*/
|
||||
function formatPct(decimal) {
|
||||
if (decimal == null || isNaN(decimal)) return '0%';
|
||||
return Math.round(decimal * 100) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes VCL summary statistics from an array of device objects.
|
||||
* Each item should have at least { is_compliant: boolean, in_scope: boolean }.
|
||||
*/
|
||||
function computeVCLStats(items, targetPct) {
|
||||
const total = items.length;
|
||||
const in_scope = items.filter(item => item.in_scope).length;
|
||||
const compliant = items.filter(item => item.is_compliant).length;
|
||||
const non_compliant = in_scope - compliant;
|
||||
const remediations_required = non_compliant;
|
||||
const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
in_scope,
|
||||
compliant,
|
||||
non_compliant,
|
||||
remediations_required,
|
||||
compliance_pct,
|
||||
target_pct: targetPct,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions non-compliant items into "blocked" (no resolution_date) and
|
||||
* "in_progress" (resolution_date set). Returns counts and percentages.
|
||||
*/
|
||||
function categorizeNonCompliant(items) {
|
||||
const total = items.length;
|
||||
const blocked = items.filter(item => item.resolution_date == null);
|
||||
const in_progress = items.filter(item => item.resolution_date != null);
|
||||
|
||||
return {
|
||||
blocked: {
|
||||
count: blocked.length,
|
||||
pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0,
|
||||
},
|
||||
in_progress: {
|
||||
count: in_progress.length,
|
||||
pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts verticals by non_compliant count in descending order.
|
||||
* Returns a new sorted array (does not mutate input).
|
||||
*/
|
||||
function rankHeavyHitters(verticalData) {
|
||||
return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets non-compliant items by resolution_date month (YYYY-MM).
|
||||
* Items with null resolution_date are skipped.
|
||||
* Returns an object like { '2026-05': 3, '2026-06': 7 }.
|
||||
*/
|
||||
function computeForecastBurndown(items) {
|
||||
const buckets = {};
|
||||
for (const item of items) {
|
||||
if (item.resolution_date == null) continue;
|
||||
const dateStr = typeof item.resolution_date === 'string'
|
||||
? item.resolution_date
|
||||
: item.resolution_date.toISOString().slice(0, 10);
|
||||
const month = dateStr.slice(0, 7); // YYYY-MM
|
||||
buckets[month] = (buckets[month] || 0) + 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches uploaded rows to existing hostnames.
|
||||
* Returns { matched: [...], unmatched: [...] }.
|
||||
*/
|
||||
function matchByHostname(uploadedRows, existingHostnames) {
|
||||
const matched = [];
|
||||
const unmatched = [];
|
||||
for (const row of uploadedRows) {
|
||||
if (existingHostnames.has(row.hostname)) {
|
||||
matched.push(row);
|
||||
} else {
|
||||
unmatched.push(row);
|
||||
}
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares uploaded row values against current DB values.
|
||||
* currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }.
|
||||
* Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }.
|
||||
*/
|
||||
function computeBulkDiff(matchedRows, currentData) {
|
||||
const results = [];
|
||||
const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes'];
|
||||
|
||||
for (const row of matchedRows) {
|
||||
const current = currentData.get(row.hostname) || {};
|
||||
const fields = {};
|
||||
let hasChange = false;
|
||||
|
||||
for (const field of COMPARE_FIELDS) {
|
||||
if (field in row) {
|
||||
const oldVal = current[field] != null ? current[field] : null;
|
||||
const newVal = row[field] != null ? row[field] : null;
|
||||
if (oldVal !== newVal) {
|
||||
fields[field] = { old: oldVal, new: newVal };
|
||||
hasChange = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
hostname: row.hostname,
|
||||
status: hasChange ? 'changed' : 'unchanged',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps column header strings to known field names (case-insensitive).
|
||||
* Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices.
|
||||
*/
|
||||
function mapColumnHeaders(headers) {
|
||||
const mapping = {};
|
||||
const KNOWN_MAPPINGS = {
|
||||
hostname: 'hostname',
|
||||
'resolution date': 'resolution_date',
|
||||
resolution_date: 'resolution_date',
|
||||
'remediation plan': 'remediation_plan',
|
||||
remediation_plan: 'remediation_plan',
|
||||
notes: 'notes',
|
||||
};
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const normalized = headers[i].trim().toLowerCase();
|
||||
if (KNOWN_MAPPINGS[normalized]) {
|
||||
mapping[KNOWN_MAPPINGS[normalized]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
isValidDateString,
|
||||
formatPct,
|
||||
computeVCLStats,
|
||||
categorizeNonCompliant,
|
||||
rankHeavyHitters,
|
||||
computeForecastBurndown,
|
||||
matchByHostname,
|
||||
computeBulkDiff,
|
||||
mapColumnHeaders,
|
||||
};
|
||||
38
backend/migrations/add_vcl_reporting_columns.js
Normal file
38
backend/migrations/add_vcl_reporting_columns.js
Normal 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();
|
||||
@@ -16,6 +16,7 @@ const MIGRATIONS_DIR = __dirname;
|
||||
const POSTGRES_MIGRATIONS = [
|
||||
'add_decom_workflow_type.js',
|
||||
'add_fp_submissions_dismissed.js',
|
||||
'add_vcl_reporting_columns.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { spawn } = require('child_process');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
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 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');
|
||||
|
||||
// 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 };
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
463
frontend/src/components/pages/BulkUploadModal.js
Normal file
463
frontend/src/components/pages/BulkUploadModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
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 [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 () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -60,6 +85,10 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
// Default selected metrics to first active failing metric
|
||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||
|
||||
// Populate metadata fields
|
||||
setResolutionDate(data.resolution_date || '');
|
||||
setRemediationPlan(data.remediation_plan || '');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -214,6 +243,80 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</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 */}
|
||||
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||
{detail.notes.length === 0 && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
import MetricInfoPanel from './MetricInfoPanel';
|
||||
import VCLReportPage from './VCLReportPage';
|
||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
|
||||
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 [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [vclView, setVclView] = useState(false);
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
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)'; }}>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
</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() && (
|
||||
<button onClick={() => setShowUpload(true)}
|
||||
className="intel-button"
|
||||
@@ -426,8 +445,13 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── VCL Report View ─────────────────────────────────────── */}
|
||||
{vclView && (
|
||||
<VCLReportPage />
|
||||
)}
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
{availableTeams.length === 0 && !isAdmin() ? (
|
||||
{!vclView && availableTeams.length === 0 && !isAdmin() ? (
|
||||
<div style={{
|
||||
padding: '1.5rem', marginBottom: '1.5rem',
|
||||
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.
|
||||
</div>
|
||||
) : (
|
||||
) : !vclView && (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
{availableTeams.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
@@ -463,7 +487,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{families.length > 0 ? (
|
||||
{!vclView && families.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Metric Health — click to filter
|
||||
@@ -564,10 +588,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
) : null}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
<ComplianceChartsPanel />
|
||||
{!vclView && <ComplianceChartsPanel />}
|
||||
|
||||
{/* ── 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%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
@@ -622,7 +646,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{/* Column headers */}
|
||||
<div style={{
|
||||
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',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
@@ -632,6 +656,8 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<span>IP Address</span>
|
||||
<span>Type</span>
|
||||
<span>Failing Metrics</span>
|
||||
<span>Resolution Date</span>
|
||||
<span>Remediation Plan</span>
|
||||
<span>Seen</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@@ -659,7 +685,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||
{selectedHost && (
|
||||
@@ -805,12 +831,17 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}
|
||||
|
||||
function DeviceRow({ device, selected, onClick }) {
|
||||
const truncateText = (text, maxLen = 80) => {
|
||||
if (!text) return '—';
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
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',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
@@ -844,6 +875,16 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
))}
|
||||
</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 */}
|
||||
<div>
|
||||
<SeenBadge count={device.seen_count} />
|
||||
|
||||
481
frontend/src/components/pages/VCLReportPage.js
Normal file
481
frontend/src/components/pages/VCLReportPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user