Add grouped metric health cards with variant pills, hover tooltips, and info panel to compliance page

This commit is contained in:
root
2026-04-22 18:30:59 +00:00
parent aa3ce3bae9
commit 0bea387ac9
10 changed files with 2923 additions and 31 deletions

View File

@@ -1,14 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw } from 'lucide-react';
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';
const TEAMS = ['STEAM', 'ACCESS-ENG'];
// Build definitions lookup map once at module level
const METRIC_DEFINITIONS = {};
for (const def of metricDefinitionsRaw) {
METRIC_DEFINITIONS[def.metric_id] = def;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -38,18 +46,83 @@ 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);
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 MetricHealthCard({ entry, active, onClick }) {
function VariantPill({ entry }) {
const color = statusColor(entry.status);
const isOk = entry.status === 'Meets/Exceeds Target';
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}`,
}} />
)}
<span style={{ color: '#94A3B8' }}>{entry.description || entry.team}</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
@@ -66,33 +139,58 @@ function MetricHealthCard({ entry, active, onClick }) {
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 = color + '40'; }}
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' }}>
{entry.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.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{entry.category}
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{family.category}
</div>
{/* Compliance % */}
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
{pctDisplay(entry.compliance_pct)}
{/* Variant pills */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginBottom: '0.5rem' }}>
{family.entries.map((entry, i) => (
<VariantPill key={entry.metric_id + '-' + i} entry={entry} />
))}
</div>
{/* Target */}
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
target {pctDisplay(entry.target)}
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginBottom: '0.5rem' }}>
target {pctDisplay(family.target)}
</div>
{/* Status pill */}
<div style={{
marginTop: '0.625rem', display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
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',
@@ -103,7 +201,7 @@ function MetricHealthCard({ entry, active, onClick }) {
background: color, flexShrink: 0,
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
}} />
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
{isOk ? 'OK' : family.worstStatus.replace(' of Target', '')}
</div>
</button>
);
@@ -158,6 +256,10 @@ export default function CompliancePage({ onNavigate }) {
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 {
@@ -225,10 +327,10 @@ export default function CompliancePage({ onNavigate }) {
// In-memory filters
const filteredDevices = devices
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
.filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
const metrics = teamMetrics(summary.entries, activeTeam);
const families = groupByMetricFamily(summary.entries, activeTeam);
const lastUpload = summary.upload;
return (
@@ -336,7 +438,7 @@ export default function CompliancePage({ onNavigate }) {
</div>
{/* ── Metric health cards ──────────────────────────────────── */}
{metrics.length > 0 ? (
{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
@@ -348,15 +450,81 @@ export default function CompliancePage({ onNavigate }) {
)}
</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)}
/>
))}
{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={{
@@ -486,6 +654,16 @@ export default function CompliancePage({ onNavigate }) {
/>
)}
{/* ── 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={{
@@ -655,3 +833,6 @@ function DeviceRow({ device, selected, onClick }) {
</div>
);
}
// Named exports for testing
export { computeWorstStatus, groupByMetricFamily };