Files
cve-dashboard/frontend/src/components/pages/CompliancePage.js
Jordan Ramos 2656df94d3 feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema)
- Create shared KNOWN_TEAMS constant and validateTeams helper
- Expose user teams in auth middleware, login, and /me responses
- Add bu_teams CRUD to user management routes with audit logging
- Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var
- Add query-time team filtering to GET /findings and /findings/counts
- Update AuthContext with teams helpers and admin scope toggle
- Create AdminScopeToggle component (My Teams / All BUs)
- Scope ReportingPage findings fetch by user teams
- Scope CompliancePage team selector by user teams
- Scope ExportsPage findings exports by user teams
- Add BU teams multi-select to UserManagement create/edit forms
- Display team badges in user list table
2026-05-05 11:04:53 -06:00

864 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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';
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>
</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 [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>
{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>
{/* ── Team tabs ────────────────────────────────────────────── */}
{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>
) : (
<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 ──────────────────────────────────── */}
{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 ──────────────────────────────── */}
<ComplianceChartsPanel />
{/* ── Device table ─────────────────────────────────────────── */}
<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: '2.5fr 1.1fr 1fr 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>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}
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 }) {
return (
<div
onClick={onClick}
style={{
display: 'grid',
gridTemplateColumns: '2.5fr 1.1fr 1fr 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>
{/* 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 };