Add grouped metric health cards with variant pills, hover tooltips, and info panel to compliance page
This commit is contained in:
1
.kiro/specs/compliance-metric-grouping/.config.kiro
Normal file
1
.kiro/specs/compliance-metric-grouping/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "9ecf72f0-b470-4877-b244-899e583007f7", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
516
.kiro/specs/compliance-metric-grouping/design.md
Normal file
516
.kiro/specs/compliance-metric-grouping/design.md
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
# 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.0–1.0
|
||||||
|
target: number, // 0.0–1.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
|
||||||
121
.kiro/specs/compliance-metric-grouping/requirements.md
Normal file
121
.kiro/specs/compliance-metric-grouping/requirements.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The AEO Compliance page currently renders one metric health card per `metric_id` returned from the summary endpoint. Many metrics share the same base ID but differ by network variant suffix (e.g., `-Corp`, `-Cust`, `-SpecBus`) in the definitions reference table. This feature groups those variant entries into a single card per metric family, adds hover tooltips with metric descriptions for quick context, provides an info panel for full metric definitions, and ships a static JSON reference file containing the complete metric definitions data. The goal is to reduce card clutter, surface metric context to engineers unfamiliar with the metrics, and preserve the existing card-click filtering behavior.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Compliance_Page**: The `CompliancePage.js` React component that renders metric health cards, team tabs, and the device violation table
|
||||||
|
- **Metric_Family**: A group of summary entries that share the same base metric ID (e.g., `5.2.5`), regardless of network variant suffix
|
||||||
|
- **Network_Variant**: A suffix classification from the metric definitions table indicating which network a metric applies to — Corp, Cust, or SpecBus
|
||||||
|
- **Variant_Pill**: A small inline badge within a grouped metric card that displays a single network variant's suffix label and its compliance percentage
|
||||||
|
- **Metric_Health_Card**: The existing `MetricHealthCard` button component that displays a metric's compliance status, now extended to support grouped variants
|
||||||
|
- **Worst_Status**: The most severe compliance status among all variants in a Metric_Family, used to determine the card's overall border and status color
|
||||||
|
- **Hover_Tooltip**: A floating overlay that appears on mouse hover over a Metric_Health_Card, showing the metric title, business justification, and data sources
|
||||||
|
- **Info_Icon**: A small `Info` icon from lucide-react placed in the corner of each Metric_Health_Card that opens the Detail_Panel on click
|
||||||
|
- **Detail_Panel**: A slide-out or inline expandable section that displays the full metric definition including scope, filters, exclusions, and per-variant notes
|
||||||
|
- **Metric_Definitions_File**: A static JSON file shipped with the frontend containing structured metric definition data for all tracked metrics
|
||||||
|
- **Design_System**: The color palette, typography, component specs, and interaction patterns defined in `DESIGN_SYSTEM.md`
|
||||||
|
- **Summary_Entry**: A single row from the backend's `/api/compliance/summary` response, containing `metric_id`, `team`, `compliance_pct`, `target`, `status`, `description`, and `category`
|
||||||
|
- **Device_Table**: The lower section of the Compliance_Page that lists non-compliant devices, filterable by metric
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Metric Family Grouping
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want metrics that share the same base ID to be consolidated into a single card, so that the compliance page is less cluttered and I can see the full picture for each metric family at a glance.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Compliance_Page receives Summary_Entry data, THE Compliance_Page SHALL group entries by their base metric ID to form Metric_Family groups
|
||||||
|
2. THE Compliance_Page SHALL render one Metric_Health_Card per Metric_Family instead of one card per Summary_Entry
|
||||||
|
3. THE Metric_Health_Card SHALL display the base metric ID (e.g., `5.2.5`) as the card title and the category name from the first entry in the group
|
||||||
|
4. THE Metric_Health_Card SHALL display one Variant_Pill for each Summary_Entry in the Metric_Family, showing the variant's team label and compliance percentage
|
||||||
|
5. WHEN a Metric_Family contains only one Summary_Entry, THE Metric_Health_Card SHALL display a single Variant_Pill — the layout scales naturally without special-casing
|
||||||
|
6. THE Metric_Health_Card SHALL determine its overall border color and status indicator using the Worst_Status among all variants in the Metric_Family
|
||||||
|
7. THE Metric_Health_Card SHALL display the shared target percentage from the Metric_Family entries
|
||||||
|
8. WHEN a user clicks a grouped Metric_Health_Card, THE Compliance_Page SHALL filter the Device_Table to show violations across all metric IDs belonging to that Metric_Family
|
||||||
|
|
||||||
|
### Requirement 2: Variant Pill Display
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to see each network variant's compliance percentage inside the grouped card, so that I can quickly identify which variant is underperforming.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Variant_Pill SHALL display the variant's distinguishing label (team name or suffix) and its compliance percentage in monospace font
|
||||||
|
2. THE Variant_Pill SHALL use a background tint derived from the variant's individual status color at low opacity, consistent with the Design_System badge pattern
|
||||||
|
3. WHEN a variant's status is not "Meets/Exceeds Target", THE Variant_Pill SHALL display a subtle glow dot matching the variant's status color to draw attention
|
||||||
|
4. THE Variant_Pill layout SHALL wrap to multiple rows when the Metric_Family contains more variants than fit on a single line
|
||||||
|
|
||||||
|
### Requirement 3: Worst-Status Card Coloring
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want the grouped card to immediately show me if any variant is failing, so that I do not have to inspect each variant individually to find problems.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Metric_Health_Card SHALL compute the Worst_Status by selecting the most severe status from all Summary_Entry items in the Metric_Family, using the severity order: "Below 15% of Target" (worst) > "Within 15% of Target" > "Meets/Exceeds Target" (best)
|
||||||
|
2. THE Metric_Health_Card SHALL apply the Worst_Status color to its border, status pill text, and status dot
|
||||||
|
3. WHEN all variants in a Metric_Family meet or exceed the target, THE Metric_Health_Card SHALL display the "OK" status indicator with the success color
|
||||||
|
|
||||||
|
### Requirement 4: Hover Tooltip for Quick Context
|
||||||
|
|
||||||
|
**User Story:** As an engineer unfamiliar with the metrics, I want to hover over a metric card and see a brief description, so that I can understand what the metric measures without disrupting my workflow.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user hovers over a Metric_Health_Card for more than 300 milliseconds, THE Compliance_Page SHALL display a Hover_Tooltip positioned near the card
|
||||||
|
2. THE Hover_Tooltip SHALL display the metric title, a one-liner business justification, and the data sources required, sourced from the Metric_Definitions_File
|
||||||
|
3. THE Hover_Tooltip SHALL use the Design_System dark card background with a subtle border and shadow for readability
|
||||||
|
4. WHEN the user moves the cursor away from the Metric_Health_Card, THE Hover_Tooltip SHALL disappear
|
||||||
|
5. THE Hover_Tooltip SHALL NOT interfere with the card's click behavior for filtering the Device_Table
|
||||||
|
6. IF no definition exists in the Metric_Definitions_File for a given metric, THEN THE Hover_Tooltip SHALL display the metric description from the Summary_Entry data as a fallback
|
||||||
|
|
||||||
|
### Requirement 5: Info Icon and Detail Panel
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to click an info icon on a metric card to see the full metric definition, so that I can understand the exact scope, filters, and exclusions without leaving the compliance page.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Metric_Health_Card SHALL display an Info_Icon (lucide-react `Info`) in the top-right corner of the card
|
||||||
|
2. WHEN a user clicks the Info_Icon, THE Compliance_Page SHALL open a Detail_Panel displaying the full metric definition from the Metric_Definitions_File
|
||||||
|
3. THE Detail_Panel SHALL display: metric title, 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 per-variant notes
|
||||||
|
4. THE Detail_Panel SHALL use the Design_System dark theme with section labels in monospace uppercase and content in the standard text colors
|
||||||
|
5. WHEN a user clicks the Info_Icon, THE click event SHALL NOT propagate to the Metric_Health_Card's onClick handler that filters the Device_Table
|
||||||
|
6. WHEN a user clicks outside the Detail_Panel or clicks a close button, THE Detail_Panel SHALL close
|
||||||
|
7. IF no definition exists in the Metric_Definitions_File for a given metric, THEN THE Detail_Panel SHALL display a "No detailed definition available" message with the Summary_Entry description as fallback content
|
||||||
|
|
||||||
|
### Requirement 6: Metric Definitions Data File
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want metric definitions stored as a static JSON file in the frontend, so that the tooltip and detail panel can render metric context without additional API calls.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Metric_Definitions_File SHALL be a JSON file located in the frontend source directory (e.g., `frontend/src/data/metricDefinitions.json`)
|
||||||
|
2. THE Metric_Definitions_File SHALL contain an entry for each metric ID with the following fields: 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, and notes
|
||||||
|
3. THE Metric_Definitions_File SHALL be importable as a standard JavaScript module using a static import statement
|
||||||
|
4. WHEN the Metric_Definitions_File is loaded, THE Compliance_Page SHALL build a lookup map keyed by metric_id for efficient access
|
||||||
|
5. THE Metric_Definitions_File SHALL use a flat array structure where each entry represents one metric row from the definitions table
|
||||||
|
|
||||||
|
### Requirement 7: Preserved Card-Click Filtering Behavior
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want clicking a grouped metric card to still filter the device table, so that the existing workflow for investigating violations is not disrupted.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user clicks a grouped Metric_Health_Card (outside the Info_Icon), THE Compliance_Page SHALL set the metric filter to include all metric IDs in that Metric_Family
|
||||||
|
2. WHEN a metric filter is active for a Metric_Family, THE Device_Table SHALL display devices that have violations for any metric ID within that family
|
||||||
|
3. WHEN a user clicks the same grouped Metric_Health_Card again, THE Compliance_Page SHALL clear the metric filter
|
||||||
|
4. THE "clear filter" button in the metric health section header SHALL continue to reset the filter to show all devices
|
||||||
|
5. THE Metric_Health_Card SHALL visually indicate the active/selected state using the existing highlight pattern (tinted background with the status color)
|
||||||
|
|
||||||
|
### Requirement 8: Metric Definitions File Structure and Round-Trip Integrity
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the metric definitions JSON to be parseable and printable without data loss, so that the file can be maintained and validated reliably.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Metric_Definitions_File SHALL be valid JSON that parses without error using `JSON.parse()`
|
||||||
|
2. FOR ALL entries in the Metric_Definitions_File, parsing the JSON then stringifying it then parsing it again SHALL produce an equivalent object (round-trip property)
|
||||||
|
3. THE Metric_Definitions_File SHALL contain a `metric_id` field in every entry that is a non-empty string
|
||||||
|
4. IF an optional field (exclusions, special_conditions, notes) has no value for a metric, THEN THE Metric_Definitions_File SHALL represent it as an empty string rather than omitting the key
|
||||||
178
.kiro/specs/compliance-metric-grouping/tasks.md
Normal file
178
.kiro/specs/compliance-metric-grouping/tasks.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Implementation Plan: Compliance Metric Grouping
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Consolidate the AEO Compliance page's metric health cards from one-per-summary-entry to one-per-metric-family. Add variant pills inside each grouped card, a hover tooltip with metric context (300ms delay), an info panel for full metric definitions, and a static `metricDefinitions.json` data file. All work is frontend-only — no backend changes needed. The `metricFilter` state changes from `string|null` to `string[]|null` to support filtering by all metric IDs in a family.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Create metric definitions data file and install test dependencies
|
||||||
|
- [x] 1.1 Create `frontend/src/data/metricDefinitions.json`
|
||||||
|
- Create the `frontend/src/data/` directory
|
||||||
|
- Build the JSON array from the metric definitions table provided by the user (130+ rows, 14 fields each)
|
||||||
|
- Each entry must have all 14 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`
|
||||||
|
- Use empty strings for optional fields with no value — never `null` or omitted keys
|
||||||
|
- Verify the file imports without error via a quick `JSON.parse` check
|
||||||
|
- _Requirements: 6.1, 6.2, 6.3, 6.5, 8.3, 8.4_
|
||||||
|
|
||||||
|
- [x] 1.2 Install `fast-check` as a dev dependency
|
||||||
|
- Run `npm install --save-dev fast-check` in `frontend/`
|
||||||
|
- Verify it appears in `package.json` devDependencies
|
||||||
|
- _Requirements: (testing infrastructure)_
|
||||||
|
|
||||||
|
- [x] 2. Implement pure helper functions and their tests
|
||||||
|
- [x] 2.1 Add `computeWorstStatus` and `groupByMetricFamily` helpers to CompliancePage.js
|
||||||
|
- Add `STATUS_SEVERITY` map: `{ 'Below 15% of Target': 0, 'Within 15% of Target': 1, 'Meets/Exceeds Target': 2 }`
|
||||||
|
- Implement `computeWorstStatus(statuses)` — returns the status with the lowest severity rank from a non-empty array
|
||||||
|
- Implement `groupByMetricFamily(allEntries, team)` — filters entries by team, groups by `metric_id`, returns array of `{ metricId, entries, category, target, worstStatus }` objects
|
||||||
|
- Export both functions for testing (named exports alongside the default CompliancePage export)
|
||||||
|
- Remove the existing `teamMetrics()` helper (replaced by `groupByMetricFamily`)
|
||||||
|
- _Requirements: 1.1, 1.2, 1.6, 3.1_
|
||||||
|
|
||||||
|
- [x] 2.2 Write property test: Grouping invariant — no entries lost or misplaced
|
||||||
|
- **Property 1: Grouping invariant — no entries lost or misplaced**
|
||||||
|
- Create test file `frontend/src/components/pages/__tests__/complianceGrouping.property.test.js`
|
||||||
|
- Generate random arrays of summary entry objects with varying `metric_id` and `team` values
|
||||||
|
- Verify: (a) every entry appears in exactly one group, (b) all entries within a group share the same `metric_id`, (c) total entries across groups equals team-filtered input count
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 1.1, 1.2**
|
||||||
|
|
||||||
|
- [x] 2.3 Write property test: Worst-status computation follows severity ordering
|
||||||
|
- **Property 2: Worst-status computation follows severity ordering**
|
||||||
|
- Generate random non-empty arrays of status strings from `{"Below 15% of Target", "Within 15% of Target", "Meets/Exceeds Target"}`
|
||||||
|
- Verify the result is the status with the lowest severity rank present in the array
|
||||||
|
- If the array contains "Below 15% of Target", the result must be "Below 15% of Target"
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 1.6, 3.1**
|
||||||
|
|
||||||
|
- [ ]* 2.4 Write property test: Device filtering with metric family includes all matching devices
|
||||||
|
- **Property 3: Device filtering with metric family includes all matching devices**
|
||||||
|
- Generate random device arrays (each with a `failing_metrics` array of `{ metric_id }` objects) and random filter ID arrays
|
||||||
|
- Verify the filtered result contains exactly those devices with at least one matching `metric_id` in the filter array
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 1.8, 7.1, 7.2**
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint — Verify helpers and property tests
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Redesign MetricHealthCard with variant pills and worst-status coloring
|
||||||
|
- [x] 4.1 Redesign `MetricHealthCard` to accept a family group
|
||||||
|
- Change props from `{ entry, active, onClick }` to `{ family, active, onClick, onInfoClick, definitionLookup }`
|
||||||
|
- `family` is `{ metricId, entries, category, target, worstStatus }`
|
||||||
|
- Display base `metricId` as card title and `category` from the family
|
||||||
|
- Display shared `target` percentage
|
||||||
|
- Use `worstStatus` color for card border, status pill text, and status dot
|
||||||
|
- When all variants meet/exceed target, show "OK" status indicator with success color
|
||||||
|
- Add `Info` icon (lucide-react) in the top-right corner with `stopPropagation` on click to call `onInfoClick(family.metricId)`
|
||||||
|
- _Requirements: 1.2, 1.3, 1.6, 1.7, 3.1, 3.2, 3.3, 5.1, 5.5_
|
||||||
|
|
||||||
|
- [x] 4.2 Implement `VariantPill` inline sub-component
|
||||||
|
- Render one pill per `entry` in `family.entries`
|
||||||
|
- Each pill shows the entry's `description` or `team` label and compliance percentage in monospace
|
||||||
|
- Background tint from the entry's individual status color at ~12% opacity
|
||||||
|
- Show a glow dot when the variant's status is not "Meets/Exceeds Target"
|
||||||
|
- Layout: `inline-flex` with `flexWrap: 'wrap'` on the parent container
|
||||||
|
- _Requirements: 1.4, 1.5, 2.1, 2.2, 2.3, 2.4_
|
||||||
|
|
||||||
|
- [x] 5. Update CompliancePage state and rendering to use grouped families
|
||||||
|
- [x] 5.1 Add new imports and build the definitions lookup map
|
||||||
|
- Import `{ Info }` from `lucide-react`
|
||||||
|
- Import `MetricInfoPanel` from `./MetricInfoPanel` (created in task 6)
|
||||||
|
- Import `metricDefinitionsRaw` from `../../data/metricDefinitions.json`
|
||||||
|
- Build `METRIC_DEFINITIONS` lookup object at module level keyed by `metric_id`
|
||||||
|
- _Requirements: 6.3, 6.4_
|
||||||
|
|
||||||
|
- [x] 5.2 Update state management and filter logic
|
||||||
|
- Change `metricFilter` from `string|null` to `string[]|null`
|
||||||
|
- Add new state: `infoMetric` (`string|null`) — which metric's info panel is open
|
||||||
|
- Add new state: `hoveredMetric` (`string|null`) — which metric is being hovered
|
||||||
|
- Add new ref: `hoverTimeoutRef` — for 300ms delay management
|
||||||
|
- Update `filteredDevices` filter from `m.metric_id === metricFilter` to `metricFilter.includes(m.metric_id)`
|
||||||
|
- _Requirements: 7.1, 7.2_
|
||||||
|
|
||||||
|
- [x] 5.3 Update card rendering to use `groupByMetricFamily`
|
||||||
|
- Replace `const metrics = teamMetrics(summary.entries, activeTeam)` with `const families = groupByMetricFamily(summary.entries, activeTeam)`
|
||||||
|
- Replace `metrics.map(entry => <MetricHealthCard entry={entry} ... />)` with `families.map(family => <MetricHealthCard family={family} ... />)`
|
||||||
|
- On card click: set `metricFilter` to `family.entries.map(e => e.metric_id)` (array of all IDs in the family), or clear if already active
|
||||||
|
- Active state check: compare `metricFilter` array contents against the family's metric IDs
|
||||||
|
- Pass `onInfoClick` handler that sets `infoMetric` state
|
||||||
|
- Pass `definitionLookup` as `METRIC_DEFINITIONS`
|
||||||
|
- _Requirements: 1.2, 1.8, 7.1, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
|
- [x] 6. Implement MetricInfoPanel component
|
||||||
|
- [x] 6.1 Create `frontend/src/components/pages/MetricInfoPanel.js`
|
||||||
|
- Props: `metricId`, `definition` (from lookup or null), `summaryEntries` (family entries for fallback), `onClose`
|
||||||
|
- Render overlay/slide-out panel with dark theme matching DESIGN_SYSTEM.md
|
||||||
|
- Close button (X icon, top-right)
|
||||||
|
- Metric title in 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
|
||||||
|
- Show "—" placeholder for empty string fields
|
||||||
|
- If `definition` is null: show "No detailed definition available" with summary description fallback
|
||||||
|
- Click outside or close button calls `onClose()`
|
||||||
|
- _Requirements: 5.2, 5.3, 5.4, 5.6, 5.7_
|
||||||
|
|
||||||
|
- [x] 7. Implement HoverTooltip inline in CompliancePage
|
||||||
|
- [x] 7.1 Add hover tooltip logic and rendering
|
||||||
|
- On `mouseEnter` on MetricHealthCard: set 300ms timeout, then set `hoveredMetric` to `family.metricId`
|
||||||
|
- On `mouseLeave`: clear timeout, set `hoveredMetric` to null
|
||||||
|
- Render tooltip when `hoveredMetric` matches a family — positioned near the card using `getBoundingClientRect()`
|
||||||
|
- Tooltip content: metric title, business justification, data sources required (from `METRIC_DEFINITIONS` lookup)
|
||||||
|
- Fall back to summary entry `description` when no definition exists
|
||||||
|
- Dark card background with subtle border and shadow per DESIGN_SYSTEM.md
|
||||||
|
- Tooltip must not interfere with card click behavior
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||||
|
|
||||||
|
- [x] 8. Checkpoint — Verify full UI integration
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 9. Property tests for definitions data and lookup
|
||||||
|
- [x] 9.1 Write property test: Definition lookup returns correct entry or null
|
||||||
|
- **Property 4: Definition lookup returns correct entry or null**
|
||||||
|
- Create test file `frontend/src/components/pages/__tests__/metricDefinitions.property.test.js`
|
||||||
|
- Generate random arrays of metric definition objects with unique `metric_id` values
|
||||||
|
- Build lookup map, query with IDs from the array (expect hit) and IDs not in the array (expect miss)
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 4.2, 4.6**
|
||||||
|
|
||||||
|
- [x] 9.2 Write property test: Detail panel renders all required definition fields
|
||||||
|
- **Property 5: Detail panel renders all required definition fields**
|
||||||
|
- Generate random metric definition objects with all 14 fields
|
||||||
|
- Extract the set of field keys that the MetricInfoPanel renders and verify all required keys are present
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 5.3**
|
||||||
|
|
||||||
|
- [x] 9.3 Write property test: Definitions schema validation — all entries have required fields
|
||||||
|
- **Property 6: Definitions schema validation — all entries have required fields**
|
||||||
|
- 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 (`exclusions`, `special_conditions`, `notes`) are strings (not null/undefined)
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 6.2, 8.3, 8.4**
|
||||||
|
|
||||||
|
- [x] 9.4 Write property test: Lookup map construction preserves all definitions
|
||||||
|
- **Property 7: Lookup map construction preserves all definitions**
|
||||||
|
- Generate random definition arrays with unique `metric_id` values
|
||||||
|
- Build lookup map and verify map size equals array length, and every definition is retrievable by its `metric_id`
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 6.4**
|
||||||
|
|
||||||
|
- [x] 9.5 Write property test: JSON round-trip preserves metric definition data
|
||||||
|
- **Property 8: JSON round-trip preserves metric definition data**
|
||||||
|
- Generate random metric definition objects with string values for all fields
|
||||||
|
- Round-trip through `JSON.stringify` then `JSON.parse` and verify deep equality
|
||||||
|
- Use `fc.assert(property, { numRuns: 100 })`
|
||||||
|
- **Validates: Requirements 8.1, 8.2**
|
||||||
|
|
||||||
|
- [x] 10. Final checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document using fast-check
|
||||||
|
- Unit tests validate specific examples and edge cases
|
||||||
|
- All styling follows the project convention of inline styles (no CSS modules or Tailwind)
|
||||||
|
- The `fast-check` library must be installed as a dev dependency before running property tests
|
||||||
|
- The `metricDefinitions.json` file contains 130+ rows — the user will provide the metric definitions table data for conversion
|
||||||
|
- `computeWorstStatus` and `groupByMetricFamily` are exported as named exports from CompliancePage.js for testability
|
||||||
@@ -41,5 +41,16 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!(fast-check)/)"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^pure-rand/(.*)$": "<rootDir>/node_modules/pure-rand/lib/$1.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"fast-check": "^4.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw } from 'lucide-react';
|
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
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 API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const TEAL = '#14B8A6';
|
const TEAL = '#14B8A6';
|
||||||
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,18 +46,83 @@ function pctDisplay(pct) {
|
|||||||
return `${Math.round(pct * 100)}%`;
|
return `${Math.round(pct * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate summary entries — one per metric_id for the selected team
|
const STATUS_SEVERITY = {
|
||||||
// (exclude aggregate "ALL: NTS-AEO" rows)
|
'Below 15% of Target': 0,
|
||||||
function teamMetrics(entries, team) {
|
'Within 15% of Target': 1,
|
||||||
return entries.filter(e => e.team === team);
|
'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
|
// Sub-components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function MetricHealthCard({ entry, active, onClick }) {
|
function VariantPill({ entry }) {
|
||||||
const color = statusColor(entry.status);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -66,33 +139,58 @@ function MetricHealthCard({ entry, active, onClick }) {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
minWidth: '160px',
|
minWidth: '160px',
|
||||||
flex: '1 1 0',
|
flex: '1 1 0',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
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 */}
|
{/* Metric ID */}
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem', paddingRight: '1.25rem' }}>
|
||||||
{entry.metric_id}
|
{family.metricId}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{entry.category}
|
{family.category}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compliance % */}
|
{/* Variant pills */}
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
||||||
{pctDisplay(entry.compliance_pct)}
|
{family.entries.map((entry, i) => (
|
||||||
|
<VariantPill key={entry.metric_id + '-' + i} entry={entry} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Target */}
|
{/* Target */}
|
||||||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginBottom: '0.5rem' }}>
|
||||||
target {pctDisplay(entry.target)}
|
target {pctDisplay(family.target)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status pill */}
|
{/* Status pill */}
|
||||||
<div style={{
|
<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',
|
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
color, padding: '0.2rem 0.5rem',
|
color, padding: '0.2rem 0.5rem',
|
||||||
background: `${color}12`, borderRadius: '999px',
|
background: `${color}12`, borderRadius: '999px',
|
||||||
@@ -103,7 +201,7 @@ function MetricHealthCard({ entry, active, onClick }) {
|
|||||||
background: color, flexShrink: 0,
|
background: color, flexShrink: 0,
|
||||||
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||||
}} />
|
}} />
|
||||||
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
|
{isOk ? 'OK' : family.worstStatus.replace(' of Target', '')}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -158,6 +256,10 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
const [rollbackConfirm, setRollbackConfirm] = useState(false);
|
const [rollbackConfirm, setRollbackConfirm] = useState(false);
|
||||||
const [rollbackLoading, setRollbackLoading] = useState(false);
|
const [rollbackLoading, setRollbackLoading] = useState(false);
|
||||||
const [rollbackResult, setRollbackResult] = useState(null);
|
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) => {
|
const fetchSummary = useCallback(async (team) => {
|
||||||
try {
|
try {
|
||||||
@@ -225,10 +327,10 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
|
|
||||||
// In-memory filters
|
// In-memory filters
|
||||||
const filteredDevices = devices
|
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()));
|
.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;
|
const lastUpload = summary.upload;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -336,7 +438,7 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||||
{metrics.length > 0 ? (
|
{families.length > 0 ? (
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||||
Metric Health — click to filter
|
Metric Health — click to filter
|
||||||
@@ -348,15 +450,81 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||||||
{metrics.map(entry => (
|
{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
|
<MetricHealthCard
|
||||||
key={entry.metric_id}
|
family={family}
|
||||||
entry={entry}
|
active={isActive}
|
||||||
active={metricFilter === entry.metric_id}
|
onClick={() => setMetricFilter(isActive ? null : familyIds)}
|
||||||
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
|
onInfoClick={(metricId) => setInfoMetric(metricId)}
|
||||||
|
definitionLookup={METRIC_DEFINITIONS}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : lastUpload === null ? (
|
) : lastUpload === null ? (
|
||||||
<div style={{
|
<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 ──────────────────────────── */}
|
{/* ── Rollback confirmation modal ──────────────────────────── */}
|
||||||
{rollbackConfirm && lastUpload && (
|
{rollbackConfirm && lastUpload && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -655,3 +833,6 @@ function DeviceRow({ device, selected, onClick }) {
|
|||||||
</div>
|
</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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1394
frontend/src/data/metricDefinitions.json
Normal file
1394
frontend/src/data/metricDefinitions.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user