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:
502
frontend/src/components/pages/CompliancePage.js
Normal file
502
frontend/src/components/pages/CompliancePage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user