feat(compliance): add AEO compliance frontend

- CompliancePage: team tabs (STEAM/ACCESS-ENG), metric health cards with
  click-to-filter, device table with Active/Resolved tabs, hostname search,
  seen-count badges, notes indicator, empty/loading/error states
- ComplianceUploadModal: phased flow (idle→upload→preview→commit→done),
  drag-and-drop xlsx drop zone, diff summary before commit
- ComplianceDetailPanel: slide-out panel with failing metrics, surfaced
  extra fields (CVEs, SLA, OS, Splunk), upload history, notes timeline,
  per-metric note add with Ctrl+Enter submit
- NavDrawer: add Compliance nav item (teal, ShieldCheck icon)
- App.js: import and render CompliancePage on compliance route
- Fix SQL join bug in compliance route (lu ON upload_id = lu.id)
- Fix groupByHostname to use max last_seen across all metric rows
This commit is contained in:
2026-03-31 15:14:51 -06:00
parent d3d86ddcf2
commit 4676279a72
6 changed files with 1070 additions and 9 deletions

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
{ id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' },
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
];
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
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 categoryColor(category) {
return CATEGORY_COLORS[category] || '#94A3B8';
}
function MetricChip({ metricId, category, status }) {
const color = status === 'resolved' ? '#64748B' : categoryColor(category);
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.2rem 0.5rem',
background: `${color}18`,
border: `1px solid ${color}50`,
borderRadius: '0.25rem',
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
}}>
{metricId}
</span>
);
}
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded }) {
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [noteText, setNoteText] = useState('');
const [noteMetric, setNoteMetric] = useState('');
const [submitting, setSubmitting] = useState(false);
const [noteError, setNoteError] = useState(null);
const fetchDetail = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default note metric to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setNoteMetric(firstActive.metric_id);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [hostname]);
useEffect(() => { fetchDetail(); }, [fetchDetail]);
const handleAddNote = async () => {
if (!noteText.trim() || !noteMetric) return;
setSubmitting(true);
setNoteError(null);
try {
const res = await fetch(`${API_BASE}/compliance/notes`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save note');
setNoteText('');
await fetchDetail();
if (onNoteAdded) onNoteAdded();
} catch (err) {
setNoteError(err.message);
} finally {
setSubmitting(false);
}
};
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 40 }} />
{/* Panel */}
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
borderLeft: `1px solid ${TEAL}30`,
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
zIndex: 41,
display: 'flex', flexDirection: 'column',
overflowY: 'auto',
}}>
{/* Header */}
<div style={{
padding: '1.25rem 1.25rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
{hostname}
</div>
{detail && (
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{detail.ip_address && (
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
)}
{detail.device_type && (
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
)}
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
</div>
)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
</div>
{loading && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
</div>
)}
{error && (
<div style={{ padding: '1.25rem', display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0, marginTop: '1px' }} />{error}
</div>
)}
{!loading && !error && detail && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Active failing metrics */}
{activeMetrics.length > 0 && (
<Section title="Failing Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
{activeMetrics.map(m => (
<MetricRow key={m.metric_id} metric={m} />
))}
</Section>
)}
{/* Resolved metrics */}
{resolvedMetrics.length > 0 && (
<Section title="Resolved Metrics" muted>
{resolvedMetrics.map(m => (
<MetricRow key={m.metric_id} metric={m} resolved />
))}
</Section>
)}
{/* Upload history summary */}
{activeMetrics.length > 0 && (
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
{activeMetrics.map(m => (
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<MetricChip metricId={m.metric_id} category={m.category} />
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
{m.seen_count}× seen
</span>
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
</div>
</div>
))}
</Section>
)}
{/* Notes */}
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
{detail.notes.length === 0 && (
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
)}
{detail.notes.map(n => (
<div key={n.id} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
</div>
))}
{/* Add note */}
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{activeMetrics.length > 1 && (
<select
value={noteMetric}
onChange={e => setNoteMetric(e.target.value)}
style={{
width: '100%', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.25rem', color: '#CBD5E1',
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
}}>
{activeMetrics.map(m => (
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} {m.category}</option>
))}
</select>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Add a note…"
rows={2}
style={{
flex: 1, resize: 'none',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem', color: '#F8FAFC',
padding: '0.5rem 0.625rem', fontSize: '0.8rem',
outline: 'none',
}}
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
/>
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
style={{
padding: '0.5rem 0.625rem', flexShrink: 0,
background: noteText.trim() ? `${TEAL}20` : 'transparent',
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
}}>
{submitting
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
: <Send style={{ width: '16px', height: '16px' }} />}
</button>
</div>
{noteError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem' }}>{noteError}</div>}
</div>
</Section>
</div>
)}
</div>
</>
);
}
function Section({ title, icon, children, muted, grow }) {
return (
<div style={{
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
marginBottom: '0.75rem',
}}>
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
{title}
</div>
{children}
</div>
);
}
function MetricRow({ metric, resolved }) {
const color = resolved ? '#475569' : categoryColor(metric.category);
const extra = metric.extra || {};
// Surface the most useful extra fields per metric type
const highlights = [];
if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
if (extra['Normalized - Operating System'])
highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
if (extra['EOS - End of Service Life'])
highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
return (
<div style={{
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
background: resolved ? 'transparent' : `${color}08`,
border: `1px solid ${color}25`,
borderRadius: '0.375rem',
opacity: resolved ? 0.5 : 1,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
</div>
{metric.metric_desc && (
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: highlights.length ? '0.4rem' : 0, lineHeight: 1.4 }}>
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
</div>
)}
{highlights.map(h => (
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,502 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const TEAMS = ['STEAM', 'ACCESS-ENG'];
// ---------------------------------------------------------------------------
// 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 metricColor(metricId, category) {
return CATEGORY_COLORS[category] || '#94A3B8';
}
function statusColor(status) {
return STATUS_COLOR[status] || '#EF4444';
}
function pctDisplay(pct) {
return `${Math.round(pct * 100)}%`;
}
// Deduplicate summary entries — one per metric_id for the selected team
// (exclude aggregate "ALL: NTS-AEO" rows)
function teamMetrics(entries, team) {
return entries.filter(e => e.team === team);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function MetricHealthCard({ entry, active, onClick }) {
const color = statusColor(entry.status);
const isOk = entry.status === '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',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = color + '40'; }}
>
{/* Metric ID */}
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
{entry.metric_id}
</div>
{/* Category */}
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{entry.category}
</div>
{/* Compliance % */}
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
{pctDisplay(entry.compliance_pct)}
</div>
{/* Target */}
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
target {pctDisplay(entry.target)}
</div>
{/* Status pill */}
<div style={{
marginTop: '0.625rem', 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' : entry.status.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() {
const { canWrite } = useAuth();
const [activeTeam, setActiveTeam] = useState('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 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
useEffect(() => {
setMetricFilter(null);
fetchDevices(activeTeam, activeTab);
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
const refresh = () => {
fetchSummary(activeTeam);
fetchDevices(activeTeam, activeTab);
};
// In-memory filters
const filteredDevices = devices
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
const metrics = teamMetrics(summary.entries, activeTeam);
const lastUpload = summary.upload;
// Active tab counts (pre-filter for display)
const activeCount = activeTab === 'active' ? filteredDevices.length : null;
const resolvedCount = activeTab === 'resolved' ? filteredDevices.length : null;
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>
) : (
<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 ────────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
{TEAMS.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 ──────────────────────────────────── */}
{metrics.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' }}>
{metrics.map(entry => (
<MetricHealthCard
key={entry.metric_id}
entry={entry}
active={metricFilter === entry.metric_id}
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
/>
))}
</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}
{/* ── 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}
/>
)}
{/* ── Upload modal ─────────────────────────────────────────── */}
{showUpload && (
<ComplianceUploadModal
onClose={() => setShowUpload(false)}
onUploadComplete={() => { setShowUpload(false); refresh(); }}
/>
)}
</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>
);
}

View File

@@ -0,0 +1,221 @@
import React, { useState, useRef } from 'react';
import { X, Upload, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// phase: idle → uploading → preview → committing → done | error
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
const [phase, setPhase] = useState('idle');
const [previewData, setPreviewData] = useState(null);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
const handleFile = async (file) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.xlsx')) {
setError('File must be an .xlsx spreadsheet');
return;
}
setPhase('uploading');
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/compliance/preview`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Upload failed');
}
setPreviewData(data);
setPhase('preview');
} catch (err) {
setError(err.message);
setPhase('error');
}
};
const handleCommit = async () => {
if (!previewData) return;
setPhase('committing');
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/commit`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tempFile: previewData.tempFile,
filename: previewData.filename,
report_date: previewData.report_date,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Commit failed');
setPhase('done');
setTimeout(onUploadComplete, 1200);
} catch (err) {
setError(err.message);
setPhase('error');
}
};
const TEAL = '#14B8A6';
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 60,
background: 'rgba(10, 14, 39, 0.97)',
backdropFilter: 'blur(12px)',
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 ${TEAL}40`,
borderRadius: '0.75rem',
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
width: '100%', maxWidth: '480px',
padding: '2rem',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
<div>
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Upload Report
</div>
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* IDLE — drop zone */}
{phase === 'idle' && (
<>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
style={{
border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
borderRadius: '0.5rem',
padding: '2.5rem',
textAlign: 'center',
cursor: 'pointer',
background: dragOver ? `${TEAL}08` : 'transparent',
transition: 'all 0.2s',
}}>
<FileSpreadsheet style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', opacity: 0.8 }} />
<div style={{ color: '#CBD5E1', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
Drop your xlsx file here or <span style={{ color: TEAL }}>browse</span>
</div>
<div style={{ color: '#475569', fontSize: '0.75rem' }}>NTS_AEO_YYYY_MM_DD.xlsx</div>
</div>
<input ref={fileInputRef} type="file" accept=".xlsx" style={{ display: 'none' }}
onChange={e => handleFile(e.target.files[0])} />
{error && (
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />{error}
</div>
)}
</>
)}
{/* UPLOADING / COMMITTING — spinner */}
{(phase === 'uploading' || phase === 'committing') && (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
</div>
</div>
)}
{/* PREVIEW — diff summary + confirm */}
{phase === 'preview' && previewData && (
<>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{previewData.filename}
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
</div>
{[
{ label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
{ label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
{ label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
].map(({ label, count, color, icon }) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: `1px solid ${color}25`,
}}>
<span style={{ color: '#CBD5E1', fontSize: '0.875rem' }}>
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
{label}
</span>
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
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={handleCommit}
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
Confirm Upload
</button>
</div>
</>
)}
{/* DONE */}
{phase === 'done' && (
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Upload committed
</div>
</div>
)}
{/* ERROR */}
{phase === 'error' && (
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
<button onClick={() => { setPhase('idle'); setError(null); }}
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
Try Again
</button>
</div>
)}
</div>
</div>
);
}