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

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

View File

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

View File

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

View File

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

View File

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