The host list on the compliance page showed stale resolution date and remediation plan values after editing them in the detail sidebar, until an unrelated refresh (filter, team, or tab change) ran. handleSaveMetadata re-fetched only the panel's own detail and never notified the parent. Add an onMetadataSaved callback invoked after a successful metadata PATCH and wire it to the existing list refresh in CompliancePage, mirroring the onNoteAdded pattern. The list now reflects saved changes immediately. Closes #23
910 lines
46 KiB
JavaScript
910 lines
46 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
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';
|
||
const TEAL = '#14B8A6';
|
||
|
||
// Build definitions lookup map once at module level
|
||
const METRIC_DEFINITIONS = {};
|
||
for (const def of metricDefinitionsRaw) {
|
||
METRIC_DEFINITIONS[def.metric_id] = def;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
const STATUS_COLOR = {
|
||
'Meets/Exceeds Target': '#10B981',
|
||
'Within 15% of Target': '#F59E0B',
|
||
'Below 15% of Target': '#EF4444',
|
||
};
|
||
|
||
const CATEGORY_COLORS = {
|
||
'Vulnerability Management': '#EF4444',
|
||
'Access & MFA': '#F59E0B',
|
||
'Logging & Monitoring': '#8B5CF6',
|
||
'End-of-Life OS': '#F97316',
|
||
'Decommissioned Assets': '#64748B',
|
||
'Asset Data Quality': '#64748B',
|
||
'Application Security': '#0EA5E9',
|
||
'Disaster Recovery': TEAL,
|
||
'Endpoint Protection': '#F97316',
|
||
};
|
||
|
||
function statusColor(status) {
|
||
return STATUS_COLOR[status] || '#EF4444';
|
||
}
|
||
|
||
function pctDisplay(pct) {
|
||
return `${Math.round(pct * 100)}%`;
|
||
}
|
||
|
||
const STATUS_SEVERITY = {
|
||
'Below 15% of Target': 0,
|
||
'Within 15% of Target': 1,
|
||
'Meets/Exceeds Target': 2,
|
||
};
|
||
|
||
function computeWorstStatus(statuses) {
|
||
let worst = 'Meets/Exceeds Target';
|
||
let worstSev = 2;
|
||
for (const s of statuses) {
|
||
const sev = STATUS_SEVERITY[s] ?? 0;
|
||
if (sev < worstSev) {
|
||
worstSev = sev;
|
||
worst = s;
|
||
}
|
||
}
|
||
return worst;
|
||
}
|
||
|
||
function groupByMetricFamily(allEntries, team) {
|
||
const teamEntries = allEntries.filter(e => e.team === team);
|
||
const familyMap = {};
|
||
|
||
for (const entry of teamEntries) {
|
||
const baseId = entry.metric_id;
|
||
if (!baseId) continue;
|
||
if (!familyMap[baseId]) {
|
||
familyMap[baseId] = [];
|
||
}
|
||
familyMap[baseId].push(entry);
|
||
}
|
||
|
||
return Object.entries(familyMap).map(([metricId, entries]) => ({
|
||
metricId,
|
||
entries,
|
||
category: entries[0].category,
|
||
target: entries[0].target,
|
||
worstStatus: computeWorstStatus(entries.map(e => e.status)),
|
||
}));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sub-components
|
||
// ---------------------------------------------------------------------------
|
||
function VariantPill({ entry, label }) {
|
||
const color = statusColor(entry.status);
|
||
const isOk = entry.status === 'Meets/Exceeds Target';
|
||
const hasRawCounts = entry.compliant != null && entry.total != null && entry.total > 0;
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.3rem',
|
||
padding: '0.15rem 0.45rem',
|
||
background: `${color}1F`,
|
||
borderRadius: '0.2rem',
|
||
border: `1px solid ${color}25`,
|
||
fontSize: '0.62rem',
|
||
fontFamily: 'monospace',
|
||
color: '#CBD5E1',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{!isOk && (
|
||
<span style={{
|
||
width: '4px', height: '4px', borderRadius: '50%',
|
||
background: color, flexShrink: 0,
|
||
boxShadow: `0 0 5px ${color}`,
|
||
}} />
|
||
)}
|
||
{label && <span style={{ color: '#94A3B8' }}>{label}</span>}
|
||
<span style={{ color, fontWeight: '600' }}>{pctDisplay(entry.compliance_pct)}</span>
|
||
{hasRawCounts && (
|
||
<span style={{ color: '#64748B', fontSize: '0.58rem' }}>({entry.compliant}/{entry.total})</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function MetricHealthCard({ family, active, onClick, onInfoClick, definitionLookup }) {
|
||
const color = statusColor(family.worstStatus);
|
||
const isOk = family.worstStatus === 'Meets/Exceeds Target';
|
||
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
style={{
|
||
background: active
|
||
? `${color}18`
|
||
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||
border: `1.5px solid ${active ? color : color + '40'}`,
|
||
borderRadius: '0.5rem',
|
||
padding: '0.875rem 1rem',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
transition: 'all 0.15s',
|
||
minWidth: '160px',
|
||
flex: '1 1 0',
|
||
position: 'relative',
|
||
}}
|
||
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
||
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = active ? color : color + '40'; }}
|
||
>
|
||
{/* Info icon — top-right */}
|
||
<span
|
||
onClick={(e) => { e.stopPropagation(); onInfoClick(family.metricId); }}
|
||
style={{
|
||
position: 'absolute',
|
||
top: '0.5rem',
|
||
right: '0.5rem',
|
||
cursor: 'pointer',
|
||
color: '#475569',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '0.15rem',
|
||
borderRadius: '0.2rem',
|
||
transition: 'color 0.15s',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; }}
|
||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; }}
|
||
>
|
||
<Info style={{ width: '13px', height: '13px' }} />
|
||
</span>
|
||
|
||
{/* Metric ID */}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem', paddingRight: '1.25rem' }}>
|
||
{family.metricId}
|
||
</div>
|
||
|
||
{/* Category */}
|
||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||
{family.category}
|
||
</div>
|
||
|
||
{/* Variant pills */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
||
{family.entries.map((entry, i) => {
|
||
// Only show a label when there are multiple variants to differentiate
|
||
let label = null;
|
||
if (family.entries.length > 1) {
|
||
label = entry.priority || `#${i + 1}`;
|
||
}
|
||
return <VariantPill key={entry.metric_id + '-' + i} entry={entry} label={label} />;
|
||
})}
|
||
</div>
|
||
|
||
{/* Target */}
|
||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginBottom: '0.5rem' }}>
|
||
target {pctDisplay(family.target)}
|
||
</div>
|
||
|
||
{/* Status pill */}
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
color, padding: '0.2rem 0.5rem',
|
||
background: `${color}12`, borderRadius: '999px',
|
||
border: `1px solid ${color}30`,
|
||
}}>
|
||
<span style={{
|
||
width: '5px', height: '5px', borderRadius: '50%',
|
||
background: color, flexShrink: 0,
|
||
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||
}} />
|
||
{isOk ? 'OK' : family.worstStatus.replace(' of Target', '')}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function MetricBadge({ metricId, category }) {
|
||
const color = CATEGORY_COLORS[category] || '#94A3B8';
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center',
|
||
padding: '0.15rem 0.45rem',
|
||
background: `${color}15`, border: `1px solid ${color}40`,
|
||
borderRadius: '0.2rem', color,
|
||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{metricId}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function SeenBadge({ count }) {
|
||
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
|
||
return (
|
||
<span style={{
|
||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
|
||
color, padding: '0.15rem 0.4rem',
|
||
background: `${color}12`, borderRadius: '0.2rem',
|
||
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
|
||
}}>
|
||
{count}×
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main Page
|
||
// ---------------------------------------------------------------------------
|
||
export default function CompliancePage({ onNavigate }) {
|
||
const { canWrite, isAdmin, getAvailableTeams, adminScope } = useAuth();
|
||
|
||
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 });
|
||
const [devices, setDevices] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [selectedHost, setSelectedHost] = useState(null);
|
||
const [showUpload, setShowUpload] = useState(false);
|
||
const [rollbackConfirm, setRollbackConfirm] = useState(false);
|
||
const [rollbackLoading, setRollbackLoading] = useState(false);
|
||
const [rollbackResult, setRollbackResult] = useState(null);
|
||
const [infoMetric, setInfoMetric] = useState(null);
|
||
const [hoveredMetric, setHoveredMetric] = useState(null);
|
||
const hoverTimeoutRef = useRef(null);
|
||
const hoveredCardRef = useRef(null);
|
||
|
||
const fetchSummary = useCallback(async (team) => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
setSummary(data);
|
||
} catch { /* silent */ }
|
||
}, []);
|
||
|
||
const fetchDevices = useCallback(async (team, tab) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to load');
|
||
setDevices(data.devices || []);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
setDevices([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setMetricFilter(null);
|
||
setHostSearch('');
|
||
setSelectedHost(null);
|
||
fetchSummary(activeTeam);
|
||
fetchDevices(activeTeam, activeTab);
|
||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// When admin scope changes, reset to first available team
|
||
useEffect(() => {
|
||
const teams = getAvailableTeams();
|
||
if (teams.length > 0 && !teams.includes(activeTeam)) {
|
||
setActiveTeam(teams[0]);
|
||
}
|
||
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
useEffect(() => {
|
||
setMetricFilter(null);
|
||
fetchDevices(activeTeam, activeTab);
|
||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const refresh = () => {
|
||
fetchSummary(activeTeam);
|
||
fetchDevices(activeTeam, activeTab);
|
||
};
|
||
|
||
const handleRollback = async () => {
|
||
if (!lastUpload) return;
|
||
setRollbackLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/compliance/rollback/${lastUpload.id}`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Rollback failed');
|
||
setRollbackResult(data);
|
||
setRollbackConfirm(false);
|
||
refresh();
|
||
// Auto-dismiss result after 4 seconds
|
||
setTimeout(() => setRollbackResult(null), 4000);
|
||
} catch (err) {
|
||
setRollbackResult({ error: err.message });
|
||
} finally {
|
||
setRollbackLoading(false);
|
||
}
|
||
};
|
||
|
||
// In-memory filters
|
||
const filteredDevices = devices
|
||
.filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
|
||
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||
|
||
const families = groupByMetricFamily(summary.entries, activeTeam);
|
||
const lastUpload = summary.upload;
|
||
|
||
return (
|
||
<div style={{ paddingBottom: '2rem' }}>
|
||
|
||
{/* ── Page header ─────────────────────────────────────────── */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
|
||
<div>
|
||
<h2 style={{
|
||
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
|
||
}}>
|
||
AEO Compliance
|
||
</h2>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||
{lastUpload ? (
|
||
<>
|
||
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||
</span>
|
||
{isAdmin() && (
|
||
<button
|
||
onClick={() => setRollbackConfirm(true)}
|
||
title="Rollback last upload"
|
||
style={{
|
||
background: 'none', border: '1px solid rgba(239,68,68,0.25)',
|
||
borderRadius: '0.25rem', padding: '0.15rem 0.4rem',
|
||
cursor: 'pointer', color: '#64748B',
|
||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||
fontSize: '0.62rem', fontFamily: 'monospace',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.6)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.25)'; }}
|
||
>
|
||
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||
Rollback
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||
)}
|
||
{summary.overall_scores?.customer_network != null && (
|
||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
|
||
</span>
|
||
)}
|
||
{summary.overall_scores?.vertical != null && (
|
||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||
<button onClick={refresh} title="Refresh"
|
||
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
|
||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||
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"
|
||
style={{
|
||
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
|
||
color: TEAL, 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',
|
||
}}>
|
||
<Upload style={{ width: '14px', height: '14px' }} />
|
||
Upload Report
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── VCL Report View ─────────────────────────────────────── */}
|
||
{vclView && (
|
||
<VCLReportPage />
|
||
)}
|
||
|
||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||
{!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)',
|
||
background: 'rgba(245, 158, 11, 0.05)',
|
||
fontFamily: 'monospace', fontSize: '0.8rem', color: '#F59E0B',
|
||
textAlign: 'center'
|
||
}}>
|
||
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;
|
||
return (
|
||
<button key={team} onClick={() => setActiveTeam(team)}
|
||
style={{
|
||
padding: '0.5rem 1.25rem', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||
borderRadius: '0.375rem',
|
||
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
|
||
background: isActive ? `${TEAL}18` : 'transparent',
|
||
color: isActive ? TEAL : '#475569',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
|
||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
|
||
{team}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||
{!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
|
||
{metricFilter && (
|
||
<button onClick={() => setMetricFilter(null)}
|
||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||
× clear filter
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||
{families.map(family => {
|
||
const familyIds = family.entries.map(e => e.metric_id);
|
||
const isActive = metricFilter !== null && metricFilter.length === familyIds.length && familyIds.every(id => metricFilter.includes(id));
|
||
return (
|
||
<div
|
||
key={family.metricId}
|
||
onMouseEnter={(e) => {
|
||
hoveredCardRef.current = e.currentTarget;
|
||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
||
hoverTimeoutRef.current = setTimeout(() => setHoveredMetric(family.metricId), 300);
|
||
}}
|
||
onMouseLeave={() => {
|
||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
||
hoverTimeoutRef.current = null;
|
||
hoveredCardRef.current = null;
|
||
setHoveredMetric(null);
|
||
}}
|
||
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px' }}
|
||
>
|
||
<MetricHealthCard
|
||
family={family}
|
||
active={isActive}
|
||
onClick={() => setMetricFilter(isActive ? null : familyIds)}
|
||
onInfoClick={(metricId) => setInfoMetric(metricId)}
|
||
definitionLookup={METRIC_DEFINITIONS}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Hover tooltip */}
|
||
{hoveredMetric && (() => {
|
||
const family = families.find(f => f.metricId === hoveredMetric);
|
||
if (!family) return null;
|
||
const def = METRIC_DEFINITIONS[hoveredMetric];
|
||
const rect = hoveredCardRef.current ? hoveredCardRef.current.getBoundingClientRect() : null;
|
||
if (!rect) return null;
|
||
const tooltipTop = Math.min(rect.bottom + 8, window.innerHeight - 180);
|
||
const tooltipLeft = Math.max(8, Math.min(rect.left, window.innerWidth - 320));
|
||
return (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: tooltipTop,
|
||
left: tooltipLeft,
|
||
zIndex: 50,
|
||
width: '300px',
|
||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||
border: '1px solid rgba(20,184,166,0.25)',
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||
padding: '0.75rem 0.875rem',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700', color: '#E2E8F0', marginBottom: '0.4rem', lineHeight: 1.3 }}>
|
||
{def ? def.metric_title : (family.entries[0]?.description || hoveredMetric)}
|
||
</div>
|
||
{def && def.business_justification && (
|
||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.3rem', lineHeight: 1.4 }}>
|
||
{def.business_justification}
|
||
</div>
|
||
)}
|
||
{def && def.data_sources_required && (
|
||
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace' }}>
|
||
Sources: {def.data_sources_required}
|
||
</div>
|
||
)}
|
||
{!def && family.entries[0]?.description && (
|
||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', lineHeight: 1.4 }}>
|
||
{family.entries[0].description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : lastUpload === null ? (
|
||
<div style={{
|
||
marginBottom: '1.5rem', padding: '2rem',
|
||
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
|
||
textAlign: 'center',
|
||
}}>
|
||
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||
No compliance data — upload a report to get started
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||
{!vclView && <ComplianceChartsPanel />}
|
||
|
||
{/* ── Device table ─────────────────────────────────────────── */}
|
||
{!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',
|
||
}}>
|
||
{/* Table toolbar */}
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||
}}>
|
||
{/* Active / Resolved tabs */}
|
||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||
{['active', 'resolved'].map(tab => {
|
||
const isActive = activeTab === tab;
|
||
return (
|
||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||
style={{
|
||
padding: '0.35rem 0.875rem', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||
borderRadius: '0.25rem',
|
||
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
|
||
background: isActive ? `${TEAL}12` : 'transparent',
|
||
color: isActive ? TEAL : '#475569',
|
||
}}>
|
||
{tab}
|
||
{isActive && (
|
||
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
|
||
({loading ? '…' : filteredDevices.length})
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Hostname search */}
|
||
<input
|
||
value={hostSearch}
|
||
onChange={e => setHostSearch(e.target.value)}
|
||
placeholder="Search hostname…"
|
||
style={{
|
||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
|
||
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||
width: '220px',
|
||
}}
|
||
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
|
||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
|
||
/>
|
||
</div>
|
||
|
||
{/* Column headers */}
|
||
<div style={{
|
||
display: 'grid',
|
||
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',
|
||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||
}}>
|
||
<span>Hostname</span>
|
||
<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>
|
||
|
||
{/* Rows */}
|
||
{loading ? (
|
||
<div style={{ padding: '3rem', textAlign: 'center' }}>
|
||
<Loader style={{ width: '28px', height: '28px', color: TEAL, margin: '0 auto', animation: 'spin 1s linear infinite' }} />
|
||
</div>
|
||
) : error ? (
|
||
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
|
||
</div>
|
||
) : filteredDevices.length === 0 ? (
|
||
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||
</div>
|
||
) : (
|
||
filteredDevices.map(device => (
|
||
<DeviceRow
|
||
key={device.hostname}
|
||
device={device}
|
||
selected={selectedHost === device.hostname}
|
||
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>}
|
||
|
||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||
{selectedHost && (
|
||
<ComplianceDetailPanel
|
||
hostname={selectedHost}
|
||
onClose={() => setSelectedHost(null)}
|
||
onNoteAdded={refresh}
|
||
onMetadataSaved={refresh}
|
||
onNavigate={onNavigate}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||
{showUpload && (
|
||
<ComplianceUploadModal
|
||
onClose={() => setShowUpload(false)}
|
||
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Metric info panel ───────────────────────────────────── */}
|
||
{infoMetric && (
|
||
<MetricInfoPanel
|
||
metricId={infoMetric}
|
||
definition={METRIC_DEFINITIONS[infoMetric] || null}
|
||
summaryEntries={(families.find(f => f.metricId === infoMetric) || {}).entries || []}
|
||
onClose={() => setInfoMetric(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Rollback confirmation modal ──────────────────────────── */}
|
||
{rollbackConfirm && lastUpload && (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 60,
|
||
background: 'rgba(10, 14, 39, 0.95)',
|
||
backdropFilter: 'blur(8px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: '1rem',
|
||
}}>
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||
border: '1px solid rgba(239,68,68,0.3)',
|
||
borderRadius: '0.75rem',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
|
||
width: '100%', maxWidth: '420px',
|
||
padding: '2rem',
|
||
}}>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#EF4444', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '1rem' }}>
|
||
Rollback Upload
|
||
</div>
|
||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: '1.5', marginBottom: '0.5rem' }}>
|
||
This will reverse the most recent upload:
|
||
</div>
|
||
<div style={{
|
||
fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8',
|
||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||
padding: '0.625rem 0.75rem', marginBottom: '1.25rem',
|
||
border: '1px solid rgba(239,68,68,0.15)',
|
||
}}>
|
||
<div><span style={{ color: '#64748B' }}>File:</span> {lastUpload.report_date || 'unknown date'}</div>
|
||
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#475569' }}>
|
||
New items will be deleted, resolved items will be reactivated, and the upload record will be removed.
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||
<button
|
||
onClick={() => setRollbackConfirm(false)}
|
||
style={{
|
||
flex: 1, padding: '0.625rem', background: 'transparent',
|
||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
|
||
color: '#64748B', cursor: 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||
}}
|
||
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={handleRollback}
|
||
disabled={rollbackLoading}
|
||
style={{
|
||
flex: 2, padding: '0.625rem',
|
||
background: rollbackLoading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.1)',
|
||
border: '1px solid #EF4444',
|
||
borderRadius: '0.375rem',
|
||
color: '#EF4444', cursor: rollbackLoading ? 'wait' : 'pointer',
|
||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||
opacity: rollbackLoading ? 0.6 : 1,
|
||
}}
|
||
onMouseEnter={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.18)'; }}
|
||
onMouseLeave={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; }}>
|
||
{rollbackLoading
|
||
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Rolling back…</>
|
||
: <><RotateCcw style={{ width: '14px', height: '14px' }} /> Confirm Rollback</>
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Rollback result toast ────────────────────────────────── */}
|
||
{rollbackResult && (
|
||
<div style={{
|
||
position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 70,
|
||
background: rollbackResult.error
|
||
? 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)'
|
||
: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||
border: `1px solid ${rollbackResult.error ? 'rgba(239,68,68,0.4)' : 'rgba(16,185,129,0.4)'}`,
|
||
borderRadius: '0.5rem',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||
padding: '0.875rem 1.25rem',
|
||
maxWidth: '360px',
|
||
fontFamily: 'monospace', fontSize: '0.75rem',
|
||
color: rollbackResult.error ? '#F87171' : '#10B981',
|
||
cursor: 'pointer',
|
||
}}
|
||
onClick={() => setRollbackResult(null)}
|
||
>
|
||
{rollbackResult.error ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||
{rollbackResult.error}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||
<RotateCcw style={{ width: '14px', height: '14px' }} />
|
||
{rollbackResult.message}
|
||
</div>
|
||
{rollbackResult.rolled_back && (
|
||
<div style={{ fontSize: '0.68rem', color: '#64748B' }}>
|
||
{rollbackResult.rolled_back.items_deleted} items deleted, {rollbackResult.rolled_back.items_reactivated} reactivated
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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: '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',
|
||
background: selected ? `${TEAL}08` : 'transparent',
|
||
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
|
||
transition: 'all 0.15s',
|
||
alignItems: 'center',
|
||
}}
|
||
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
{/* Hostname */}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{device.hostname}
|
||
</div>
|
||
|
||
{/* IP */}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
|
||
{device.ip_address || '—'}
|
||
</div>
|
||
|
||
{/* Type */}
|
||
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{device.device_type || '—'}
|
||
</div>
|
||
|
||
{/* Failing metrics */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{device.failing_metrics.map(m => (
|
||
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Resolution Date */}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
|
||
{device.resolution_date ? device.resolution_date.slice(0, 10) : '—'}
|
||
</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} />
|
||
</div>
|
||
|
||
{/* Notes indicator */}
|
||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||
{device.has_notes && (
|
||
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Named exports for testing
|
||
export { computeWorstStatus, groupByMetricFamily };
|