Add grouped metric health cards with variant pills, hover tooltips, and info panel to compliance page
This commit is contained in:
@@ -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 };
|
||||
|
||||
161
frontend/src/components/pages/MetricInfoPanel.js
Normal file
161
frontend/src/components/pages/MetricInfoPanel.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const TEAL = '#14B8A6';
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
{ key: 'asset_types', label: 'Asset Types' },
|
||||
{ key: 'asset_types_in_scope', label: 'Asset Types In Scope' },
|
||||
{ key: 'application_types_in_scope', label: 'Application Types In Scope' },
|
||||
{ key: 'environment_in_scope', label: 'Environment In Scope' },
|
||||
{ key: 'status_in_scope', label: 'Status In Scope' },
|
||||
{ key: 'instance_types_in_scope', label: 'Instance Types In Scope' },
|
||||
{ key: 'criticality_levels_in_scope', label: 'Criticality Levels In Scope' },
|
||||
{ key: 'exclusions', label: 'Exclusions' },
|
||||
{ key: 'special_conditions', label: 'Special Conditions' },
|
||||
{ key: 'data_sources_required', label: 'Data Sources Required' },
|
||||
{ key: 'business_justification', label: 'Business Justification' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
];
|
||||
|
||||
export default function MetricInfoPanel({ metricId, definition, summaryEntries, onClose }) {
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const title = definition
|
||||
? definition.metric_title
|
||||
: (summaryEntries && summaryEntries.length > 0 ? summaryEntries[0].description : metricId);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(10, 14, 39, 0.92)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '0',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: '480px',
|
||||
height: '100vh',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
borderLeft: `1px solid ${TEAL}30`,
|
||||
boxShadow: '0 0 40px rgba(0,0,0,0.7)',
|
||||
padding: '1.75rem',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
background: 'none',
|
||||
border: '1px solid rgba(100,116,139,0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.3rem',
|
||||
cursor: 'pointer',
|
||||
color: '#64748B',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#E2E8F0'; e.currentTarget.style.borderColor = 'rgba(100,116,139,0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(100,116,139,0.3)'; }}
|
||||
>
|
||||
<X style={{ width: '16px', height: '16px' }} />
|
||||
</button>
|
||||
|
||||
{/* Metric ID */}
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
color: TEAL,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.375rem',
|
||||
}}>
|
||||
Metric {metricId}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '1.05rem',
|
||||
fontWeight: '700',
|
||||
color: '#E2E8F0',
|
||||
margin: '0 0 1.5rem 0',
|
||||
lineHeight: 1.4,
|
||||
paddingRight: '2rem',
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{!definition ? (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'rgba(15,23,42,0.6)',
|
||||
border: '1px solid rgba(100,116,139,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
No detailed definition available.
|
||||
{summaryEntries && summaryEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', color: '#CBD5E1', fontSize: '0.78rem' }}>
|
||||
{summaryEntries[0].description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{SECTION_FIELDS.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.3rem',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: definition[key] ? '#CBD5E1' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: 1.5,
|
||||
padding: '0.4rem 0.6rem',
|
||||
background: 'rgba(15,23,42,0.4)',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid rgba(255,255,255,0.04)',
|
||||
}}>
|
||||
{definition[key] || '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Exported for testing — the list of field keys rendered by the panel
|
||||
MetricInfoPanel.RENDERED_FIELD_KEYS = SECTION_FIELDS.map(f => f.key);
|
||||
@@ -0,0 +1,118 @@
|
||||
import fc from 'fast-check';
|
||||
import { computeWorstStatus, groupByMetricFamily } from '../CompliancePage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_STATUSES = [
|
||||
'Below 15% of Target',
|
||||
'Within 15% of Target',
|
||||
'Meets/Exceeds Target',
|
||||
];
|
||||
|
||||
const statusArb = fc.constantFrom(...VALID_STATUSES);
|
||||
|
||||
const summaryEntryArb = fc.record({
|
||||
metric_id: fc.stringMatching(/^[1-9]\d{0,2}\.\d{1,2}\.\d{1,2}$/),
|
||||
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
||||
priority: fc.constantFrom('High', 'Medium', 'Low'),
|
||||
non_compliant: fc.nat({ max: 500 }),
|
||||
compliant: fc.nat({ max: 500 }),
|
||||
total: fc.nat({ max: 1000 }),
|
||||
compliance_pct: fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
target: fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
status: statusArb,
|
||||
description: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
category: fc.constantFrom(
|
||||
'Vulnerability Management',
|
||||
'Access & MFA',
|
||||
'Logging & Monitoring',
|
||||
'End-of-Life OS',
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 1: Grouping invariant — no entries lost or misplaced
|
||||
// Validates: Requirements 1.1, 1.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 1: Grouping invariant — no entries lost or misplaced', () => {
|
||||
test('every entry appears in exactly one group, groups share metric_id, totals match', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 30 }),
|
||||
fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
||||
(entries, team) => {
|
||||
const groups = groupByMetricFamily(entries, team);
|
||||
const teamEntries = entries.filter(
|
||||
(e) => e.team === team && e.metric_id,
|
||||
);
|
||||
|
||||
// (c) total entries across groups equals team-filtered input count
|
||||
const totalGrouped = groups.reduce(
|
||||
(sum, g) => sum + g.entries.length,
|
||||
0,
|
||||
);
|
||||
expect(totalGrouped).toBe(teamEntries.length);
|
||||
|
||||
// (b) all entries within a group share the same metric_id
|
||||
for (const group of groups) {
|
||||
for (const entry of group.entries) {
|
||||
expect(entry.metric_id).toBe(group.metricId);
|
||||
}
|
||||
}
|
||||
|
||||
// (a) every team entry appears in exactly one group
|
||||
const allGroupedEntries = groups.flatMap((g) => g.entries);
|
||||
for (const entry of teamEntries) {
|
||||
const occurrences = allGroupedEntries.filter(
|
||||
(e) => e === entry,
|
||||
).length;
|
||||
expect(occurrences).toBe(1);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: Worst-status computation follows severity ordering
|
||||
// Validates: Requirements 1.6, 3.1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_SEVERITY = {
|
||||
'Below 15% of Target': 0,
|
||||
'Within 15% of Target': 1,
|
||||
'Meets/Exceeds Target': 2,
|
||||
};
|
||||
|
||||
describe('Property 2: Worst-status computation follows severity ordering', () => {
|
||||
test('result is the status with the lowest severity rank present', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(statusArb, { minLength: 1, maxLength: 20 }),
|
||||
(statuses) => {
|
||||
const result = computeWorstStatus(statuses);
|
||||
|
||||
// Result must be a valid status
|
||||
expect(VALID_STATUSES).toContain(result);
|
||||
|
||||
// Result must be the minimum severity present
|
||||
const minSeverity = Math.min(
|
||||
...statuses.map((s) => STATUS_SEVERITY[s]),
|
||||
);
|
||||
expect(STATUS_SEVERITY[result]).toBe(minSeverity);
|
||||
|
||||
// If array contains "Below 15% of Target", result must be that
|
||||
if (statuses.includes('Below 15% of Target')) {
|
||||
expect(result).toBe('Below 15% of Target');
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import fc from 'fast-check';
|
||||
import MetricInfoPanel from '../MetricInfoPanel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFINITION_KEYS = [
|
||||
'metric_id',
|
||||
'metric_title',
|
||||
'asset_types',
|
||||
'asset_types_in_scope',
|
||||
'application_types_in_scope',
|
||||
'environment_in_scope',
|
||||
'status_in_scope',
|
||||
'instance_types_in_scope',
|
||||
'criticality_levels_in_scope',
|
||||
'exclusions',
|
||||
'special_conditions',
|
||||
'data_sources_required',
|
||||
'business_justification',
|
||||
'notes',
|
||||
];
|
||||
|
||||
const metricDefinitionArb = fc.record({
|
||||
metric_id: fc.stringMatching(/^[1-9]\d{0,2}\.\d{1,2}\.\d{1,2}$/),
|
||||
metric_title: fc.string({ minLength: 1, maxLength: 80 }),
|
||||
asset_types: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
asset_types_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
application_types_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
environment_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
status_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
instance_types_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
criticality_levels_in_scope: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
exclusions: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
special_conditions: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
data_sources_required: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
business_justification: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
notes: fc.string({ minLength: 0, maxLength: 60 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate an array of metric definitions with unique metric_id values.
|
||||
*/
|
||||
const uniqueDefinitionsArb = fc
|
||||
.array(metricDefinitionArb, { minLength: 1, maxLength: 20 })
|
||||
.map((defs) => {
|
||||
const seen = new Set();
|
||||
return defs.filter((d) => {
|
||||
if (seen.has(d.metric_id)) return false;
|
||||
seen.add(d.metric_id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter((arr) => arr.length > 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 4: Definition lookup returns correct entry or null
|
||||
// Validates: Requirements 4.2, 4.6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 4: Definition lookup returns correct entry or null', () => {
|
||||
test('lookup hits for IDs in the array and misses for IDs not in the array', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
uniqueDefinitionsArb,
|
||||
fc.stringMatching(/^[1-9]\d{0,2}\.\d{1,2}\.\d{1,2}$/),
|
||||
(definitions, queryId) => {
|
||||
// Build lookup map
|
||||
const lookup = {};
|
||||
for (const def of definitions) {
|
||||
lookup[def.metric_id] = def;
|
||||
}
|
||||
|
||||
// Query with IDs from the array — expect hit
|
||||
for (const def of definitions) {
|
||||
expect(lookup[def.metric_id]).toBe(def);
|
||||
}
|
||||
|
||||
// Query with a random ID — expect hit if present, miss if not
|
||||
const existsInArray = definitions.some(
|
||||
(d) => d.metric_id === queryId,
|
||||
);
|
||||
if (existsInArray) {
|
||||
expect(lookup[queryId]).toBeDefined();
|
||||
} else {
|
||||
expect(lookup[queryId]).toBeUndefined();
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 5: Detail panel renders all required definition fields
|
||||
// Validates: Requirements 5.3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 5: Detail panel renders all required definition fields', () => {
|
||||
test('RENDERED_FIELD_KEYS includes all required definition keys (excluding metric_id and metric_title)', () => {
|
||||
const renderedKeys = MetricInfoPanel.RENDERED_FIELD_KEYS;
|
||||
|
||||
// Keys that are rendered separately (as title/header), not in the section list
|
||||
const separatelyRendered = ['metric_id', 'metric_title'];
|
||||
const requiredSectionKeys = DEFINITION_KEYS.filter(
|
||||
(k) => !separatelyRendered.includes(k),
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(metricDefinitionArb, (definition) => {
|
||||
// Verify every required section key is in the rendered set
|
||||
for (const key of requiredSectionKeys) {
|
||||
expect(renderedKeys).toContain(key);
|
||||
}
|
||||
|
||||
// Verify the definition object has all keys that will be rendered
|
||||
for (const key of renderedKeys) {
|
||||
expect(definition).toHaveProperty(key);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 6: Definitions schema validation — all entries have required fields
|
||||
// Validates: Requirements 6.2, 8.3, 8.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 6: Definitions schema validation — all entries have required fields', () => {
|
||||
test('every entry has all 14 keys, metric_id is non-empty string, optional fields are strings', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(metricDefinitionArb, { minLength: 1, maxLength: 15 }),
|
||||
(definitions) => {
|
||||
for (const def of definitions) {
|
||||
// All 14 keys present
|
||||
for (const key of DEFINITION_KEYS) {
|
||||
expect(def).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// metric_id is a non-empty string
|
||||
expect(typeof def.metric_id).toBe('string');
|
||||
expect(def.metric_id.length).toBeGreaterThan(0);
|
||||
|
||||
// Optional fields are strings (not null/undefined)
|
||||
const optionalFields = [
|
||||
'exclusions',
|
||||
'special_conditions',
|
||||
'notes',
|
||||
];
|
||||
for (const field of optionalFields) {
|
||||
expect(typeof def[field]).toBe('string');
|
||||
expect(def[field]).not.toBeNull();
|
||||
expect(def[field]).not.toBeUndefined();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 7: Lookup map construction preserves all definitions
|
||||
// Validates: Requirements 6.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 7: Lookup map construction preserves all definitions', () => {
|
||||
test('map size equals array length and every definition is retrievable', () => {
|
||||
fc.assert(
|
||||
fc.property(uniqueDefinitionsArb, (definitions) => {
|
||||
// Build lookup map
|
||||
const lookup = {};
|
||||
for (const def of definitions) {
|
||||
lookup[def.metric_id] = def;
|
||||
}
|
||||
|
||||
// Map size equals array length
|
||||
expect(Object.keys(lookup).length).toBe(definitions.length);
|
||||
|
||||
// Every definition is retrievable by its metric_id
|
||||
for (const def of definitions) {
|
||||
expect(lookup[def.metric_id]).toBe(def);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 8: JSON round-trip preserves metric definition data
|
||||
// Validates: Requirements 8.1, 8.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 8: JSON round-trip preserves metric definition data', () => {
|
||||
test('JSON.parse(JSON.stringify(definition)) produces a deeply equal object', () => {
|
||||
fc.assert(
|
||||
fc.property(metricDefinitionArb, (definition) => {
|
||||
const roundTripped = JSON.parse(JSON.stringify(definition));
|
||||
expect(roundTripped).toEqual(definition);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user