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:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Property-Based Test: Color resolution with fallback
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback
|
||||
* **Validates: Requirements 6.2, 6.3**
|
||||
*
|
||||
* For any non-metric category metric_id, the resolved color equals
|
||||
* CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed,
|
||||
* else #94A3B8.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage';
|
||||
|
||||
// The color resolution logic extracted for direct testing
|
||||
function resolveColor(metricId, categoriesConfig) {
|
||||
const categoryName = categoriesConfig[metricId] || null;
|
||||
return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const knownCategories = Object.keys(CATEGORY_COLORS);
|
||||
const categoryNameArb = fc.oneof(
|
||||
fc.constantFrom(...knownCategories),
|
||||
fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS
|
||||
);
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => {
|
||||
it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => {
|
||||
fc.assert(
|
||||
fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => {
|
||||
const result = resolveColor(metricId, config);
|
||||
|
||||
const categoryName = config[metricId];
|
||||
if (categoryName && CATEGORY_COLORS[categoryName]) {
|
||||
expect(result).toBe(CATEGORY_COLORS[categoryName]);
|
||||
} else {
|
||||
expect(result).toBe('#94A3B8');
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('deriveNonMetricCategories produces correct colors for all returned categories', () => {
|
||||
const deviceWithMetricArb = metricIdArb.map(id => ({
|
||||
hostname: 'host-' + id,
|
||||
failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }],
|
||||
}));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }),
|
||||
categoriesConfigArb,
|
||||
(devices, config) => {
|
||||
// Empty summary means all device metric_ids are non-metric
|
||||
const result = deriveNonMetricCategories(devices, [], config);
|
||||
|
||||
for (const item of result) {
|
||||
const expectedColor = resolveColor(item.metricId, config);
|
||||
expect(item.color).toBe(expectedColor);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Property-Based Test: Non-metric category derivation is the set difference with accurate counts
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation
|
||||
* **Validates: Requirements 1.1, 1.2, 2.2, 2.4**
|
||||
*
|
||||
* For any set of devices and any set of summary entries for a team,
|
||||
* deriveNonMetricCategories returns exactly the metric_ids present in at least
|
||||
* one device's failing_metrics array that do not appear in any summary entry's
|
||||
* metric_id — deduplicated, sorted alphabetically by metricId, and each with a
|
||||
* count equal to the number of devices whose failing_metrics contains that metric_id.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories } from '../components/pages/CompliancePage';
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const summaryEntryArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
team: fc.constant('STEAM'),
|
||||
compliance_pct: fc.double({ min: 0, max: 1 }),
|
||||
status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'),
|
||||
});
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom(
|
||||
'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring',
|
||||
'Asset Data Quality', 'Endpoint Protection', 'Application Security',
|
||||
'Disaster Recovery', 'Decommissioned Assets'
|
||||
));
|
||||
|
||||
describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => {
|
||||
it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
||||
const deviceMetricIds = new Set();
|
||||
for (const d of devices) {
|
||||
for (const m of (d.failing_metrics || [])) {
|
||||
if (m.metric_id) deviceMetricIds.add(m.metric_id);
|
||||
}
|
||||
}
|
||||
const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id)));
|
||||
|
||||
const resultIds = new Set(result.map(r => r.metricId));
|
||||
expect(resultIds).toEqual(expectedIds);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returned list is deduplicated and sorted alphabetically by metricId', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
// Deduplicated
|
||||
const ids = result.map(r => r.metricId);
|
||||
expect(ids.length).toBe(new Set(ids).size);
|
||||
|
||||
// Sorted alphabetically
|
||||
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
|
||||
expect(ids).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each count equals the number of devices whose failing_metrics contains that metric_id', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
for (const { metricId, count } of result) {
|
||||
const expectedCount = devices.filter(d =>
|
||||
(d.failing_metrics || []).some(m => m.metric_id === metricId)
|
||||
).length;
|
||||
expect(count).toBe(expectedCount);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Property-Based Test: Filter predicate correctness
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness
|
||||
* **Validates: Requirements 3.1, 5.2, 5.3, 5.4**
|
||||
*
|
||||
* For any filter state and any set of devices: when null, all devices pass;
|
||||
* when metric, exactly devices with matching metric_id in ids pass;
|
||||
* when nonmetric, exactly devices with matching metric_id pass.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter predicate logic from CompliancePage
|
||||
function applyFilter(devices, filterState) {
|
||||
return devices.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;
|
||||
});
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 });
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => {
|
||||
it('when filterState is null, all devices pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, (devices) => {
|
||||
const result = applyFilter(devices, null);
|
||||
expect(result.length).toBe(devices.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is metric, exactly devices with a matching metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => {
|
||||
const filterState = { type: 'metric', ids };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => ids.includes(m.metric_id))
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is nonmetric, exactly devices with that metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, metricIdArb, (devices, id) => {
|
||||
const filterState = { type: 'nonmetric', id };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => m.metric_id === id)
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Property-Based Test: Filter state mutual exclusivity
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity
|
||||
* **Validates: Requirements 3.4, 3.5, 5.1**
|
||||
*
|
||||
* For any prior filter state and any filter action (metric card click, chip click,
|
||||
* or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] },
|
||||
* or { type: 'nonmetric', id: string } — never undefined, never both.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter state transition logic from CompliancePage
|
||||
function applyFilterAction(currentState, action) {
|
||||
if (action.type === 'clear') {
|
||||
return null;
|
||||
}
|
||||
if (action.type === 'metric_click') {
|
||||
const familyIds = action.ids;
|
||||
// If currently active metric with same ids, toggle off
|
||||
if (currentState?.type === 'metric' &&
|
||||
currentState.ids.length === familyIds.length &&
|
||||
familyIds.every(id => currentState.ids.includes(id))) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'metric', ids: familyIds };
|
||||
}
|
||||
if (action.type === 'chip_click') {
|
||||
const metricId = action.id;
|
||||
// If currently active nonmetric with same id, toggle off
|
||||
if (currentState?.type === 'nonmetric' && currentState.id === metricId) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'nonmetric', id: metricId };
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
function isValidFilterState(state) {
|
||||
if (state === null) return true;
|
||||
if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true;
|
||||
if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
const filterActionArb = fc.oneof(
|
||||
fc.constant({ type: 'clear' }),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })),
|
||||
metricIdArb.map(id => ({ type: 'chip_click', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => {
|
||||
it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, filterActionArb, (priorState, action) => {
|
||||
const result = applyFilterAction(priorState, action);
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(isValidFilterState(result)).toBe(true);
|
||||
|
||||
// Never has both metric and nonmetric properties
|
||||
if (result !== null) {
|
||||
if (result.type === 'metric') {
|
||||
expect(result).not.toHaveProperty('id');
|
||||
}
|
||||
if (result.type === 'nonmetric') {
|
||||
expect(result).not.toHaveProperty('ids');
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('clear action always results in null regardless of prior state', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, (priorState) => {
|
||||
const result = applyFilterAction(priorState, { type: 'clear' });
|
||||
expect(result).toBeNull();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('metric click replaces any prior state with metric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => {
|
||||
const result = applyFilterAction(priorState, { type: 'metric_click', ids });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been metric with same ids
|
||||
expect(priorState?.type).toBe('metric');
|
||||
} else {
|
||||
expect(result.type).toBe('metric');
|
||||
expect(result.ids).toEqual(ids);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, metricIdArb, (priorState, id) => {
|
||||
const result = applyFilterAction(priorState, { type: 'chip_click', id });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been nonmetric with same id
|
||||
expect(priorState?.type).toBe('nonmetric');
|
||||
expect(priorState?.id).toBe(id);
|
||||
} else {
|
||||
expect(result.type).toBe('nonmetric');
|
||||
expect(result.id).toBe(id);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
25
frontend/src/data/complianceCategories.json
Normal file
25
frontend/src/data/complianceCategories.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"1.1.1": "Logging & Monitoring",
|
||||
"1.1.3": "Logging & Monitoring",
|
||||
"1.4.1": "Logging & Monitoring",
|
||||
"2.3.4i": "Vulnerability Management",
|
||||
"2.3.6i": "Vulnerability Management",
|
||||
"2.3.8i": "Vulnerability Management",
|
||||
"5.2.4": "Access & MFA",
|
||||
"5.2.5": "Access & MFA",
|
||||
"5.2.6": "Access & MFA",
|
||||
"5.2.7": "Access & MFA",
|
||||
"5.2.8": "Access & MFA",
|
||||
"5.3.4": "Endpoint Protection",
|
||||
"5.5.4i": "Vulnerability Management",
|
||||
"5.5.5": "Decommissioned Assets",
|
||||
"5.8.1": "Application Security",
|
||||
"7.1.1": "Logging & Monitoring",
|
||||
"7.1.4": "Logging & Monitoring",
|
||||
"7.6.13": "Disaster Recovery",
|
||||
"7.6.16": "Disaster Recovery",
|
||||
"Missing_AppID": "Asset Data Quality",
|
||||
"Missing_DF": "Asset Data Quality",
|
||||
"Missing_OS": "Asset Data Quality",
|
||||
"5.5.2": "Other"
|
||||
}
|
||||
Reference in New Issue
Block a user