Compare commits
2 Commits
808625dab4
...
04360cc4bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04360cc4bc
|
||
|
|
d61383ac7b
|
@@ -1,27 +0,0 @@
|
||||
# Workflow & Context Gathering
|
||||
|
||||
## Specs First
|
||||
|
||||
Before making changes to any feature area, **always check `.kiro/specs/` for related spec folders first**. Specs contain the original requirements, design decisions, architecture diagrams, data models, and task breakdowns that informed the implementation. They provide critical context about:
|
||||
|
||||
- Why a feature was built a certain way
|
||||
- What data models and API contracts were agreed upon
|
||||
- What correctness properties must hold
|
||||
- What edge cases were considered
|
||||
|
||||
Even if the code has evolved since the spec was written, the spec is the starting point for understanding intent.
|
||||
|
||||
## Spec Folder Structure
|
||||
|
||||
Each spec folder typically contains:
|
||||
|
||||
- `requirements.md` — user stories and acceptance criteria
|
||||
- `design.md` — architecture, data models, API contracts, error handling
|
||||
- `tasks.md` — implementation task breakdown with completion status
|
||||
|
||||
## When to Check Specs
|
||||
|
||||
- Fixing bugs in a feature area — check the spec to understand intended behavior
|
||||
- Adding to an existing feature — check the spec to understand design constraints
|
||||
- Investigating unexpected behavior — the spec documents what "correct" looks like
|
||||
- Refactoring — the spec documents which properties must be preserved
|
||||
@@ -205,6 +205,79 @@ function mapColumnHeaders(headers) {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts vertical code and report date from a filename.
|
||||
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
||||
*
|
||||
* Examples:
|
||||
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||
*
|
||||
* Returns null if the filename does not match the expected pattern.
|
||||
*/
|
||||
function parseVerticalFilename(filename) {
|
||||
// Strip .xlsx extension (case-insensitive)
|
||||
const stem = filename.replace(/\.xlsx$/i, '');
|
||||
// Match: everything up to the last _YYYY_MM_DD
|
||||
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const vertical = match[1];
|
||||
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
||||
|
||||
// Validate the date portion is a real date
|
||||
if (!isValidDateString(date)) return null;
|
||||
|
||||
return { vertical, date };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-vertical burndown forecast from non-compliant items.
|
||||
* Returns breakdown of items with/without resolution dates and monthly projections.
|
||||
*/
|
||||
function computeVerticalBurndown(items) {
|
||||
const total = items.length;
|
||||
const withDates = items.filter(i => i.resolution_date != null);
|
||||
const blockers = items.filter(i => i.resolution_date == null);
|
||||
|
||||
// Bucket by month
|
||||
const monthly = {};
|
||||
for (const item of withDates) {
|
||||
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
|
||||
monthly[month] = (monthly[month] || 0) + 1;
|
||||
}
|
||||
|
||||
// Cumulative projection — how many remain after each month
|
||||
let remaining = total;
|
||||
const projection = {};
|
||||
for (const month of Object.keys(monthly).sort()) {
|
||||
remaining -= monthly[month];
|
||||
projection[month] = { remediated: monthly[month], remaining };
|
||||
}
|
||||
|
||||
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
||||
let projectedClearDate = null;
|
||||
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
||||
const sortedMonths = Object.keys(projection).sort();
|
||||
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
blockers: blockers.length,
|
||||
with_dates: withDates.length,
|
||||
monthly,
|
||||
projection,
|
||||
projected_clear_date: projectedClearDate,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -217,4 +290,6 @@ module.exports = {
|
||||
matchByHostname,
|
||||
computeBulkDiff,
|
||||
mapColumnHeaders,
|
||||
parseVerticalFilename,
|
||||
computeVerticalBurndown,
|
||||
};
|
||||
|
||||
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Migration: Add multi-vertical support for VCL compliance reporting
|
||||
// Adds vertical column to compliance_items and compliance_uploads,
|
||||
// creates vcl_multi_vertical_summary table for per-vertical metric data.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting VCL multi-vertical migration...');
|
||||
try {
|
||||
// Add vertical column to compliance_items
|
||||
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_items');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
||||
console.log('✓ idx_compliance_items_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
||||
console.log('✓ idx_compliance_items_vertical_status index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_metric ON compliance_items(vertical, metric_id, status)`);
|
||||
console.log('✓ idx_compliance_items_vertical_metric index created');
|
||||
|
||||
// Add vertical column to compliance_uploads
|
||||
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_uploads');
|
||||
|
||||
// Create summary table for per-vertical metric data from Summary sheets
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||
id SERIAL PRIMARY KEY,
|
||||
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
vertical TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT DEFAULT '',
|
||||
category TEXT DEFAULT 'Other',
|
||||
team TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT '',
|
||||
non_compliant INTEGER DEFAULT 0,
|
||||
compliant INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0,
|
||||
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||
target NUMERIC(5,2) DEFAULT 0,
|
||||
status TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ vcl_multi_vertical_summary table created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
||||
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
||||
console.log('✓ idx_vcl_multi_summary_upload index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical_metric ON vcl_multi_vertical_summary(vertical, metric_id)`);
|
||||
console.log('✓ idx_vcl_multi_summary_vertical_metric index created');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Migration error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -19,6 +19,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_fp_submissions_requeued_at.js',
|
||||
'add_vcl_reporting_columns.js',
|
||||
'add_vcl_vertical_metadata.js',
|
||||
'add_vcl_multi_vertical.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
880
backend/routes/vclMultiVertical.js
Normal file
880
backend/routes/vclMultiVertical.js
Normal file
@@ -0,0 +1,880 @@
|
||||
// VCL Multi-Vertical Routes — Cross-organizational compliance reporting
|
||||
// Handles multi-file per-vertical xlsx upload, scoped resolution, and executive reporting.
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant } = require('../helpers/vclHelpers');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Python parser, return parsed object
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseXlsx(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]);
|
||||
let out = '';
|
||||
let err = '';
|
||||
py.stdout.on('data', d => { out += d; });
|
||||
py.stderr.on('data', d => { err += d; });
|
||||
py.on('close', code => {
|
||||
if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`));
|
||||
try { resolve(JSON.parse(out)); }
|
||||
catch (e) { reject(new Error('Parser returned invalid JSON')); }
|
||||
});
|
||||
py.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compute scoped diff: only considers items within the same vertical
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeScopedDiff(incomingItems, vertical) {
|
||||
const { rows: activeRows } = await pool.query(
|
||||
`SELECT hostname, metric_id FROM compliance_items WHERE status = 'active' AND vertical = $1`,
|
||||
[vertical]
|
||||
);
|
||||
const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`));
|
||||
const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||
|
||||
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||
for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; }
|
||||
for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; }
|
||||
|
||||
return { newCount, recurringCount, resolvedCount };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persist a single vertical's upload with scoped resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
async function persistMultiVerticalUpload({ items, summary, reportDate, filename, vertical, userId }, client) {
|
||||
// Get active items for THIS vertical only
|
||||
const { rows: activeRows } = await client.query(
|
||||
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items
|
||||
WHERE status = 'active' AND vertical = $1`,
|
||||
[vertical]
|
||||
);
|
||||
const activeMap = {};
|
||||
activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; });
|
||||
|
||||
const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||
|
||||
// 1. Insert the upload record
|
||||
const uploadResult = await client.query(
|
||||
`INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, vertical, summary_json)
|
||||
VALUES ($1, $2, $3, NOW(), $4, $5)
|
||||
RETURNING id`,
|
||||
[filename, reportDate || null, userId || null, vertical, JSON.stringify(summary)]
|
||||
);
|
||||
const uploadId = uploadResult.rows[0].id;
|
||||
|
||||
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||
|
||||
// 2. Upsert each incoming non-compliant item
|
||||
for (const item of items) {
|
||||
const key = `${item.hostname}|||${item.metric_id}`;
|
||||
const existing = activeMap[key];
|
||||
const extraStr = JSON.stringify(item.extra_json || {});
|
||||
|
||||
if (existing) {
|
||||
await client.query(
|
||||
`UPDATE compliance_items
|
||||
SET upload_id = $1, seen_count = $2, ip_address = $3, device_type = $4, extra_json = $5,
|
||||
metric_desc = $6, category = $7, team = $8
|
||||
WHERE id = $9`,
|
||||
[uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr,
|
||||
item.metric_desc, item.category, item.team, existing.id]
|
||||
);
|
||||
recurringCount++;
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_items
|
||||
(upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc,
|
||||
category, extra_json, status, first_seen_upload_id, seen_count, vertical)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, 1, $11)`,
|
||||
[uploadId, item.hostname, item.ip_address, item.device_type, item.team,
|
||||
item.metric_id, item.metric_desc, item.category, extraStr, uploadId, vertical]
|
||||
);
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve items NOT present in this upload — SCOPED to this vertical only
|
||||
for (const [key, row] of Object.entries(activeMap)) {
|
||||
if (!newKeys.has(key)) {
|
||||
await client.query(
|
||||
`UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $1 WHERE id = $2`,
|
||||
[uploadId, row.id]
|
||||
);
|
||||
resolvedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update upload with final counts
|
||||
await client.query(
|
||||
`UPDATE compliance_uploads SET new_count = $1, resolved_count = $2, recurring_count = $3 WHERE id = $4`,
|
||||
[newCount, resolvedCount, recurringCount, uploadId]
|
||||
);
|
||||
|
||||
// 5. Store summary entries in vcl_multi_vertical_summary
|
||||
if (summary && summary.entries && summary.entries.length > 0) {
|
||||
for (const entry of summary.entries) {
|
||||
await client.query(
|
||||
`INSERT INTO vcl_multi_vertical_summary
|
||||
(upload_id, vertical, metric_id, metric_desc, category, team, priority,
|
||||
non_compliant, compliant, total, compliance_pct, target, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
[uploadId, vertical, entry.metric_id, entry.description || entry.metric_desc || '',
|
||||
entry.category || 'Other', entry.team || '', entry.priority || '',
|
||||
entry.non_compliant || 0, entry.compliant || 0, entry.total || 0,
|
||||
entry.compliance_pct || 0, entry.target || 0, entry.status || '']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create/update compliance_snapshots for this vertical
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const { rows: verticalStats } = await client.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical = $1`,
|
||||
[vertical]
|
||||
);
|
||||
const vs = verticalStats[0] || { total_devices: 0, non_compliant: 0 };
|
||||
const totalDevices = vs.total_devices;
|
||||
const compliant = totalDevices - vs.non_compliant;
|
||||
const compPct = totalDevices > 0 ? Math.round((compliant / totalDevices) * 100 * 100) / 100 : 0;
|
||||
|
||||
await client.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, vertical, totalDevices, compliant, vs.non_compliant, compPct]
|
||||
);
|
||||
|
||||
return { uploadId, newCount, recurringCount, resolvedCount };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe temp path check
|
||||
// ---------------------------------------------------------------------------
|
||||
function isSafeTempPath(filePath) {
|
||||
const resolved = path.resolve(filePath);
|
||||
return resolved.startsWith(path.resolve(TEMP_DIR) + path.sep) && path.extname(resolved) === '.json';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createVCLMultiVerticalRouter(upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth());
|
||||
|
||||
/**
|
||||
* POST /preview
|
||||
* Accepts multiple xlsx files, parses each, extracts vertical from filename,
|
||||
* computes per-vertical scoped diffs, and stores parsed data in temp files.
|
||||
*
|
||||
* @method POST
|
||||
* @route /preview
|
||||
* @group Admin, Standard_User
|
||||
*
|
||||
* @body multipart/form-data
|
||||
* - files: File[] — 1–14 xlsx files with naming convention <VERTICAL>_YYYY_MM_DD.xlsx
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* files: Array<{
|
||||
* filename: string,
|
||||
* vertical: string,
|
||||
* report_date: string,
|
||||
* total_items: number,
|
||||
* summary_entries: number,
|
||||
* diff: { new_count: number, recurring_count: number, resolved_count: number },
|
||||
* tempFile: string
|
||||
* }>,
|
||||
* unrecognized: Array<{ filename: string, error: string }>
|
||||
* }
|
||||
* @response 400 { error: string } — upload error or no files provided
|
||||
*/
|
||||
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
upload.array('files', 14)(req, res, async (uploadErr) => {
|
||||
if (uploadErr) return res.status(400).json({ error: uploadErr.message });
|
||||
if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
const results = [];
|
||||
const unrecognized = [];
|
||||
const seenVerticals = new Set();
|
||||
|
||||
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
|
||||
for (const file of req.files) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext !== '.xlsx') {
|
||||
unrecognized.push({ filename: file.originalname, error: 'Not an xlsx file' });
|
||||
fs.unlink(file.path, () => {});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract vertical from filename
|
||||
const parsed = parseVerticalFilename(file.originalname);
|
||||
if (!parsed) {
|
||||
unrecognized.push({ filename: file.originalname, error: 'Filename does not match pattern <VERTICAL>_YYYY_MM_DD.xlsx' });
|
||||
fs.unlink(file.path, () => {});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate verticals in the same batch
|
||||
if (seenVerticals.has(parsed.vertical)) {
|
||||
unrecognized.push({ filename: file.originalname, error: `Duplicate vertical "${parsed.vertical}" in batch` });
|
||||
fs.unlink(file.path, () => {});
|
||||
continue;
|
||||
}
|
||||
seenVerticals.add(parsed.vertical);
|
||||
|
||||
try {
|
||||
const xlsxData = await parseXlsx(file.path);
|
||||
if (xlsxData.error) {
|
||||
unrecognized.push({ filename: file.originalname, error: xlsxData.error });
|
||||
fs.unlink(file.path, () => {});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute scoped diff for this vertical
|
||||
const diff = await computeScopedDiff(xlsxData.items, parsed.vertical);
|
||||
|
||||
// Store parsed data in temp file
|
||||
const tempFilename = `vcl_multi_${parsed.vertical}_${Date.now()}_${Math.random().toString(36).slice(2)}.json`;
|
||||
const tempFilePath = path.join(TEMP_DIR, tempFilename);
|
||||
fs.writeFileSync(tempFilePath, JSON.stringify({
|
||||
items: xlsxData.items,
|
||||
summary: xlsxData.summary,
|
||||
report_date: parsed.date,
|
||||
vertical: parsed.vertical,
|
||||
filename: file.originalname.replace(/[^\w.\-() ]/g, '_'),
|
||||
}));
|
||||
|
||||
results.push({
|
||||
filename: file.originalname,
|
||||
vertical: parsed.vertical,
|
||||
report_date: parsed.date,
|
||||
total_items: xlsxData.total || xlsxData.items.length,
|
||||
summary_entries: (xlsxData.summary && xlsxData.summary.entries) ? xlsxData.summary.entries.length : 0,
|
||||
diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount },
|
||||
tempFile: tempFilePath,
|
||||
});
|
||||
} catch (parseErr) {
|
||||
unrecognized.push({ filename: file.originalname, error: parseErr.message });
|
||||
} finally {
|
||||
fs.unlink(file.path, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files: results, unrecognized });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /commit
|
||||
* Commits all previewed files in a single transaction with vertical-scoped resolution.
|
||||
*
|
||||
* @method POST
|
||||
* @route /commit
|
||||
* @group Admin, Standard_User
|
||||
*
|
||||
* @body application/json
|
||||
* {
|
||||
* files: Array<{
|
||||
* tempFile: string,
|
||||
* vertical?: string,
|
||||
* report_date?: string,
|
||||
* filename?: string
|
||||
* }>
|
||||
* }
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* committed: Array<{
|
||||
* vertical: string,
|
||||
* upload_id: number,
|
||||
* new_count: number,
|
||||
* recurring_count: number,
|
||||
* resolved_count: number
|
||||
* }>,
|
||||
* total_new: number,
|
||||
* total_resolved: number
|
||||
* }
|
||||
* @response 400 { error: string } — invalid/missing tempFile or expired preview session
|
||||
* @response 500 { error: string } — transaction failure
|
||||
*/
|
||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { files } = req.body;
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
return res.status(400).json({ error: 'files array is required' });
|
||||
}
|
||||
|
||||
// Validate all temp files exist before starting transaction
|
||||
for (const file of files) {
|
||||
if (!file.tempFile || !isSafeTempPath(file.tempFile)) {
|
||||
return res.status(400).json({ error: `Invalid tempFile path for ${file.vertical || 'unknown'}` });
|
||||
}
|
||||
if (!fs.existsSync(file.tempFile)) {
|
||||
return res.status(400).json({ error: `Preview session expired for ${file.vertical || 'unknown'} — please upload again` });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const committed = [];
|
||||
for (const file of files) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(fs.readFileSync(file.tempFile, 'utf8')); }
|
||||
catch { throw new Error(`Could not read preview data for ${file.vertical}`); }
|
||||
|
||||
const result = await persistMultiVerticalUpload({
|
||||
items: parsed.items,
|
||||
summary: parsed.summary,
|
||||
reportDate: file.report_date || parsed.report_date,
|
||||
filename: file.filename || parsed.filename,
|
||||
vertical: file.vertical || parsed.vertical,
|
||||
userId: req.user?.id || null,
|
||||
}, client);
|
||||
|
||||
committed.push({
|
||||
vertical: file.vertical || parsed.vertical,
|
||||
upload_id: result.uploadId,
|
||||
new_count: result.newCount,
|
||||
recurring_count: result.recurringCount,
|
||||
resolved_count: result.resolvedCount,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Clean up temp files
|
||||
for (const file of files) {
|
||||
fs.unlink(file.tempFile, () => {});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'vcl_multi_vertical_upload',
|
||||
entityType: 'compliance_uploads',
|
||||
entityId: null,
|
||||
details: {
|
||||
verticals: committed.map(c => c.vertical),
|
||||
total_new: committed.reduce((s, c) => s + c.new_count, 0),
|
||||
total_resolved: committed.reduce((s, c) => s + c.resolved_count, 0),
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({
|
||||
committed,
|
||||
total_new: committed.reduce((s, c) => s + c.new_count, 0),
|
||||
total_resolved: committed.reduce((s, c) => s + c.resolved_count, 0),
|
||||
});
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
// Clean up temp files on failure too
|
||||
for (const file of files) {
|
||||
if (file.tempFile) fs.unlink(file.tempFile, () => {});
|
||||
}
|
||||
console.error('[VCL Multi] Commit error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to commit batch: ' + err.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /stats
|
||||
* Returns aggregated cross-vertical executive summary statistics.
|
||||
*
|
||||
* @method GET
|
||||
* @route /stats
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* stats: {
|
||||
* total_devices: number,
|
||||
* compliant: number,
|
||||
* non_compliant: number,
|
||||
* compliance_pct: number,
|
||||
* target_pct: number
|
||||
* },
|
||||
* donut: { blocked: number, in_progress: number },
|
||||
* vertical_breakdown: Array<{
|
||||
* vertical: string,
|
||||
* total_devices: number,
|
||||
* compliant: number,
|
||||
* non_compliant: number,
|
||||
* compliance_pct: number,
|
||||
* blockers: number,
|
||||
* forecast_burndown: Array<{ month: string, projected_remaining: number }>,
|
||||
* last_upload: string|null
|
||||
* }>,
|
||||
* last_upload_date: string|null
|
||||
* }
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
// Aggregate device-level stats across all multi-vertical items
|
||||
const { rows: statsRows } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL
|
||||
`);
|
||||
|
||||
const raw = statsRows[0] || { total_devices: 0, non_compliant: 0 };
|
||||
const total_devices = raw.total_devices;
|
||||
const non_compliant = raw.non_compliant;
|
||||
const compliant = total_devices - non_compliant;
|
||||
const compliance_pct = total_devices > 0 ? Math.round((compliant / total_devices) * 100) : 0;
|
||||
|
||||
// Donut: blocked vs in-progress across all verticals
|
||||
const { rows: donutRows } = await pool.query(`
|
||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'
|
||||
GROUP BY hostname
|
||||
`);
|
||||
const donutItems = donutRows.map(r => ({ resolution_date: r.resolution_date }));
|
||||
const donut = categorizeNonCompliant(donutItems);
|
||||
|
||||
// Per-vertical breakdown
|
||||
const { rows: verticalRows } = await pool.query(`
|
||||
SELECT
|
||||
vertical,
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL
|
||||
GROUP BY vertical
|
||||
ORDER BY vertical
|
||||
`);
|
||||
|
||||
// Get last upload date per vertical
|
||||
const { rows: uploadDates } = await pool.query(`
|
||||
SELECT vertical, MAX(report_date) AS last_upload
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
GROUP BY vertical
|
||||
`);
|
||||
const uploadDateMap = {};
|
||||
uploadDates.forEach(r => { uploadDateMap[r.vertical] = r.last_upload; });
|
||||
|
||||
// Get burndown data per vertical
|
||||
const { rows: burndownRows } = await pool.query(`
|
||||
SELECT vertical, hostname, resolution_date
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'
|
||||
`);
|
||||
// Group by vertical for burndown computation
|
||||
const burndownByVertical = {};
|
||||
for (const row of burndownRows) {
|
||||
if (!burndownByVertical[row.vertical]) burndownByVertical[row.vertical] = [];
|
||||
burndownByVertical[row.vertical].push(row);
|
||||
}
|
||||
|
||||
const vertical_breakdown = verticalRows.map(v => {
|
||||
const totalDev = v.total_devices;
|
||||
const comp = totalDev - v.non_compliant;
|
||||
const pct = totalDev > 0 ? Math.round((comp / totalDev) * 100) : 0;
|
||||
const items = burndownByVertical[v.vertical] || [];
|
||||
const burndown = computeVerticalBurndown(items);
|
||||
|
||||
return {
|
||||
vertical: v.vertical,
|
||||
total_devices: totalDev,
|
||||
compliant: comp,
|
||||
non_compliant: v.non_compliant,
|
||||
compliance_pct: pct,
|
||||
blockers: burndown.blockers,
|
||||
forecast_burndown: burndown.monthly,
|
||||
last_upload: uploadDateMap[v.vertical] || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
stats: {
|
||||
total_devices,
|
||||
compliant,
|
||||
non_compliant,
|
||||
compliance_pct,
|
||||
target_pct: VCL_TARGET_PCT,
|
||||
},
|
||||
donut,
|
||||
vertical_breakdown,
|
||||
last_upload_date: uploadDates.length > 0 ? uploadDates.reduce((max, r) => r.last_upload > max ? r.last_upload : max, '') : null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /stats error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /trend
|
||||
* Returns monthly compliance trend data aggregated across all verticals.
|
||||
* Includes linear regression forecast when 3+ months of data exist.
|
||||
*
|
||||
* @method GET
|
||||
* @route /trend
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* months: Array<{
|
||||
* month: string,
|
||||
* compliant_count: number|null,
|
||||
* compliance_pct: number|null,
|
||||
* forecast_pct: number|null,
|
||||
* target_pct: number
|
||||
* }>
|
||||
* }
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/trend', async (req, res) => {
|
||||
try {
|
||||
// Get snapshots for multi-vertical data (vertical IS NOT NULL)
|
||||
const { rows: snapshots } = await pool.query(`
|
||||
SELECT snapshot_month, SUM(total_devices)::int AS total_devices,
|
||||
SUM(compliant)::int AS compliant, SUM(non_compliant)::int AS non_compliant
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical IS NOT NULL AND vertical != ''
|
||||
GROUP BY snapshot_month
|
||||
ORDER BY snapshot_month ASC
|
||||
`);
|
||||
|
||||
if (snapshots.length === 0) return res.json({ months: [] });
|
||||
|
||||
const months = snapshots.map(s => {
|
||||
const total = s.total_devices;
|
||||
const pct = total > 0 ? Math.round((s.compliant / total) * 100 * 10) / 10 : 0;
|
||||
return {
|
||||
month: s.snapshot_month,
|
||||
compliant_count: s.compliant,
|
||||
compliance_pct: 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;
|
||||
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));
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Add forecast_pct to last actual month as starting point
|
||||
if (n > 0) {
|
||||
months[n - 1].forecast_pct = months[n - 1].compliance_pct;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ months });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /trend error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /vertical/:code/metrics
|
||||
* Returns per-metric breakdown for a specific vertical from the latest upload's summary data.
|
||||
*
|
||||
* @method GET
|
||||
* @route /vertical/:code/metrics
|
||||
* @param {string} code — vertical code (e.g., "NTS_AEO", "SDIT_CISO")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* vertical: string,
|
||||
* metrics: Array<{
|
||||
* metric_id: string,
|
||||
* metric_desc: string,
|
||||
* category: string,
|
||||
* team: string,
|
||||
* priority: string,
|
||||
* non_compliant: number,
|
||||
* compliant: number,
|
||||
* total: number,
|
||||
* compliance_pct: number,
|
||||
* target: number,
|
||||
* status: string
|
||||
* }>,
|
||||
* categories: Array<{
|
||||
* category: string,
|
||||
* non_compliant: number,
|
||||
* compliant: number,
|
||||
* total: number,
|
||||
* compliance_pct: number
|
||||
* }>
|
||||
* }
|
||||
* @response 400 { error: string } — invalid vertical code
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/vertical/:code/metrics', async (req, res) => {
|
||||
const vertical = req.params.code;
|
||||
if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' });
|
||||
|
||||
try {
|
||||
// Get the latest upload for this vertical
|
||||
const { rows: latestUpload } = await pool.query(
|
||||
`SELECT id FROM compliance_uploads WHERE vertical = $1 ORDER BY id DESC LIMIT 1`,
|
||||
[vertical]
|
||||
);
|
||||
|
||||
if (latestUpload.length === 0) return res.json({ vertical, metrics: [], categories: [] });
|
||||
|
||||
const uploadId = latestUpload[0].id;
|
||||
|
||||
// Get per-metric summary data
|
||||
const { rows: metrics } = await pool.query(
|
||||
`SELECT metric_id, metric_desc, category, team, priority,
|
||||
non_compliant, compliant, total, compliance_pct, target, status
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = $1 AND vertical = $2
|
||||
ORDER BY category, metric_id`,
|
||||
[uploadId, vertical]
|
||||
);
|
||||
|
||||
// Aggregate by category
|
||||
const categoryMap = {};
|
||||
for (const m of metrics) {
|
||||
const cat = m.category || 'Other';
|
||||
if (!categoryMap[cat]) categoryMap[cat] = { category: cat, non_compliant: 0, compliant: 0, total: 0 };
|
||||
categoryMap[cat].non_compliant += m.non_compliant;
|
||||
categoryMap[cat].compliant += m.compliant;
|
||||
categoryMap[cat].total += m.total;
|
||||
}
|
||||
const categories = Object.values(categoryMap).map(c => ({
|
||||
...c,
|
||||
compliance_pct: c.total > 0 ? Math.round((c.compliant / c.total) * 100 * 10) / 10 : 0,
|
||||
}));
|
||||
|
||||
res.json({ vertical, metrics, categories });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /vertical/:code/metrics error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /vertical/:code/metric/:metricId/devices
|
||||
* Returns the list of non-compliant devices for a specific vertical + metric.
|
||||
*
|
||||
* @method GET
|
||||
* @route /vertical/:code/metric/:metricId/devices
|
||||
* @param {string} code — vertical code (e.g., "NTS_AEO")
|
||||
* @param {string} metricId — metric identifier (e.g., "VM-001")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* vertical: string,
|
||||
* metric_id: string,
|
||||
* devices: Array<{
|
||||
* hostname: string,
|
||||
* ip_address: string,
|
||||
* device_type: string,
|
||||
* team: string,
|
||||
* seen_count: number,
|
||||
* resolution_date: string|null,
|
||||
* remediation_plan: string|null,
|
||||
* first_seen: string|null,
|
||||
* last_seen: string|null
|
||||
* }>
|
||||
* }
|
||||
* @response 400 { error: string } — invalid vertical code or metric ID
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/vertical/:code/metric/:metricId/devices', async (req, res) => {
|
||||
const vertical = req.params.code;
|
||||
const metricId = req.params.metricId;
|
||||
if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' });
|
||||
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metric ID' });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count,
|
||||
ci.resolution_date, ci.remediation_plan,
|
||||
fu.report_date AS first_seen, lu.report_date AS last_seen
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||
WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active'
|
||||
ORDER BY ci.hostname`,
|
||||
[vertical, metricId]
|
||||
);
|
||||
|
||||
res.json({ vertical, metric_id: metricId, devices: rows });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /vertical/:code/metric/:metricId/devices error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /vertical/:code/burndown
|
||||
* Returns burndown forecast for a specific vertical.
|
||||
* Deduplicates devices by hostname and computes monthly projected resolution.
|
||||
*
|
||||
* @method GET
|
||||
* @route /vertical/:code/burndown
|
||||
* @param {string} code — vertical code (e.g., "TSI")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* vertical: string,
|
||||
* blockers: number,
|
||||
* in_progress: number,
|
||||
* monthly: Array<{ month: string, projected_remaining: number }>
|
||||
* }
|
||||
* @response 400 { error: string } — invalid vertical code
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/vertical/:code/burndown', async (req, res) => {
|
||||
const vertical = req.params.code;
|
||||
if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hostname, resolution_date
|
||||
FROM compliance_items
|
||||
WHERE vertical = $1 AND status = 'active'`,
|
||||
[vertical]
|
||||
);
|
||||
|
||||
// Deduplicate by hostname (a device may have multiple failing metrics)
|
||||
const deviceMap = {};
|
||||
for (const row of rows) {
|
||||
if (!deviceMap[row.hostname]) {
|
||||
deviceMap[row.hostname] = { hostname: row.hostname, resolution_date: row.resolution_date };
|
||||
} else if (row.resolution_date && !deviceMap[row.hostname].resolution_date) {
|
||||
// If any metric has a resolution date, the device counts as "in progress"
|
||||
deviceMap[row.hostname].resolution_date = row.resolution_date;
|
||||
}
|
||||
}
|
||||
const devices = Object.values(deviceMap);
|
||||
const burndown = computeVerticalBurndown(devices);
|
||||
|
||||
res.json({ vertical, ...burndown });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /vertical/:code/burndown error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /uploads
|
||||
* Returns upload history for multi-vertical uploads (most recent 100).
|
||||
*
|
||||
* @method GET
|
||||
* @route /uploads
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* uploads: Array<{
|
||||
* id: number,
|
||||
* filename: string,
|
||||
* report_date: string|null,
|
||||
* uploaded_at: string,
|
||||
* vertical: string,
|
||||
* new_count: number,
|
||||
* resolved_count: number,
|
||||
* recurring_count: number
|
||||
* }>
|
||||
* }
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/uploads', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, filename, report_date, uploaded_at, vertical, new_count, resolved_count, recurring_count
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 100`
|
||||
);
|
||||
res.json({ uploads: rows });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /uploads error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /verticals
|
||||
* Returns the list of known verticals derived from compliance_items records.
|
||||
*
|
||||
* @method GET
|
||||
* @route /verticals
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* verticals: string[]
|
||||
* }
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/verticals', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT vertical FROM compliance_items WHERE vertical IS NOT NULL ORDER BY vertical`
|
||||
);
|
||||
res.json({ verticals: rows.map(r => r.vertical) });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /verticals error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createVCLMultiVerticalRouter };
|
||||
@@ -28,6 +28,7 @@ const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const { createComplianceRouter } = require('./routes/compliance');
|
||||
const { createVCLMultiVerticalRouter } = require('./routes/vclMultiVertical');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
@@ -213,6 +214,9 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(upload));
|
||||
|
||||
// VCL multi-vertical routes — cross-organizational compliance reporting
|
||||
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload));
|
||||
|
||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||
app.use('/api/atlas', createAtlasRouter());
|
||||
|
||||
|
||||
33
configure.js
33
configure.js
@@ -104,9 +104,9 @@ const VARIABLE_DESCRIPTORS = [
|
||||
{
|
||||
name: 'CORS_ORIGINS',
|
||||
group: 'Core Settings',
|
||||
required: true,
|
||||
required: false,
|
||||
default: null, // derived from frontend port at runtime
|
||||
description: 'Comma-separated list of allowed CORS origins for the backend',
|
||||
description: 'Allowed CORS origins (only needed if frontend dev server runs on a separate port)',
|
||||
docUrl: null,
|
||||
sensitive: false,
|
||||
validator: 'validateCorsOrigins'
|
||||
@@ -1229,6 +1229,21 @@ async function main() {
|
||||
let confirmedPort = null;
|
||||
|
||||
for (const group of GROUP_ORDER) {
|
||||
// Auto-derive Frontend Settings from PORT and API_HOST — no need to prompt
|
||||
if (group === 'Frontend Settings') {
|
||||
const port = confirmedPort || '3001';
|
||||
const host = values.get('API_HOST') || 'localhost';
|
||||
const apiBase = `http://${host}:${port}/api`;
|
||||
const apiHost = `http://${host}:${port}`;
|
||||
values.set('REACT_APP_API_BASE', apiBase);
|
||||
values.set('REACT_APP_API_HOST', apiHost);
|
||||
console.log(`\n=== Frontend Settings ===`);
|
||||
console.log(` Auto-configured from your backend settings:`);
|
||||
console.log(` REACT_APP_API_BASE = ${apiBase}`);
|
||||
console.log(` REACT_APP_API_HOST = ${apiHost}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
printGroupHeader(group);
|
||||
|
||||
// For skippable groups, ask if user wants to configure
|
||||
@@ -1267,14 +1282,6 @@ async function main() {
|
||||
if (confirmedPort !== null) {
|
||||
currentValue = null; // Will use derived default below
|
||||
}
|
||||
} else if (descriptor.name === 'REACT_APP_API_BASE') {
|
||||
if (confirmedPort !== null) {
|
||||
currentValue = null; // Will use derived default below
|
||||
}
|
||||
} else if (descriptor.name === 'REACT_APP_API_HOST') {
|
||||
if (confirmedPort !== null) {
|
||||
currentValue = null; // Will use derived default below
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,12 +1299,6 @@ async function main() {
|
||||
};
|
||||
} else if (descriptor.name === 'CORS_ORIGINS' && currentValue === null && descriptor.default === null) {
|
||||
effectiveDescriptor = { ...descriptor, default: 'http://localhost:3000' };
|
||||
} else if (descriptor.name === 'REACT_APP_API_BASE' && currentValue === null && descriptor.default === null) {
|
||||
const port = confirmedPort || '3001';
|
||||
effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}/api` };
|
||||
} else if (descriptor.name === 'REACT_APP_API_HOST' && currentValue === null && descriptor.default === null) {
|
||||
const port = confirmedPort || '3001';
|
||||
effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}` };
|
||||
}
|
||||
|
||||
const result = await promptVariable(rl, effectiveDescriptor, currentValue);
|
||||
|
||||
@@ -684,6 +684,8 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/findings` | Admin, Standard_User | Add or remove findings on an existing submission |
|
||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/attachments` | Admin, Standard_User | Upload additional attachments (local files and/or `libraryDocIds`) to an existing submission |
|
||||
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/status` | Admin, Standard_User | Update submission lifecycle status |
|
||||
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/dismiss` | Admin, Standard_User | Dismiss a rejected submission (sets `dismissed_at` timestamp) |
|
||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/requeue` | Admin, Standard_User | Re-queue findings from a rejected submission into the todo queue under a new workflow type |
|
||||
|
||||
### Ivanti — Todo Queue
|
||||
|
||||
@@ -889,7 +891,7 @@ All tables are defined in `backend/db-schema.sql` and created by `setup-postgres
|
||||
|
||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
||||
|
||||
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed).
|
||||
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed). Rejected submissions can be dismissed (`dismissed_at`) or re-queued to the todo queue under a different workflow type (`requeued_at`).
|
||||
|
||||
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||
|
||||
@@ -1040,6 +1042,8 @@ node migrations/add_fp_submissions_table.js
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_fp_submission_editing.js
|
||||
node migrations/add_fp_submissions_dismissed.js
|
||||
node migrations/add_fp_submissions_requeued_at.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
```
|
||||
|
||||
164
docs/guides/kb-vcl-reporting-guide.md
Normal file
164
docs/guides/kb-vcl-reporting-guide.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# VCL Executive Reporting — How It Works
|
||||
|
||||
## Overview
|
||||
|
||||
The VCL (Vulnerability Compliance Level) Report page generates an executive-level compliance summary from device-level data already tracked in the STEAM Security Dashboard. It aggregates individual device findings into team-level metrics, burndown projections, and compliance percentages — the same data leadership uses in the VCL deck for quarterly reporting.
|
||||
|
||||
The report is not a separate data source. It reads from the same `compliance_items` table that the AEO Compliance page uses. The difference is the view: Compliance shows device-level detail, VCL shows team-level aggregation.
|
||||
|
||||
---
|
||||
|
||||
## Metrics Explained
|
||||
|
||||
### Stats Bar
|
||||
|
||||
| Metric | What It Means | What Feeds It |
|
||||
|---|---|---|
|
||||
| Total Devices | Count of unique hostnames across all compliance items (active + resolved) | Weekly compliance xlsx upload |
|
||||
| In-Scope | Same as Total Devices (all tracked devices are considered in-scope) | Weekly compliance xlsx upload |
|
||||
| Compliant | Devices with NO active findings (all their findings are resolved) | Compliance upload resolves findings when devices drop off the report |
|
||||
| Non-Compliant | Devices with at least one active finding | Compliance upload adds new findings; devices stay non-compliant until all findings resolve |
|
||||
| Remediations Required | Same as Non-Compliant (each non-compliant device needs remediation) | Same as Non-Compliant |
|
||||
| Current % | `(Compliant / In-Scope) * 100`, rounded to whole number | Computed from the counts above |
|
||||
| Target % | Organization-defined compliance target (default 95%) | Set via `VCL_TARGET_PCT` environment variable on the backend |
|
||||
|
||||
### Status of Non-Compliant Assets (Donut Chart)
|
||||
|
||||
| Segment | What It Means | What Feeds It |
|
||||
|---|---|---|
|
||||
| Blocked | Non-compliant devices with NO resolution date set — the team has not committed to a remediation timeline | Devices without a `resolution_date` value |
|
||||
| In-Progress | Non-compliant devices WITH a resolution date set — the team has a target fix date | Devices with a `resolution_date` value |
|
||||
|
||||
**How to move devices from Blocked to In-Progress:** Set a resolution date on the device, either by clicking into it on the Compliance page and entering a date, or by using the Bulk Upload with a "Resolution Date" column.
|
||||
|
||||
### Heavy Hitters Table
|
||||
|
||||
| Column | What It Means | What Feeds It |
|
||||
|---|---|---|
|
||||
| Vertical / Team | The team responsible for the non-compliant devices | `team` field on compliance items (set during xlsx upload) |
|
||||
| Non-Compliant | Count of unique hostnames with active findings for that team | Computed from compliance_items |
|
||||
| Compliance Date | The team's stated target for full remediation (e.g., "Q3 2026") | Manually entered on this page (click to edit) |
|
||||
| Notes | Team-level summary of their remediation approach | Manually entered on this page (click to edit) |
|
||||
|
||||
### Vertical Breakdown Table
|
||||
|
||||
| Column | What It Means | What Feeds It |
|
||||
|---|---|---|
|
||||
| Vertical | Team name | `team` field on compliance items |
|
||||
| Compliance % | `(Compliant devices in team / Total devices in team) * 100` | Computed from compliance_items |
|
||||
| Team | Same as Vertical | Same |
|
||||
| Non-Compliant | Count of non-compliant devices for that team | Computed from compliance_items |
|
||||
| Forecast Burndown (monthly columns) | How many devices are expected to be remediated each month | Grouped by the `resolution_date` month on individual devices |
|
||||
| Blockers | Non-compliant devices with NO resolution date (no committed timeline) | Count of devices where `resolution_date` is NULL |
|
||||
| RAs | Risk Acceptances — count of approved exceptions for that team | Manually entered on this page (click to edit) |
|
||||
| Notes | Team-level remediation narrative | Manually entered on this page (click to edit) |
|
||||
|
||||
### Compliance Overview Trend Chart
|
||||
|
||||
| Element | What It Means | What Feeds It |
|
||||
|---|---|---|
|
||||
| Green bars | Count of compliant devices for each month | Monthly snapshots (created automatically on each compliance upload) |
|
||||
| Solid teal line | Actual compliance percentage for each month | Monthly snapshots |
|
||||
| Dashed teal line | Forecasted compliance percentage (projected forward) | Linear regression on the last 3+ months of actual data |
|
||||
| Amber horizontal line | Target compliance threshold | `VCL_TARGET_PCT` environment variable |
|
||||
|
||||
> The trend chart requires at least one compliance upload to create the first snapshot. After 3+ monthly uploads, the forecast line appears.
|
||||
|
||||
---
|
||||
|
||||
## What Feeds the Data
|
||||
|
||||
### Automatic (from compliance uploads)
|
||||
|
||||
These values update automatically when a new weekly compliance xlsx is uploaded:
|
||||
|
||||
- Total Devices, In-Scope, Compliant, Non-Compliant counts
|
||||
- Current Compliance %
|
||||
- Per-team compliance percentages
|
||||
- Monthly trend snapshots (one snapshot per upload)
|
||||
- Devices moving between active/resolved status
|
||||
|
||||
### Manual (entered by engineers or BIs)
|
||||
|
||||
These values are entered by users and persist until changed:
|
||||
|
||||
| Field | Where to Enter It | Scope |
|
||||
|---|---|---|
|
||||
| Resolution Date | Compliance page → click device → Resolution Date field | Per device |
|
||||
| Remediation Plan | Compliance page → click device → Remediation Plan field | Per device |
|
||||
| Compliance Date | VCL Report → Heavy Hitters table → click the cell | Per team |
|
||||
| Notes | VCL Report → Heavy Hitters or Vertical Breakdown → click the cell | Per team |
|
||||
| RAs (Risk Acceptances) | VCL Report → Vertical Breakdown → click the cell | Per team |
|
||||
|
||||
### Bulk Upload
|
||||
|
||||
For updating many devices at once (e.g., 1000 devices), use the **Bulk Upload** button on the VCL Report page:
|
||||
|
||||
1. Prepare an xlsx file with columns: `Hostname`, `Resolution Date`, `Remediation Plan`, `Notes`
|
||||
2. Click Bulk Upload and select the file
|
||||
3. Review the diff preview (shows matched/unmatched/changed/invalid counts)
|
||||
4. Confirm to commit changes
|
||||
|
||||
---
|
||||
|
||||
## How Metrics Adjust Over Time
|
||||
|
||||
### Weekly Compliance Upload Cycle
|
||||
|
||||
Each weekly xlsx upload triggers these changes:
|
||||
|
||||
1. **New findings** appear as active items → Non-Compliant count increases
|
||||
2. **Resolved findings** (devices no longer on the report) get marked resolved → Compliant count increases
|
||||
3. **A monthly snapshot** is created/updated in `compliance_snapshots` → feeds the trend chart
|
||||
4. **Stats bar** reflects the new totals immediately
|
||||
|
||||
### As Teams Set Resolution Dates
|
||||
|
||||
When resolution dates are added to devices:
|
||||
|
||||
1. **Donut chart shifts** — devices move from "Blocked" (red) to "In-Progress" (amber)
|
||||
2. **Forecast burndown columns populate** — showing expected remediations per month per team
|
||||
3. **Blockers count decreases** — fewer devices without a committed timeline
|
||||
|
||||
### As Devices Get Remediated
|
||||
|
||||
When a device drops off the weekly compliance report (finding resolved):
|
||||
|
||||
1. **Non-Compliant count decreases**
|
||||
2. **Compliant count increases**
|
||||
3. **Current % improves**
|
||||
4. **Team compliance % improves**
|
||||
5. **The device's resolution_date no longer contributes to forecast** (it's done)
|
||||
|
||||
### Trend Chart Over Months
|
||||
|
||||
After 3+ monthly compliance uploads:
|
||||
|
||||
1. The trend chart shows actual compliance % per month (solid line)
|
||||
2. A linear regression projects the trend forward 3 months (dashed line)
|
||||
3. You can see whether the organization is on track to hit the target % (amber line)
|
||||
|
||||
---
|
||||
|
||||
## Summary: Data Flow
|
||||
|
||||
```
|
||||
Weekly xlsx upload
|
||||
→ compliance_items (active/resolved findings per device)
|
||||
→ compliance_snapshots (monthly aggregate for trend chart)
|
||||
→ Stats bar, donut, heavy hitters, vertical breakdown auto-update
|
||||
|
||||
Engineers set resolution_date on devices (manual or bulk upload)
|
||||
→ Donut shifts from Blocked to In-Progress
|
||||
→ Forecast burndown columns populate
|
||||
→ Blockers count decreases
|
||||
|
||||
BIs edit team-level fields on VCL Report page
|
||||
→ Compliance Date, Notes, RAs saved per team
|
||||
→ Displayed in Heavy Hitters and Vertical Breakdown tables
|
||||
|
||||
Devices remediated (drop off next weekly upload)
|
||||
→ Compliance % improves
|
||||
→ Trend chart shows upward movement
|
||||
→ Forecast adjusts based on new regression
|
||||
```
|
||||
184
docs/vcl-multi-vertical-design-brief.md
Normal file
184
docs/vcl-multi-vertical-design-brief.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# VCL Multi-Vertical Upload — Design Brief
|
||||
|
||||
## Purpose
|
||||
|
||||
This document summarizes the design decisions and architectural choices for the VCL Multi-Vertical Upload feature. It is intended as a reference for presenting the approach to stakeholders and the compliance team.
|
||||
|
||||
---
|
||||
|
||||
## What We Are Building
|
||||
|
||||
A new upload flow on the STEAM Security Dashboard that accepts multiple per-vertical compliance xlsx files (one per organizational vertical), ingests them with vertical-scoped resolution logic, and generates an executive-level VCL compliance report across all organizations — with drill-down by vertical and by metric.
|
||||
|
||||
This is a POC. The compliance team currently exports data from CyberMetrics as xlsx files on a 24-hour cycle. This feature lets them upload those files and generate the same reports they currently build manually in PowerPoint/Excel for senior leadership.
|
||||
|
||||
---
|
||||
|
||||
## The Problem It Solves
|
||||
|
||||
Today the compliance team has 14 separate xlsx files — one per vertical (NTS_AEO, SDIT_CISO, TSI, etc.). The existing dashboard upload flow accepts a single consolidated file and treats it as the complete compliance state. If you upload just one vertical's file, the system incorrectly marks every device from the other 13 verticals as "resolved."
|
||||
|
||||
There is no automated way to:
|
||||
- Ingest all 14 files and produce a unified report
|
||||
- Drill down from the organizational view into specific metrics and devices
|
||||
- Generate burndown forecasts across verticals
|
||||
|
||||
---
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
### 1. Vertical-Scoped Resolution
|
||||
|
||||
**Decision:** When a file for vertical X is committed, only items belonging to vertical X are evaluated for resolution. All other verticals are untouched.
|
||||
|
||||
**Why:** This is the fundamental change that makes per-vertical uploads safe. Without it, uploading one file would destroy data from the other 13 verticals.
|
||||
|
||||
**Implication:** Verticals are independent. You can upload NTS_AEO on Monday and SDIT_CISO on Wednesday without interference. This also supports the daily upload cadence the compliance team wants.
|
||||
|
||||
### 2. Vertical Identity Comes From the Filename
|
||||
|
||||
**Decision:** The vertical code is extracted from the filename pattern `<VERTICAL>_YYYY_MM_DD.xlsx`, not from data inside the xlsx.
|
||||
|
||||
**Why:** The internal xlsx structure is identical across verticals — same Summary sheet, same metric detail sheets, same columns. The only differentiator is the filename. This also means the Python parser requires zero changes.
|
||||
|
||||
**Implication:** Filenames must follow the convention. If they don't, the system flags them as "unrecognized" and the user can manually assign a vertical. This is a reasonable tradeoff for a POC.
|
||||
|
||||
### 3. Separate From Existing AEO Upload
|
||||
|
||||
**Decision:** This is a new flow with its own endpoints (`/api/compliance/vcl-multi/...`), its own UI page, and its own nav entry. The existing AEO compliance upload is unchanged.
|
||||
|
||||
**Why:**
|
||||
- The existing flow works for the STEAM/ACCESS-ENG team's day-to-day operations
|
||||
- The compliance team may deploy this on a separate instance to experiment without affecting production
|
||||
- Different user groups with different needs — engineers vs. compliance analysts vs. senior leadership
|
||||
|
||||
**Implication:** There are now two ways to upload compliance data. They coexist via the `vertical` column — existing AEO data has `vertical = NULL`, multi-vertical data has a vertical code. The VCL report page can aggregate either or both.
|
||||
|
||||
### 4. Two-Dimensional Grouping (Vertical + Team)
|
||||
|
||||
**Decision:** `vertical` and `team` are separate fields. Vertical is the organizational unit (NTS_AEO, SDIT_CISO). Team is the sub-team within a vertical (STEAM, ACCESS-ENG, ACCESS-OPS).
|
||||
|
||||
**Why:** NTS_AEO contains multiple sub-teams. Senior leadership wants to see the vertical-level view. The STEAM team wants to see their team-level view. Both are valid groupings on the same data.
|
||||
|
||||
**Implication:** The cross-organizational report groups by vertical. Drilling into NTS_AEO still shows the STEAM/ACCESS-ENG/ACCESS-OPS breakdown because that data exists in the "Team" column inside the xlsx.
|
||||
|
||||
### 5. Summary Sheet Data Stored Separately
|
||||
|
||||
**Decision:** The parsed Summary sheet (metric-level health data) is stored in a dedicated `vcl_multi_vertical_summary` table, not just as JSON on the upload record.
|
||||
|
||||
**Why:** The metric drill-down view needs to query per-metric compliance percentages and targets efficiently. Storing structured rows enables filtering, sorting, and aggregation at the database level rather than parsing JSON blobs in application code.
|
||||
|
||||
**Implication:** Slightly more storage, but enables fast queries like "show me all metrics below target across all verticals" without full-table scans.
|
||||
|
||||
### 6. Batch Upload With Atomic Commit
|
||||
|
||||
**Decision:** All files in a batch are committed in a single database transaction. If any file fails, the entire batch rolls back.
|
||||
|
||||
**Why:** Partial commits would leave the report in an inconsistent state — some verticals updated, others stale. The compliance team uploads all 14 files together as a reporting cycle. It should either all succeed or all fail.
|
||||
|
||||
**Implication:** If one file has a parsing error, the user is shown the error in the preview phase (before commit). They can remove that file from the batch and commit the rest. Once they hit "Commit," it's all-or-nothing.
|
||||
|
||||
### 7. Daily Upload Support (Idempotent)
|
||||
|
||||
**Decision:** Re-uploading the same vertical on the same day produces the same final state as uploading it once. The system doesn't create duplicate records.
|
||||
|
||||
**Why:** CyberMetrics refreshes on a 24-hour cycle. The compliance team may want to upload daily to track movement. They shouldn't have to worry about "did I already upload today?"
|
||||
|
||||
**Implication:** The resolution logic uses `vertical + hostname + metric_id` as the identity key. Recurring items get their `seen_count` incremented and metadata updated. New items are inserted. Missing items are resolved. Same logic as today, just scoped to the vertical.
|
||||
|
||||
---
|
||||
|
||||
## Drill-Down Hierarchy
|
||||
|
||||
```
|
||||
Executive Overview (all verticals aggregated)
|
||||
│
|
||||
├── Stats: 4200 devices, 90% compliant, target 95%
|
||||
├── Trend: monthly compliance % with forecast
|
||||
├── Donut: 30% blocked, 70% in-progress
|
||||
│
|
||||
└── Vertical Breakdown Table
|
||||
│
|
||||
├── NTS_AEO — 90% — 80 non-compliant — click to drill down
|
||||
│ │
|
||||
│ ├── Metric Breakdown
|
||||
│ │ ├── 5.2.4 (Access & MFA) — 98.1% — 15 non-compliant
|
||||
│ │ ├── 1.1.1 (Logging) — 85% — 120 non-compliant → click
|
||||
│ │ │ └── Device list: hostname, IP, type, team, seen_count
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── Burndown: 25 blockers, 55 with dates, projected clear Q3 2026
|
||||
│
|
||||
├── SDIT_CISO — 92% — 45 non-compliant
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Burndown Forecast
|
||||
|
||||
The burndown forecast answers: "When will this vertical reach compliance?"
|
||||
|
||||
**How it works:**
|
||||
1. Each non-compliant device can have a `resolution_date` set (target remediation date)
|
||||
2. Devices with dates are bucketed by month → "20 devices expected remediated in June, 35 in July"
|
||||
3. Devices without dates are counted as "blockers" — no committed timeline
|
||||
4. The trend chart uses linear regression on 3+ months of actual data to project a forecast line
|
||||
|
||||
**What feeds it:**
|
||||
- Resolution dates can be set manually (click device → set date) or via bulk upload (xlsx with Hostname + Resolution Date columns)
|
||||
- The existing bulk upload flow on the VCL page already supports this
|
||||
|
||||
**What the compliance team sees:**
|
||||
- Per-vertical: "NTS_AEO has 80 non-compliant, 25 are blockers, 55 have dates, projected clear by August 2026"
|
||||
- Aggregated: trend line showing whether the organization is on track to hit 95% target
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Existing AEO compliance upload (single file) — unchanged
|
||||
- Existing VCL report page (STEAM/ACCESS-ENG view) — unchanged
|
||||
- Existing compliance_items table structure — only adds a nullable `vertical` column
|
||||
- Python parser — reused as-is, no modifications
|
||||
- Auth model — same groups (Admin, Standard_User) required for upload
|
||||
|
||||
---
|
||||
|
||||
## Deployment Options
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| Same instance | Add the feature to the existing dashboard. Multi-vertical data coexists with AEO data via the `vertical` column. |
|
||||
| Separate instance | Deploy a fresh instance with its own database. Compliance team experiments freely. No risk to dev/production data. |
|
||||
| Later: API integration | Replace xlsx upload with direct CyberMetrics API calls. Backend endpoints stay the same — just a different client pushing data. |
|
||||
|
||||
The architecture supports all three without code changes. The `vertical` column and scoped resolution logic work regardless of deployment topology.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for the Meeting
|
||||
|
||||
1. **Vertical list** — Are the 14 verticals in the screenshot the complete set, or do new verticals get added periodically? (Affects whether we hardcode a list or keep it dynamic.)
|
||||
|
||||
2. **Target % per vertical** — Is the 95% target uniform across all verticals, or do different verticals have different targets?
|
||||
|
||||
3. **Access control** — Should the compliance team have their own user accounts with a specific role, or do they use existing Admin/Standard_User groups?
|
||||
|
||||
4. **Naming** — What should this page be called in the nav? "CCP Metrics", "VCL Multi-Vertical", "Compliance Reporting", something else?
|
||||
|
||||
5. **Retention** — How long should historical upload data be kept? (Affects trend chart depth and storage.)
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|---|---|---|
|
||||
| 1. Migration + backend endpoints | Schema changes, upload flow, scoped resolution, stats/trend/drill-down APIs | 2–3 days |
|
||||
| 2. Frontend — upload modal | Multi-file drop, filename parsing, batch preview, commit | 1–2 days |
|
||||
| 3. Frontend — report page | Stats bar, vertical table, trend chart, donut, drill-down views | 2–3 days |
|
||||
| 4. Frontend — burndown | Per-vertical burndown chart, blocker counts, forecast | 1 day |
|
||||
| 5. Testing + polish | Property tests, edge cases, error handling, loading states | 1 day |
|
||||
|
||||
Total: roughly 7–10 working days for the full POC.
|
||||
@@ -14,6 +14,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
import CompliancePage from './components/pages/CompliancePage';
|
||||
import CCPMetricsPage from './components/pages/CCPMetricsPage';
|
||||
import JiraPage from './components/pages/JiraPage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
@@ -1082,6 +1083,7 @@ export default function App() {
|
||||
{/* Page content */}
|
||||
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||
{currentPage === 'ccp-metrics' && <CCPMetricsPage />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'jira' && <JiraPage />}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2 } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||
|
||||
602
frontend/src/components/pages/CCPMetricsPage.js
Normal file
602
frontend/src/components/pages/CCPMetricsPage.js
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, TrendingUp, Target, ShieldAlert, BarChart3 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const PURPLE = '#A78BFA';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const PAGE_STYLE = {
|
||||
padding: '1.5rem 2rem',
|
||||
minHeight: '100vh',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
};
|
||||
|
||||
const CARD_STYLE = {
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(167, 139, 250, 0.2)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.25rem',
|
||||
};
|
||||
|
||||
const STAT_CARD_STYLE = {
|
||||
...CARD_STYLE,
|
||||
textAlign: 'center',
|
||||
flex: 1,
|
||||
minWidth: '140px',
|
||||
};
|
||||
|
||||
const TABLE_STYLE = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '0.8rem',
|
||||
};
|
||||
|
||||
const TH_STYLE = {
|
||||
padding: '0.75rem 1rem',
|
||||
textAlign: 'left',
|
||||
color: '#94A3B8',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
};
|
||||
|
||||
const TD_STYLE = {
|
||||
padding: '0.75rem 1rem',
|
||||
color: '#E2E8F0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
function StatsBar({ stats }) {
|
||||
if (!stats) return null;
|
||||
const items = [
|
||||
{ label: 'Total Devices', value: stats.total_devices.toLocaleString(), color: '#94A3B8' },
|
||||
{ label: 'Compliant', value: stats.compliant.toLocaleString(), color: '#10B981' },
|
||||
{ label: 'Non-Compliant', value: stats.non_compliant.toLocaleString(), color: '#EF4444' },
|
||||
{ label: 'Current %', value: `${stats.compliance_pct}%`, color: stats.compliance_pct >= stats.target_pct ? '#10B981' : '#F59E0B' },
|
||||
{ label: 'Target %', value: `${stats.target_pct}%`, color: PURPLE },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||
{items.map(({ label, value, color }) => (
|
||||
<div key={label} style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>{label}</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', color }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Donut Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
function DonutChart({ donut }) {
|
||||
if (!donut) return null;
|
||||
const data = [
|
||||
{ name: 'Blocked', count: donut.blocked.count, color: '#EF4444' },
|
||||
{ name: 'In-Progress', count: donut.in_progress.count, color: '#F59E0B' },
|
||||
];
|
||||
const total = donut.blocked.count + donut.in_progress.count;
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, flex: 1 }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Non-Compliant Status
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ResponsiveContainer width={200} height={200}>
|
||||
<PieChart>
|
||||
<Pie data={data} innerRadius={55} outerRadius={80} dataKey="count" nameKey="name">
|
||||
{data.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
|
||||
<Legend wrapperStyle={{ fontSize: '0.7rem' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ textAlign: 'center', marginLeft: '1rem' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#E2E8F0' }}>{total}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B' }}>Total Non-Compliant</div>
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.7rem' }}>
|
||||
<div style={{ color: '#EF4444' }}>Blocked: {donut.blocked.count} ({donut.blocked.pct}%)</div>
|
||||
<div style={{ color: '#F59E0B' }}>In-Progress: {donut.in_progress.count} ({donut.in_progress.pct}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trend Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
function TrendChart({ months }) {
|
||||
if (!months || months.length === 0) return (
|
||||
<div style={{ ...CARD_STYLE, flex: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: '0.8rem' }}>
|
||||
No trend data yet. Upload compliance data to generate trends.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, flex: 2 }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Compliance Trend
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={months}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis yAxisId="count" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis yAxisId="pct" orientation="right" domain={[0, 100]} unit="%" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
|
||||
<Bar yAxisId="count" dataKey="compliant_count" fill="#10B981" fillOpacity={0.6} name="Compliant Devices" />
|
||||
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
|
||||
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
|
||||
<ReferenceLine yAxisId="pct" y={months[0]?.target_pct || 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vertical Breakdown Table
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerticalTable({ breakdown, onSelectVertical }) {
|
||||
if (!breakdown || breakdown.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Vertical Breakdown
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Vertical</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliance %</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Blockers</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Last Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.map(v => {
|
||||
const pctColor = v.compliance_pct >= 95 ? '#10B981' : v.compliance_pct >= 80 ? '#F59E0B' : '#EF4444';
|
||||
return (
|
||||
<tr
|
||||
key={v.vertical}
|
||||
onClick={() => onSelectVertical(v.vertical)}
|
||||
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{v.vertical}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{v.total_devices.toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{v.compliant.toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{v.non_compliant.toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{v.compliance_pct}%</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: v.blockers > 0 ? '#EF4444' : '#64748B' }}>{v.blockers}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B', fontSize: '0.7rem' }}>{v.last_upload || '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vertical Detail View (metric drill-down)
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [categories, setCategories] = useState(null);
|
||||
const [burndown, setBurndown] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// ⚠️ CONVENTION: No error state — catch silently swallows errors without displaying them to the user
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metrics`, { credentials: 'include' }).then(r => r.json()),
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/burndown`, { credentials: 'include' }).then(r => r.json()),
|
||||
]).then(([metricsData, burndownData]) => {
|
||||
setMetrics(metricsData.metrics || []);
|
||||
setCategories(metricsData.categories || []);
|
||||
setBurndown(burndownData);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [vertical]);
|
||||
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Overview
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.2rem', color: '#E2E8F0', marginBottom: '1rem', fontWeight: '700' }}>
|
||||
<Building2 style={{ width: '20px', height: '20px', display: 'inline', marginRight: '0.5rem', color: PURPLE }} />
|
||||
{vertical}
|
||||
</h2>
|
||||
|
||||
{/* Burndown summary */}
|
||||
{burndown && (
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Non-Compliant</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{burndown.total}</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>With Dates</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#F59E0B' }}>{burndown.with_dates}</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Blockers</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{burndown.blockers}</div>
|
||||
</div>
|
||||
{burndown.projected_clear_date && (
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Projected Clear</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{burndown.projected_clear_date}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Burndown chart */}
|
||||
{burndown && burndown.monthly && Object.keys(burndown.monthly).length > 0 && (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Burndown Forecast
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ComposedChart data={Object.entries(burndown.monthly).sort(([a], [b]) => a.localeCompare(b)).map(([month, count]) => ({ month, count }))}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
|
||||
<Bar dataKey="count" fill={PURPLE} fillOpacity={0.7} name="Remediations" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category summary */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
By Category
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{categories.map(c => (
|
||||
<div key={c.category} style={{ ...CARD_STYLE, padding: '0.75rem 1rem', minWidth: '160px' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#94A3B8', marginBottom: '0.25rem' }}>{c.category}</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: c.compliance_pct >= 95 ? '#10B981' : c.compliance_pct >= 80 ? '#F59E0B' : '#EF4444' }}>
|
||||
{c.compliance_pct}%
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B' }}>{c.non_compliant} non-compliant</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics table */}
|
||||
<div style={CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Metrics
|
||||
</div>
|
||||
<table style={TABLE_STYLE}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Metric</th>
|
||||
<th style={TH_STYLE}>Description</th>
|
||||
<th style={TH_STYLE}>Category</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>%</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics && metrics.map((m, i) => {
|
||||
const pctColor = m.compliance_pct >= m.target ? '#10B981' : m.compliance_pct >= (m.target * 0.85) ? '#F59E0B' : '#EF4444';
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => onSelectMetric(m.metric_id)}
|
||||
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{m.metric_id}</td>
|
||||
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.metric_desc}</td>
|
||||
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}>{m.category}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{m.compliant}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{m.non_compliant}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{m.total}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{Number(m.compliance_pct).toFixed(1)}%</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{Number(m.target).toFixed(0)}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric Device List (deepest drill-down)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricDeviceList({ vertical, metricId, onBack }) {
|
||||
const [devices, setDevices] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// ⚠️ CONVENTION: No error state — catch silently swallows errors without displaying them to the user
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => { setDevices(data.devices || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [vertical, metricId]);
|
||||
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
|
||||
</button>
|
||||
|
||||
<h3 style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '1rem' }}>
|
||||
{vertical} / Metric {metricId} — {devices ? devices.length : 0} non-compliant devices
|
||||
</h3>
|
||||
|
||||
<div style={CARD_STYLE}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Hostname</th>
|
||||
<th style={TH_STYLE}>IP Address</th>
|
||||
<th style={TH_STYLE}>Type</th>
|
||||
<th style={TH_STYLE}>Team</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Seen Count</th>
|
||||
<th style={TH_STYLE}>First Seen</th>
|
||||
<th style={TH_STYLE}>Last Seen</th>
|
||||
<th style={TH_STYLE}>Resolution Date</th>
|
||||
<th style={TH_STYLE}>Remediation Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices && devices.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ ...TD_STYLE, fontWeight: '600', color: '#E2E8F0' }}>{d.hostname}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.ip_address || '—'}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.device_type || '—'}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.team || '—'}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{d.seen_count}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#64748B', fontSize: '0.7rem' }}>{d.first_seen || '—'}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#64748B', fontSize: '0.7rem' }}>{d.last_seen || '—'}</td>
|
||||
<td style={{ ...TD_STYLE, color: d.resolution_date ? '#F59E0B' : '#64748B', fontSize: '0.7rem' }}>{d.resolution_date || 'Not set'}</td>
|
||||
<td style={{ ...TD_STYLE, color: '#94A3B8', fontSize: '0.7rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.remediation_plan || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{devices && devices.length === 0 && (
|
||||
<tr><td colSpan={9} style={{ ...TD_STYLE, textAlign: 'center', color: '#64748B' }}>No devices found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CCPMetricsPage() {
|
||||
const { isAdmin, isEditor } = useAuth();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [trend, setTrend] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// Drill-down state
|
||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
|
||||
]).then(([statsData, trendData]) => {
|
||||
setStats(statsData);
|
||||
setTrend(trendData);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
setShowUpload(false);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Render drill-down views
|
||||
if (selectedMetric && selectedVertical) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<MetricDeviceList
|
||||
vertical={selectedVertical}
|
||||
metricId={selectedMetric}
|
||||
onBack={() => setSelectedMetric(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedVertical) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<VerticalDetailView
|
||||
vertical={selectedVertical}
|
||||
onBack={() => setSelectedVertical(null)}
|
||||
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main overview
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.3rem', fontWeight: '700', color: '#E2E8F0', margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<BarChart3 style={{ width: '24px', height: '24px', color: PURPLE }} />
|
||||
CCP Metrics — Multi-Vertical VCL
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.7rem', color: '#64748B', margin: '0.25rem 0 0 0' }}>
|
||||
Cross-organizational compliance posture across all verticals
|
||||
</p>
|
||||
</div>
|
||||
{(isAdmin() || isEditor()) && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.6rem 1.2rem',
|
||||
background: `${PURPLE}20`,
|
||||
border: `1px solid ${PURPLE}60`,
|
||||
borderRadius: '0.5rem',
|
||||
color: PURPLE,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = `${PURPLE}35`}
|
||||
onMouseLeave={e => e.currentTarget.style.background = `${PURPLE}20`}
|
||||
>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
Upload Verticals
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading / Error states */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: '#64748B' }}>
|
||||
<Loader style={{ width: '24px', height: '24px', animation: 'spin 1s linear infinite', marginBottom: '0.5rem' }} />
|
||||
<div style={{ fontSize: '0.8rem' }}>Loading compliance data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ ...CARD_STYLE, borderColor: 'rgba(239, 68, 68, 0.3)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem' }}>
|
||||
<AlertCircle style={{ width: '18px', height: '18px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && stats && (
|
||||
<>
|
||||
{/* Stats bar */}
|
||||
<StatsBar stats={stats.stats} />
|
||||
|
||||
{/* Charts row */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<TrendChart months={trend?.months} />
|
||||
<DonutChart donut={stats.donut} />
|
||||
</div>
|
||||
|
||||
{/* Vertical breakdown table */}
|
||||
<VerticalTable
|
||||
breakdown={stats.vertical_breakdown}
|
||||
onSelectVertical={setSelectedVertical}
|
||||
/>
|
||||
|
||||
{/* Last upload info */}
|
||||
{stats.last_upload_date && (
|
||||
<div style={{ marginTop: '1rem', fontSize: '0.65rem', color: '#475569', textAlign: 'right' }}>
|
||||
Last upload: {stats.last_upload_date}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && (!stats || !stats.vertical_breakdown || stats.vertical_breakdown.length === 0) && (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '3rem', marginTop: '2rem' }}>
|
||||
<Building2 style={{ width: '48px', height: '48px', color: '#334155', margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontSize: '1rem', color: '#94A3B8', marginBottom: '0.5rem' }}>No multi-vertical data yet</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginBottom: '1.5rem' }}>
|
||||
Upload per-vertical compliance xlsx files to generate cross-organizational reports.
|
||||
</div>
|
||||
{(isAdmin() || isEditor()) && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
style={{
|
||||
padding: '0.6rem 1.5rem',
|
||||
background: PURPLE,
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#FFF',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Upload Verticals
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<MultiVerticalUploadModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
428
frontend/src/components/pages/MultiVerticalUploadModal.js
Normal file
428
frontend/src/components/pages/MultiVerticalUploadModal.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, Upload, FileSpreadsheet, Loader, CheckCircle, AlertCircle, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const PURPLE = '#A78BFA';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const OVERLAY_STYLE = {
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 100,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
|
||||
const MODAL_STYLE = {
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
border: `1px solid ${PURPLE}40`,
|
||||
borderRadius: '1rem',
|
||||
width: '90%',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
padding: '2rem',
|
||||
boxShadow: `0 0 60px rgba(167, 139, 250, 0.15)`,
|
||||
};
|
||||
|
||||
const DROP_ZONE_STYLE = {
|
||||
border: `2px dashed ${PURPLE}50`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '3rem 2rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
};
|
||||
|
||||
const DROP_ZONE_ACTIVE = {
|
||||
...DROP_ZONE_STYLE,
|
||||
borderColor: PURPLE,
|
||||
background: `${PURPLE}10`,
|
||||
};
|
||||
|
||||
const FILE_ROW_STYLE = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
marginBottom: '0.5rem',
|
||||
};
|
||||
|
||||
// phase: idle → uploading → preview → committing → done | error
|
||||
export default function MultiVerticalUploadModal({ onClose, onUploadComplete }) {
|
||||
const [phase, setPhase] = useState('idle');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [previewData, setPreviewData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [commitResult, setCommitResult] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = (fileList) => {
|
||||
const newFiles = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.xlsx'));
|
||||
if (newFiles.length === 0) {
|
||||
setError('Please select .xlsx files');
|
||||
return;
|
||||
}
|
||||
setFiles(prev => {
|
||||
const existing = new Set(prev.map(f => f.name));
|
||||
const unique = newFiles.filter(f => !existing.has(f.name));
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeFile = (index) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Upload and preview
|
||||
const handlePreview = async () => {
|
||||
if (files.length === 0) return;
|
||||
setPhase('uploading');
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Upload failed');
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
setPreviewData(data);
|
||||
setPhase('preview');
|
||||
} catch (err) {
|
||||
setError('Network error: ' + err.message);
|
||||
setPhase('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Commit
|
||||
const handleCommit = async () => {
|
||||
if (!previewData || !previewData.files || previewData.files.length === 0) return;
|
||||
setPhase('committing');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/commit`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
files: previewData.files.map(f => ({
|
||||
tempFile: f.tempFile,
|
||||
vertical: f.vertical,
|
||||
report_date: f.report_date,
|
||||
filename: f.filename,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Commit failed');
|
||||
setPhase('preview');
|
||||
return;
|
||||
}
|
||||
setCommitResult(data);
|
||||
setPhase('done');
|
||||
} catch (err) {
|
||||
setError('Network error: ' + err.message);
|
||||
setPhase('preview');
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a file from preview
|
||||
const removePreviewFile = (index) => {
|
||||
setPreviewData(prev => ({
|
||||
...prev,
|
||||
files: prev.files.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY_STYLE} onClick={onClose}>
|
||||
<div style={MODAL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>
|
||||
Upload Vertical Files
|
||||
</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Phase: Idle — file selection */}
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
style={dragOver ? DROP_ZONE_ACTIVE : DROP_ZONE_STYLE}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload style={{ width: '32px', height: '32px', color: PURPLE, margin: '0 auto 0.75rem' }} />
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||
Drop xlsx files here or click to browse
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
|
||||
Expected format: VERTICAL_YYYY_MM_DD.xlsx (up to 14 files)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* Selected files list */}
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.75rem' }}>
|
||||
Selected Files ({files.length})
|
||||
</div>
|
||||
{files.map((file, i) => (
|
||||
<div key={i} style={FILE_ROW_STYLE}>
|
||||
<FileSpreadsheet style={{ width: '16px', height: '16px', color: '#10B981', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: '0.8rem', color: '#E2E8F0' }}>{file.name}</span>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>{(file.size / 1024).toFixed(0)} KB</span>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
|
||||
>
|
||||
<Trash2 style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.7rem 2rem',
|
||||
background: PURPLE,
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#FFF',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Preview Upload
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Phase: Uploading */}
|
||||
{phase === 'uploading' && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Parsing {files.length} file(s)...</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Extracting verticals and computing diffs</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: Preview */}
|
||||
{phase === 'preview' && previewData && (
|
||||
<>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Preview — {previewData.files.length} file(s) ready
|
||||
</div>
|
||||
|
||||
{/* Preview table */}
|
||||
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Vertical</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Date</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Items</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>New</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Recurring</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Resolved</th>
|
||||
<th style={{ padding: '0.5rem', textAlign: 'center', borderBottom: '1px solid rgba(255,255,255,0.08)' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.files.map((f, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '0.5rem', color: PURPLE, fontWeight: '600' }}>{f.vertical}</td>
|
||||
<td style={{ padding: '0.5rem', color: '#94A3B8' }}>{f.report_date}</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#E2E8F0' }}>{f.total_items}</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981' }}>{f.diff.new_count}</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B' }}>{f.diff.recurring_count}</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9' }}>{f.diff.resolved_count}</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'center' }}>
|
||||
{previewData.files.length > 1 && (
|
||||
<button
|
||||
onClick={() => removePreviewFile(i)}
|
||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.2rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
|
||||
>
|
||||
<Trash2 style={{ width: '12px', height: '12px' }} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<td style={{ padding: '0.5rem', fontWeight: '600', color: '#E2E8F0' }}>Total</td>
|
||||
<td></td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#E2E8F0' }}>
|
||||
{previewData.files.reduce((s, f) => s + f.total_items, 0)}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#10B981' }}>
|
||||
{previewData.files.reduce((s, f) => s + f.diff.new_count, 0)}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#F59E0B' }}>
|
||||
{previewData.files.reduce((s, f) => s + f.diff.recurring_count, 0)}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#0EA5E9' }}>
|
||||
{previewData.files.reduce((s, f) => s + f.diff.resolved_count, 0)}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Unrecognized files */}
|
||||
{previewData.unrecognized && previewData.unrecognized.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#EF4444', fontWeight: '600', marginBottom: '0.5rem' }}>
|
||||
Unrecognized Files ({previewData.unrecognized.length})
|
||||
</div>
|
||||
{previewData.unrecognized.map((u, i) => (
|
||||
<div key={i} style={{ fontSize: '0.7rem', color: '#F87171', marginBottom: '0.25rem' }}>
|
||||
{u.filename}: {u.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
onClick={() => { setPhase('idle'); setPreviewData(null); }}
|
||||
style={{
|
||||
flex: 1, padding: '0.7rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#94A3B8',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={previewData.files.length === 0}
|
||||
style={{
|
||||
flex: 2, padding: '0.7rem',
|
||||
background: PURPLE,
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#FFF',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
cursor: previewData.files.length === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: previewData.files.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Commit {previewData.files.length} File(s)
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Phase: Committing */}
|
||||
{phase === 'committing' && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Committing batch...</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Writing to database with vertical-scoped resolution</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: Done */}
|
||||
{phase === 'done' && commitResult && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<CheckCircle style={{ width: '48px', height: '48px', color: '#10B981', margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '0.5rem' }}>Upload Complete</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94A3B8', marginBottom: '1.5rem' }}>
|
||||
{commitResult.committed.length} vertical(s) committed — {commitResult.total_new} new, {commitResult.total_resolved} resolved
|
||||
</div>
|
||||
|
||||
{/* Per-vertical summary */}
|
||||
<div style={{ textAlign: 'left', marginBottom: '1.5rem' }}>
|
||||
{commitResult.committed.map((c, i) => (
|
||||
<div key={i} style={{ ...FILE_ROW_STYLE, justifyContent: 'space-between' }}>
|
||||
<span style={{ color: PURPLE, fontWeight: '600', fontSize: '0.8rem' }}>{c.vertical}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>
|
||||
+{c.new_count} new / {c.recurring_count} recurring / -{c.resolved_count} resolved
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { onUploadComplete(); }}
|
||||
style={{
|
||||
padding: '0.7rem 2rem',
|
||||
background: PURPLE,
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#FFF',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#F87171' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user