Add non-metric category filters to compliance page
Adds a CategoryFilterBar with pill-shaped FilterChip components below the metric health cards. Non-metric categories (Missing_AppID, Aging Vulns, Missing_DF, etc.) are derived dynamically from device data and displayed as color-coded filterable chips with device counts. Unified filter state replaces the old metricFilter array, ensuring mutual exclusivity between metric card filters and non-metric chip filters. Includes 4 property-based tests validating derivation, filter predicate, mutual exclusivity, and color resolution correctness. Closes #26
This commit is contained in:
@@ -7,6 +7,7 @@ import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
import MetricInfoPanel from './MetricInfoPanel';
|
||||
import VCLReportPage from './VCLReportPage';
|
||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
import metricCategoriesConfig from '../../data/complianceCategories.json';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
@@ -87,6 +88,32 @@ function groupByMetricFamily(allEntries, team) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-metric category derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
function deriveNonMetricCategories(devices, summaryEntries, categoriesConfig) {
|
||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
||||
const countMap = new Map();
|
||||
|
||||
for (const device of devices) {
|
||||
if (!device.failing_metrics) continue;
|
||||
const seen = new Set();
|
||||
for (const m of device.failing_metrics) {
|
||||
if (!m.metric_id || summaryIds.has(m.metric_id) || seen.has(m.metric_id)) continue;
|
||||
seen.add(m.metric_id);
|
||||
countMap.set(m.metric_id, (countMap.get(m.metric_id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...countMap.entries()]
|
||||
.map(([metricId, count]) => {
|
||||
const categoryName = categoriesConfig[metricId] || null;
|
||||
const color = (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
||||
return { metricId, count, category: categoryName || 'Unknown', color };
|
||||
})
|
||||
.sort((a, b) => a.metricId.localeCompare(b.metricId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -246,6 +273,71 @@ function SeenBadge({ count }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ metricId, count, color, active, dimmed, onClick }) {
|
||||
const label = metricId.length > 24 ? metricId.slice(0, 24) + '…' : metricId;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={metricId.length > 24 ? metricId : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${active ? color : color + '40'}`,
|
||||
background: active ? `${color}1F` : `${color}0A`,
|
||||
cursor: 'pointer',
|
||||
opacity: dimmed ? 0.5 : 1,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = color + '80'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = active ? color : color + '40'; }}
|
||||
>
|
||||
<span style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontSize: '0.72rem', color, fontFamily: 'monospace', fontWeight: '600', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '150px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B', fontWeight: '700' }}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryFilterBar({ categories, activeFilter, onFilterSelect, onClear, dimmed }) {
|
||||
if (!categories || categories.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem', opacity: dimmed ? 0.5 : 1, transition: 'opacity 0.15s' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Non-Metric Categories
|
||||
{activeFilter && (
|
||||
<button onClick={onClear}
|
||||
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.5rem', flexWrap: 'wrap' }}>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat.metricId}
|
||||
metricId={cat.metricId}
|
||||
count={cat.count}
|
||||
color={cat.color}
|
||||
active={activeFilter === cat.metricId}
|
||||
dimmed={false}
|
||||
onClick={() => onFilterSelect(cat.metricId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -256,7 +348,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [vclView, setVclView] = useState(false);
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [filterState, setFilterState] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||
const [devices, setDevices] = useState([]);
|
||||
@@ -297,7 +389,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
setFilterState(null);
|
||||
setHostSearch('');
|
||||
setSelectedHost(null);
|
||||
fetchSummary(activeTeam);
|
||||
@@ -313,7 +405,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
setFilterState(null);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -346,7 +438,12 @@ export default function CompliancePage({ onNavigate }) {
|
||||
|
||||
// In-memory filters
|
||||
const filteredDevices = devices
|
||||
.filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
|
||||
.filter(d => {
|
||||
if (!filterState) return true;
|
||||
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
|
||||
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
|
||||
return true;
|
||||
})
|
||||
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||||
|
||||
const families = groupByMetricFamily(summary.entries, activeTeam);
|
||||
@@ -495,8 +592,8 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<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)}
|
||||
{filterState && (
|
||||
<button onClick={() => setFilterState(null)}
|
||||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||
× clear filter
|
||||
</button>
|
||||
@@ -505,7 +602,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||||
{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));
|
||||
const isActive = filterState?.type === 'metric' && filterState.ids.length === familyIds.length && familyIds.every(id => filterState.ids.includes(id));
|
||||
return (
|
||||
<div
|
||||
key={family.metricId}
|
||||
@@ -520,12 +617,12 @@ export default function CompliancePage({ onNavigate }) {
|
||||
hoveredCardRef.current = null;
|
||||
setHoveredMetric(null);
|
||||
}}
|
||||
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px' }}
|
||||
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px', opacity: filterState?.type === 'nonmetric' ? 0.5 : 1, transition: 'opacity 0.15s' }}
|
||||
>
|
||||
<MetricHealthCard
|
||||
family={family}
|
||||
active={isActive}
|
||||
onClick={() => setMetricFilter(isActive ? null : familyIds)}
|
||||
onClick={() => setFilterState(isActive ? null : { type: 'metric', ids: familyIds })}
|
||||
onInfoClick={(metricId) => setInfoMetric(metricId)}
|
||||
definitionLookup={METRIC_DEFINITIONS}
|
||||
/>
|
||||
@@ -591,6 +688,27 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Non-metric category filter bar ─────────────────────── */}
|
||||
{!vclView && !loading && (() => {
|
||||
const nonMetricCategories = deriveNonMetricCategories(devices, summary.entries.filter(e => e.team === activeTeam), metricCategoriesConfig);
|
||||
if (nonMetricCategories.length === 0) return null;
|
||||
return (
|
||||
<CategoryFilterBar
|
||||
categories={nonMetricCategories}
|
||||
activeFilter={filterState?.type === 'nonmetric' ? filterState.id : null}
|
||||
onFilterSelect={(metricId) => {
|
||||
if (filterState?.type === 'nonmetric' && filterState.id === metricId) {
|
||||
setFilterState(null);
|
||||
} else {
|
||||
setFilterState({ type: 'nonmetric', id: metricId });
|
||||
}
|
||||
}}
|
||||
onClear={() => setFilterState(null)}
|
||||
dimmed={filterState?.type === 'metric'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
{!vclView && <ComplianceChartsPanel />}
|
||||
|
||||
@@ -677,7 +795,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</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'}
|
||||
{lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map(device => (
|
||||
@@ -906,4 +1024,4 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
}
|
||||
|
||||
// Named exports for testing
|
||||
export { computeWorstStatus, groupByMetricFamily };
|
||||
export { computeWorstStatus, groupByMetricFamily, deriveNonMetricCategories, CATEGORY_COLORS };
|
||||
|
||||
Reference in New Issue
Block a user