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 ( {metricId} ); } export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [noteText, setNoteText] = useState(''); const [selectedMetrics, setSelectedMetrics] = 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 selected metrics to first active failing metric const firstActive = (data.metrics || []).find(m => m.status === 'active'); if (firstActive) setSelectedMetrics([firstActive.metric_id]); } catch (err) { setError(err.message); } finally { setLoading(false); } }, [hostname]); useEffect(() => { fetchDetail(); }, [fetchDetail]); const handleAddNote = async () => { if (!noteText.trim() || selectedMetrics.length === 0) 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_ids: selectedMetrics, 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 */}
{/* Panel */}
{/* Header */}
{hostname}
{detail && (
{detail.ip_address && ( {detail.ip_address} )} {detail.device_type && ( · {detail.device_type} )} · {detail.team}
)}
{loading && (
)} {error && (
{error}
)} {!loading && !error && detail && (
{/* Active failing metrics */} {activeMetrics.length > 0 && (
}> {activeMetrics.map(m => ( ))}
)} {/* Resolved metrics */} {resolvedMetrics.length > 0 && (
{resolvedMetrics.map(m => ( ))}
)} {/* Upload history summary */} {activeMetrics.length > 0 && (
}> {activeMetrics.map(m => (
2 ? '#F59E0B' : '#94A3B8' }}> {m.seen_count}× seen {m.first_seen && since {m.first_seen}}
))}
)} {/* Notes */}
} grow> {detail.notes.length === 0 && (
No notes yet
)} {(() => { // Build a lookup map for metric categories (active + resolved) const metricMap = {}; (detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; }); // Group notes by group_id, preserving reverse chronological order const grouped = []; const seen = new Set(); // detail.notes is already sorted newest-first from the API for (const n of detail.notes) { const gid = n.group_id; if (!gid) { // Legacy note without group_id — render individually grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at }); } else if (!seen.has(gid)) { seen.add(gid); const group = detail.notes.filter(x => x.group_id === gid); grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at }); } } return grouped.map(g => (
{g.notes.map(n => ( ))}
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
{g.note}
)); })()} {/* Add note */}
{activeMetrics.length > 1 && (() => { const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id)); return (
Metrics
{activeMetrics.map(m => { const isSelected = selectedMetrics.includes(m.metric_id); const color = categoryColor(m.category); return ( ); })}
); })()}