495 lines
29 KiB
JavaScript
495 lines
29 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2 } from 'lucide-react';
|
||
import ConfirmModal from '../ConfirmModal';
|
||
|
||
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, 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 [pendingConfirm, setPendingConfirm] = 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 handleDeleteNote = async (noteId, hasGroup) => {
|
||
setPendingConfirm({
|
||
title: 'Delete Note',
|
||
message: 'Delete this note?',
|
||
confirmText: 'Delete',
|
||
onConfirm: async () => {
|
||
setPendingConfirm(null);
|
||
try {
|
||
const url = hasGroup
|
||
? `${API_BASE}/compliance/notes/${noteId}?group=true`
|
||
: `${API_BASE}/compliance/notes/${noteId}`;
|
||
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to delete note');
|
||
await fetchDetail();
|
||
if (onNoteAdded) onNoteAdded();
|
||
} catch (err) {
|
||
setNoteError(err.message);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
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} onNavigate={onNavigate} />
|
||
))}
|
||
</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>
|
||
)}
|
||
{(() => {
|
||
// 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 => (
|
||
<div key={g.key} 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: 'flex-start', marginBottom: '0.3rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{g.notes.map(n => (
|
||
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
|
||
</span>
|
||
<button
|
||
onClick={() => handleDeleteNote(g.notes[0].id, !!g.notes[0].group_id)}
|
||
title="Delete note"
|
||
style={{
|
||
background: 'none', border: '1px solid rgba(239,68,68,0.15)',
|
||
borderRadius: '0.25rem', padding: '0.2rem',
|
||
cursor: 'pointer', color: '#334155',
|
||
transition: 'all 0.15s', lineHeight: 1,
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.5)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.15)'; }}
|
||
>
|
||
<Trash2 style={{ width: '11px', height: '11px' }} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
|
||
</div>
|
||
));
|
||
})()}
|
||
|
||
{/* Add note */}
|
||
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||
{activeMetrics.length > 1 && (() => {
|
||
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
|
||
return (
|
||
<div style={{ marginBottom: '0.5rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
|
||
Metrics
|
||
</span>
|
||
<button
|
||
onClick={() => {
|
||
if (allSelected) {
|
||
setSelectedMetrics([activeMetrics[0].metric_id]);
|
||
} else {
|
||
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
|
||
}
|
||
}}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: '0.68rem', fontFamily: 'monospace',
|
||
color: TEAL, padding: 0,
|
||
transition: 'opacity 0.15s',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
|
||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||
>
|
||
{allSelected ? 'Deselect All' : 'Select All'}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||
{activeMetrics.map(m => {
|
||
const isSelected = selectedMetrics.includes(m.metric_id);
|
||
const color = categoryColor(m.category);
|
||
return (
|
||
<button
|
||
key={m.metric_id}
|
||
onClick={() => {
|
||
if (isSelected) {
|
||
if (selectedMetrics.length > 1) {
|
||
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
|
||
}
|
||
} else {
|
||
setSelectedMetrics([...selectedMetrics, m.metric_id]);
|
||
}
|
||
}}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center',
|
||
padding: '0.25rem 0.5rem',
|
||
background: isSelected ? `${color}25` : `${color}08`,
|
||
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
|
||
borderRadius: '0.25rem',
|
||
color: isSelected ? color : `${color}90`,
|
||
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
|
||
transition: 'all 0.15s',
|
||
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
|
||
}}
|
||
>
|
||
{m.metric_id}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
<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() || selectedMetrics.length === 0 || submitting}
|
||
style={{
|
||
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
|
||
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
|
||
cursor: (noteText.trim() && selectedMetrics.length > 0) ? '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>
|
||
|
||
{/* Confirmation Modal */}
|
||
<ConfirmModal
|
||
open={!!pendingConfirm}
|
||
title={pendingConfirm?.title}
|
||
message={pendingConfirm?.message}
|
||
confirmText={pendingConfirm?.confirmText}
|
||
variant="danger"
|
||
onConfirm={pendingConfirm?.onConfirm}
|
||
onCancel={() => setPendingConfirm(null)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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, onNavigate }) {
|
||
const color = resolved ? '#475569' : categoryColor(metric.category);
|
||
const extra = metric.extra || {};
|
||
|
||
const ivantiId = (!resolved && metric.metric_id?.startsWith('2.3'))
|
||
? (extra['Ivanti_Vulnerability_ID'] || null)
|
||
: null;
|
||
|
||
// 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 || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
|
||
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
|
||
</div>
|
||
)}
|
||
{ivantiId && (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
|
||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
|
||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
|
||
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||
</div>
|
||
{onNavigate && (
|
||
<button
|
||
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||
style={{
|
||
flexShrink: 0, marginLeft: '0.5rem',
|
||
background: 'rgba(14,165,233,0.1)',
|
||
border: '1px solid rgba(14,165,233,0.4)',
|
||
borderRadius: '0.25rem',
|
||
color: '#0EA5E9',
|
||
fontSize: '0.65rem', fontFamily: 'monospace',
|
||
padding: '0.2rem 0.5rem',
|
||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||
>
|
||
View in Triage →
|
||
</button>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|