603 lines
32 KiB
JavaScript
603 lines
32 KiB
JavaScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|