Files
cve-dashboard/.kiro/specs/compliance-metric-grouping/design.md

517 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Document: Compliance Metric Grouping
## Overview
The Compliance Metric Grouping feature consolidates the AEO Compliance page's metric health cards from one-per-summary-entry to one-per-metric-family. A metric family is the set of summary entries that share the same base `metric_id` (e.g., `5.2.5`). The same base ID can appear multiple times in the summary data because the backend parser produces one entry per team/variant row in the xlsx Summary sheet.
The feature adds three new capabilities on top of the grouping:
1. **Variant pills** inside each grouped card showing per-variant compliance percentages and status indicators
2. **Hover tooltip** (300ms delay) displaying metric title, business justification, and data sources from a static definitions file
3. **Info panel** opened via an info icon on each card, showing the full metric definition (scope, filters, exclusions, notes)
A static JSON file (`metricDefinitions.json`) ships with the frontend containing structured metric definition data for all tracked metrics. No new backend endpoints are needed.
### Design Decisions
- **Grouping is frontend-only.** The backend summary endpoint returns flat entries. The frontend groups them by `metric_id` at render time. This avoids backend changes and keeps the grouping logic testable as a pure function.
- **`metricFilter` changes from a single string to an array.** Currently `metricFilter` is a single `metric_id` string or `null`. With grouping, clicking a card sets the filter to the array of all `metric_id` values in that family. For single-entry families this is a one-element array. The device table filter checks `metricFilter.includes(m.metric_id)` instead of `m.metric_id === metricFilter`.
- **Worst-status drives card color.** Each grouped card computes the most severe status across its variants using a defined severity ordering. This gives engineers an at-a-glance signal when any variant is failing.
- **Definitions file is static, not an API.** The metric definitions table has ~130 rows that change infrequently. A static JSON import avoids API round-trips and keeps the tooltip/panel responsive. The file can be regenerated from the source xlsx definitions table when metrics change.
- **MetricInfoPanel is a new component.** The detail panel is complex enough (12+ fields, dark-themed sections) to warrant its own component file rather than inlining it in CompliancePage.js. The hover tooltip, being lightweight, stays inline.
- **Info icon click uses `stopPropagation`.** The info icon sits inside the card button. Clicking it opens the detail panel without triggering the card's metric filter toggle.
## Architecture
```mermaid
graph TD
A[CompliancePage.js] -->|imports| B[metricDefinitions.json]
A -->|renders| C[MetricHealthCard - grouped]
A -->|renders| D[MetricInfoPanel]
A -->|renders| E[HoverTooltip - inline]
A -->|fetches| F[GET /api/compliance/summary?team=X]
A -->|fetches| G[GET /api/compliance/items?team=X&status=Y]
F -->|returns| H[summary.entries array]
H -->|groupByMetricFamily| I[Map of metricId → entries array]
I -->|one card per group| C
C -->|click outside info icon| J[setMetricFilter - array of IDs]
C -->|click info icon| K[setInfoMetric - opens MetricInfoPanel]
C -->|hover 300ms| E
J -->|filters| G2[filteredDevices]
B -->|lookup by metric_id| E
B -->|lookup by metric_id| D
```
### Component Hierarchy
```
CompliancePage
├── PageHeader (unchanged)
├── TeamTabs (unchanged)
├── MetricHealthSection
│ ├── SectionHeader ("Metric Health — click to filter" + clear button)
│ └── MetricHealthCard (one per metric family)
│ ├── CardTitle (base metric_id + category)
│ ├── VariantPill[] (one per summary entry in family)
│ ├── WorstStatusPill (computed from all variants)
│ ├── TargetDisplay (shared target %)
│ ├── InfoIcon (lucide-react Info, top-right)
│ └── HoverTooltip (inline, 300ms delay)
├── MetricInfoPanel (slide-out/overlay, opened by info icon click)
├── ComplianceChartsPanel (unchanged)
├── DeviceTable (unchanged, filter logic updated)
├── ComplianceDetailPanel (unchanged)
├── ComplianceUploadModal (unchanged)
└── RollbackModal (unchanged)
```
### Data Flow
1. `CompliancePage` fetches summary entries from `/api/compliance/summary?team=X` on mount and team change.
2. The `groupByMetricFamily(entries)` helper groups the flat entries array into a `Map<string, SummaryEntry[]>` keyed by base `metric_id`.
3. One `MetricHealthCard` renders per map entry. Each card receives the full array of entries for that family.
4. The card computes `worstStatus` from the entries' status fields and uses it for border/pill coloring.
5. On card click (outside info icon), `metricFilter` is set to the array of `metric_id` values in that family (or cleared if already active).
6. The device table filter changes from `d.failing_metrics.some(m => m.metric_id === metricFilter)` to `d.failing_metrics.some(m => metricFilter.includes(m.metric_id))`.
7. On hover (300ms), a tooltip renders using data from the `metricDefinitions.json` lookup map, falling back to the summary entry description.
8. On info icon click, `infoMetric` state is set, opening `MetricInfoPanel` with the full definition.
## Components and Interfaces
### groupByMetricFamily (pure helper function)
```javascript
// In CompliancePage.js — replaces the current teamMetrics() helper
// Input: entries (array of SummaryEntry from the summary endpoint), team (string)
// Output: array of { metricId, entries, category, target, worstStatus }
function groupByMetricFamily(allEntries, team) {
const teamEntries = allEntries.filter(e => e.team === team);
const familyMap = {};
for (const entry of teamEntries) {
const baseId = entry.metric_id; // already the base ID from the parser
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)),
}));
}
```
### computeWorstStatus (pure helper function)
```javascript
// Input: array of status strings
// Output: the most severe status string
// Severity order: "Below 15% of Target" > "Within 15% of Target" > "Meets/Exceeds Target"
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;
}
```
### MetricHealthCard (redesigned)
```javascript
// Props:
// family: { metricId, entries, category, target, worstStatus }
// active: boolean (is this family's filter currently active)
// onClick: () => void (toggle metric filter)
// onInfoClick: (metricId) => void (open detail panel)
// definitionLookup: Map<string, MetricDefinition> (for tooltip)
//
// Renders:
// - Base metric ID as title
// - Category label
// - One VariantPill per entry in family.entries
// - Shared target percentage
// - Worst-status pill with border color
// - Info icon (top-right, stopPropagation on click)
// - HoverTooltip (300ms delay, positioned near card)
```
### VariantPill (new inline sub-component)
```javascript
// Props:
// entry: SummaryEntry (single variant)
//
// Renders:
// - Label: entry.team or entry.description (distinguishing text)
// - Compliance percentage in monospace
// - Background tint from entry's status color at ~12% opacity
// - Glow dot if status !== "Meets/Exceeds Target"
//
// Layout: inline-flex, wraps via parent flexWrap
```
### HoverTooltip (inline in CompliancePage.js)
```javascript
// State managed in CompliancePage:
// hoveredMetric: string | null
// hoverTimeout: ref (setTimeout ID)
// tooltipPosition: { top, left } (computed from card bounding rect)
//
// On mouseEnter on MetricHealthCard:
// Set timeout for 300ms → set hoveredMetric to family.metricId
// On mouseLeave:
// Clear timeout, set hoveredMetric to null
//
// Renders (when hoveredMetric matches):
// - Fixed-position div near the card
// - Metric title (from definitionLookup or entry.description)
// - Business justification (from definitionLookup)
// - Data sources required (from definitionLookup)
// - Dark card background, subtle border, shadow per DESIGN_SYSTEM.md
// - Falls back to summary entry description if no definition found
```
### MetricInfoPanel (new component file)
```javascript
// frontend/src/components/pages/MetricInfoPanel.js
// Props:
// metricId: string (base metric ID)
// definition: MetricDefinition | null (from lookup)
// summaryEntries: SummaryEntry[] (the family's entries, for fallback)
// onClose: () => void
//
// Renders:
// - Overlay/slide-out panel with dark theme
// - Close button (X icon, top-right)
// - Metric title (h3, monospace)
// - Sections with monospace uppercase labels:
// - 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
// - If definition is null: "No detailed definition available" + summary description fallback
// - Click outside or close button → onClose()
```
### Integration with CompliancePage.js
```javascript
// New imports:
import { Info } from 'lucide-react';
import MetricInfoPanel from './MetricInfoPanel';
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
// Build lookup map once at module level:
const METRIC_DEFINITIONS = {};
for (const def of metricDefinitionsRaw) {
METRIC_DEFINITIONS[def.metric_id] = def;
}
// State changes in CompliancePage:
// - metricFilter: null → null | string[] (array of metric IDs)
// - New state: infoMetric (string | null) — which metric's info panel is open
// - New state: hoveredMetric (string | null) — which metric is being hovered
// - New ref: hoverTimeoutRef — for 300ms delay
// Filter logic change:
// Old: .filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
// New: .filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
// Card rendering change:
// Old: metrics.map(entry => <MetricHealthCard entry={entry} ... />)
// New: families.map(family => <MetricHealthCard family={family} ... />)
```
## Data Models
### SummaryEntry (from backend, unchanged)
```javascript
{
metric_id: string, // e.g. "5.2.5" — base ID, no suffix
team: string, // e.g. "STEAM", "ACCESS-ENG"
priority: string,
non_compliant: number,
compliant: number,
total: number,
compliance_pct: number, // 0.01.0
target: number, // 0.01.0
status: string, // "Meets/Exceeds Target" | "Within 15% of Target" | "Below 15% of Target"
description: string,
category: string // from compliance_config.json metric_categories
}
```
### MetricFamily (computed client-side)
```javascript
{
metricId: string, // base metric ID (e.g. "5.2.5")
entries: SummaryEntry[], // all summary entries for this base ID
category: string, // from first entry
target: number, // from first entry (shared across variants)
worstStatus: string // computed worst status across all entries
}
```
### MetricDefinition (from metricDefinitions.json)
```javascript
{
metric_id: string, // e.g. "5.2.5"
metric_title: string, // e.g. "MFA for Privileged Access"
asset_types: string, // e.g. "Servers, Network Devices"
asset_types_in_scope: string,
application_types_in_scope: string,
environment_in_scope: string,
status_in_scope: string,
instance_types_in_scope: string,
criticality_levels_in_scope: string,
exclusions: string, // empty string if none
special_conditions: string, // empty string if none
data_sources_required: string,
business_justification: string,
notes: string // empty string if none
}
```
### metricDefinitions.json (file structure)
```json
[
{
"metric_id": "1.1.1",
"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": ""
}
]
```
All entries use the same set of keys. Optional fields with no value use empty strings, never `null` or omitted keys.
### Status Severity Map
```javascript
const STATUS_SEVERITY = {
'Below 15% of Target': 0, // worst
'Within 15% of Target': 1,
'Meets/Exceeds Target': 2, // best
};
```
### Updated metricFilter State
```javascript
// Old: metricFilter: string | null
// New: metricFilter: string[] | null
//
// null = no filter (show all devices)
// string[] = show devices with failing_metrics matching any ID in the array
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Grouping invariant — no entries lost or misplaced
*For any* array of summary entries and any team string, grouping by metric family SHALL produce groups where (a) every entry appears in exactly one group, (b) all entries within a group share the same `metric_id`, and (c) the total number of entries across all groups equals the number of entries for that team in the input.
**Validates: Requirements 1.1, 1.2**
### Property 2: Worst-status computation follows severity ordering
*For any* non-empty array of status strings drawn from `{"Below 15% of Target", "Within 15% of Target", "Meets/Exceeds Target"}`, the computed worst status SHALL be the status with the lowest severity rank present in the array. If the array contains "Below 15% of Target", the result SHALL be "Below 15% of Target" regardless of other values.
**Validates: Requirements 1.6, 3.1**
### Property 3: Device filtering with metric family includes all matching devices
*For any* array of device objects (each with a `failing_metrics` array of `{metric_id}` objects) and *for any* non-empty array of filter metric IDs, the filtered result SHALL contain exactly those devices that have at least one `failing_metrics` entry whose `metric_id` is included in the filter array. No matching device is excluded and no non-matching device is included.
**Validates: Requirements 1.8, 7.1, 7.2**
### Property 4: Definition lookup returns correct entry or null
*For any* array of metric definition objects with unique `metric_id` values, building a lookup map and querying it with a `metric_id` that exists in the array SHALL return the corresponding definition object. Querying with a `metric_id` not in the array SHALL return `undefined`.
**Validates: Requirements 4.2, 4.6**
### Property 5: Detail panel renders all required definition fields
*For any* valid metric definition object (with all 14 fields present), the set of field keys rendered by the detail panel SHALL include: `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`, and `notes`.
**Validates: Requirements 5.3**
### Property 6: Definitions schema validation — all entries have required fields
*For any* entry in the metric definitions array, the entry SHALL have all 14 required keys present, and the `metric_id` field SHALL be a non-empty string. Optional fields (`exclusions`, `special_conditions`, `notes`) SHALL be present as strings (empty string if no value), never omitted or null.
**Validates: Requirements 6.2, 8.3, 8.4**
### Property 7: Lookup map construction preserves all definitions
*For any* array of metric definition objects with unique `metric_id` values, building a lookup map keyed by `metric_id` SHALL produce a map with exactly as many entries as the input array, and every input definition SHALL be retrievable by its `metric_id`.
**Validates: Requirements 6.4**
### Property 8: JSON round-trip preserves metric definition data
*For any* valid metric definition object, `JSON.parse(JSON.stringify(definition))` SHALL produce an object deeply equal to the original.
**Validates: Requirements 8.1, 8.2**
## Error Handling
### Metric Definitions File
| Error Scenario | Handling |
|---|---|
| `metricDefinitions.json` fails to import (malformed JSON) | Build-time error caught by Create React App. The file is validated at development time. |
| Metric ID not found in definitions lookup | Tooltip falls back to `entry.description` from summary data. Info panel shows "No detailed definition available" with summary description. |
| Definition entry has empty optional fields | Rendered sections show "—" placeholder for empty strings. No error thrown. |
### Grouping Logic
| Error Scenario | Handling |
|---|---|
| Summary entries array is empty | `groupByMetricFamily` returns empty array. No cards rendered. Existing "No compliance data" empty state shown. |
| Summary entry has missing or empty `metric_id` | Entry is skipped during grouping (filtered out). |
| All entries for a team have the same `metric_id` | Single family group with multiple variant pills. Works correctly. |
### Hover Tooltip
| Error Scenario | Handling |
|---|---|
| User moves mouse away before 300ms | Timeout cleared, tooltip never shown. No side effects. |
| Tooltip would render off-screen | Position clamped to viewport bounds using `getBoundingClientRect()`. |
| Rapid hover/unhover across multiple cards | Previous timeout cleared on each `mouseLeave`. Only the currently hovered card's tooltip can appear. |
### Info Panel
| Error Scenario | Handling |
|---|---|
| Info icon click while tooltip is visible | Tooltip dismissed (mouseLeave fires). Panel opens. No conflict. |
| Multiple rapid info icon clicks | `infoMetric` state is set to the latest clicked metric. Only one panel open at a time. |
| Click outside panel while scrolled | Overlay backdrop captures click, closes panel. Scroll position preserved. |
### Device Table Filtering
| Error Scenario | Handling |
|---|---|
| `metricFilter` is set to an array but no devices match | Empty state message shown: "No non-compliant devices". |
| Device has `failing_metrics` with IDs not in any family | Device only shown when no filter is active or when its metric IDs match the active filter. |
## Testing Strategy
### Unit Tests (Example-Based)
Unit tests cover specific rendering, interaction, and integration scenarios:
**Grouping and display:**
- Groups entries with the same `metric_id` into one card
- Single-entry families render one variant pill
- Multi-entry families render one pill per entry
- Card title shows base metric ID and category from first entry
- Card shows shared target percentage
**Worst-status computation:**
- All "Meets/Exceeds Target" → card shows "OK" with success color
- Mix of statuses → card uses the worst status color
- Single "Below 15% of Target" among passing variants → card shows danger color
**Variant pills:**
- Each pill shows the entry's team label and compliance percentage
- Pill background tint matches the entry's individual status color
- Non-passing variants show a glow dot
**Hover tooltip:**
- Tooltip appears after 300ms hover delay
- Tooltip shows metric title, business justification, data sources from definitions
- Tooltip disappears on mouse leave
- Tooltip falls back to summary description when no definition exists
- Tooltip does not interfere with card click
**Info panel:**
- Info icon click opens MetricInfoPanel with correct metric definition
- Info icon click does not trigger card's metric filter toggle (stopPropagation)
- Panel displays all 12+ definition fields with section labels
- Panel shows fallback message when no definition exists
- Panel closes on outside click or close button
**Device table filtering:**
- Clicking a grouped card sets filter to all metric IDs in that family
- Filtered device table shows devices matching any ID in the family
- Clicking the same card again clears the filter
- Clear filter button resets to show all devices
- Active card shows highlighted styling
**Definitions file:**
- File imports without error
- Lookup map contains all metric IDs from the file
- All entries have the required 14 fields
### Property-Based Tests
Property-based tests use [fast-check](https://github.com/dubzzz/fast-check) to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
| Property | Test Description | Tag |
|---|---|---|
| Property 1 | Generate random arrays of summary entries with varying metric_id values and team strings. Group them and verify: all entries accounted for, entries within each group share the same metric_id, group count equals unique metric_id count. | Feature: compliance-metric-grouping, Property 1: Grouping invariant — no entries lost or misplaced |
| Property 2 | Generate random non-empty arrays of status strings from the valid set. Compute worst status and verify it matches the minimum severity rank in the array. | Feature: compliance-metric-grouping, Property 2: Worst-status computation follows severity ordering |
| Property 3 | Generate random device arrays (each with random failing_metrics) and random filter ID arrays. Filter and verify the result contains exactly the devices with at least one matching metric. | Feature: compliance-metric-grouping, Property 3: Device filtering with metric family includes all matching devices |
| Property 4 | Generate random arrays of metric definitions with unique IDs. Build lookup map, query with IDs from the array (expect hit) and IDs not in the array (expect miss). | Feature: compliance-metric-grouping, Property 4: Definition lookup returns correct entry or null |
| Property 5 | Generate random metric definition objects with all 14 fields. Extract the rendered field keys and verify all required keys are present. | Feature: compliance-metric-grouping, Property 5: Detail panel renders all required definition fields |
| Property 6 | Generate random arrays of metric definition objects. Verify every entry has all 14 keys present, metric_id is a non-empty string, and optional fields are strings (not null/undefined). | Feature: compliance-metric-grouping, Property 6: Definitions schema validation — all entries have required fields |
| Property 7 | Generate random definition arrays with unique IDs. Build lookup map and verify map size equals array length, and every definition is retrievable by its metric_id. | Feature: compliance-metric-grouping, Property 7: Lookup map construction preserves all definitions |
| Property 8 | Generate random metric definition objects with string values. Round-trip through JSON.stringify then JSON.parse and verify deep equality. | Feature: compliance-metric-grouping, Property 8: JSON round-trip preserves metric definition data |
### Test Configuration
- **Library:** fast-check (JavaScript property-based testing)
- **Runner:** Jest (via react-scripts test)
- **Iterations:** Minimum 100 per property test (`fc.assert(property, { numRuns: 100 })`)
- **Tag format:** Comment at top of each property test referencing the design property