Files
cve-dashboard/.kiro/specs/atlas-metrics-report/design.md

363 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Document: Atlas Metrics Report
## Overview
This feature adds a tab system to the existing Metric Graphs panel on the ReportingPage, separating Ivanti donut charts from new Atlas coverage charts. A new `GET /api/atlas/metrics` endpoint aggregates cached Atlas action plan data into chart-ready metrics. The frontend renders three new donut charts — coverage, plan type distribution, and plan status distribution — under an "Atlas Coverage" tab, while the existing four Ivanti donuts move under an "Ivanti Findings" tab.
### Key Design Decisions
- **Server-side aggregation**: The metrics endpoint computes counts and distributions on the backend rather than shipping raw `plans_json` to the client. This keeps the frontend simple and avoids parsing potentially large JSON arrays in the browser.
- **Reuse existing donut helpers**: The new Atlas donut charts reuse the `polarToCartesian` and `donutArcPath` SVG helper functions already defined in ReportingPage.js, along with the same dimensions (180px size, 72px outer radius, 48px inner radius). This keeps the visual language consistent.
- **Fetch once, refresh on sync**: Atlas metrics are fetched once on page mount and re-fetched only after a successful Atlas sync. Tab switches do not trigger new API calls.
- **No server.js changes**: The new endpoint is added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`. The router is already mounted at `/api/atlas` in server.js.
- **Tab state is local**: The active tab is stored in React component state — no URL params, no localStorage. The default is "Ivanti Findings" to preserve the existing experience.
- **IvantiCountsChart conditional visibility**: The trend chart below the Metric Graphs panel is only shown when the Ivanti tab is active, since it has no relevance to Atlas data.
## Architecture
```mermaid
graph TD
subgraph Frontend - ReportingPage
TS[Tab System<br/>Ivanti Findings | Atlas Coverage]
ID[Ivanti Donuts<br/>StatusDonut, ActionCoverageDonut,<br/>FPWorkflowDonut x2]
AD[Atlas Donuts<br/>CoverageDonut, PlanTypeDonut,<br/>PlanStatusDonut]
IC[IvantiCountsChart]
end
subgraph Backend - Atlas Router
ME[GET /api/atlas/metrics]
AC[(atlas_action_plans_cache<br/>SQLite)]
end
TS -->|tab = ivanti| ID
TS -->|tab = ivanti| IC
TS -->|tab = atlas| AD
AD -->|fetch on mount + after sync| ME
ME -->|SELECT + aggregate| AC
```
### Data Flow: Metrics Fetch
1. ReportingPage mounts → calls `GET /api/atlas/metrics`
2. Backend queries all rows from `atlas_action_plans_cache`, including `plans_json`
3. Backend iterates rows, parses each `plans_json`, counts plans by type and status
4. Backend returns aggregated JSON: `{ totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }`
5. Frontend stores result in `atlasMetrics` state
6. When "Atlas Coverage" tab is active, three donut components render from this state
### Data Flow: Refresh After Sync
1. User clicks Atlas sync button → `POST /api/atlas/sync` (existing)
2. On success, frontend calls `GET /api/atlas/metrics` again
3. Atlas donut charts re-render with updated data
## Components and Interfaces
### Backend: GET /api/atlas/metrics Endpoint
Added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`.
| Method | Path | Auth | Group | Description |
|--------|------|------|-------|-------------|
| `GET` | `/metrics` | requireAuth | any | Return aggregated Atlas metrics for chart rendering |
**Response shape**:
```json
{
"totalHosts": 42,
"hostsWithPlans": 28,
"hostsWithoutPlans": 14,
"plansByType": {
"decommission": 5,
"remediation": 18,
"false_positive": 3,
"risk_acceptance": 8,
"scan_exclusion": 2
},
"plansByStatus": {
"active": 25,
"expired": 7,
"completed": 4
},
"totalPlans": 36
}
```
**Implementation approach**:
1. Query all rows: `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
2. Initialize counters: `totalHosts = rows.length`, `hostsWithPlans = 0`, `hostsWithoutPlans = 0`, `plansByType = {}`, `plansByStatus = {}`, `totalPlans = 0`
3. For each row:
- If `has_action_plan === 1`, increment `hostsWithPlans`; else increment `hostsWithoutPlans`
- Try to parse `plans_json`; on failure, skip plan details for that row
- For each plan in the parsed array, increment the corresponding `plansByType[plan.plan_type]` and `plansByStatus[plan.status]` counters, and increment `totalPlans`
4. Return the aggregated object
Uses the existing `dbAll` promise wrapper already defined in `atlas.js`.
### Frontend: Tab System
A horizontal tab bar rendered inside the Metric Graphs panel header, to the right of the "Metric Graphs" title and `PieChart` icon.
**State**: `metricsTab``'ivanti'` (default) or `'atlas'`
**Tab bar structure**:
```
┌──────────────────────────────────────────────────────────────┐
│ [PieChart icon] METRIC GRAPHS [Ivanti Findings] [Atlas Coverage] │
└──────────────────────────────────────────────────────────────┘
```
**Styling**:
- Tabs use `role="tab"` and `aria-selected`; the content area uses `role="tabpanel"`
- Active tab: `color: #F59E0B`, `borderBottom: 2px solid #F59E0B`
- Inactive tab: `color: #64748B`, no bottom border
- Hover on inactive: `background: rgba(245, 158, 11, 0.06)`
- Font: `'JetBrains Mono', monospace`, `0.7rem`, `uppercase`, `letterSpacing: 0.08em`
- Tabs are keyboard navigable via Tab and Enter keys
### Frontend: Atlas Metrics State
New state variables in the ReportingPage component:
```javascript
const [metricsTab, setMetricsTab] = useState('ivanti');
const [atlasMetrics, setAtlasMetrics] = useState(null);
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
```
New fetch function:
```javascript
const fetchAtlasMetrics = useCallback(async () => {
setAtlasMetricsLoading(true);
setAtlasMetricsError(null);
try {
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAtlasMetrics(data);
} else {
const err = await res.json().catch(() => ({}));
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
}
} catch (err) {
setAtlasMetricsError(err.message);
} finally {
setAtlasMetricsLoading(false);
}
}, []);
```
Called on mount (in the existing `useEffect`) and after a successful Atlas sync.
### Frontend: Atlas Donut Charts
Three new inline components defined in ReportingPage.js, following the same pattern as `StatusDonut`, `ActionCoverageDonut`, and `FPWorkflowDonut`. All reuse the existing `polarToCartesian` and `donutArcPath` helper functions.
**AtlasCoverageDonut**
- Props: `{ hostsWithPlans, hostsWithoutPlans, totalHosts }`
- Segments: emerald (`#10B981`) for with plans, amber (`#F59E0B`) for without plans
- Center text: `totalHosts` count, "HOSTS" label
- Empty state: "No data — run Atlas Sync"
**AtlasPlanTypeDonut**
- Props: `{ plansByType, totalPlans }`
- Color map: `decommission: #EF4444`, `remediation: #0EA5E9`, `false_positive: #A855F7`, `risk_acceptance: #F59E0B`, `scan_exclusion: #64748B`
- Center text: `totalPlans` count, "PLANS" label
- Legend: only shows types with count > 0
- Empty state: "No plans — run Atlas Sync"
**AtlasPlanStatusDonut**
- Props: `{ plansByStatus, totalPlans }`
- Color map: `active: #10B981`, `expired: #EF4444`, `completed: #0EA5E9`, fallback: `#64748B`
- Center text: `totalPlans` count, "STATUS" label
- Legend: only shows statuses with count > 0
- Empty state: "No plans — run Atlas Sync"
All three follow the same SVG dimensions: 180px size, 72px outer radius, 48px inner radius.
### Frontend: Metric Graphs Panel Layout
When "Ivanti Findings" tab is active:
- Existing four donuts in horizontal flex row with dividers (unchanged)
- IvantiCountsChart rendered below the panel (unchanged)
When "Atlas Coverage" tab is active:
- Three Atlas donuts in horizontal flex row with dividers (same layout pattern)
- IvantiCountsChart hidden
## Data Models
### Metrics Endpoint Response
| Field | Type | Description |
|-------|------|-------------|
| `totalHosts` | integer | Count of all rows in `atlas_action_plans_cache` |
| `hostsWithPlans` | integer | Count of rows where `has_action_plan = 1` |
| `hostsWithoutPlans` | integer | Count of rows where `has_action_plan = 0` |
| `plansByType` | object | Map of plan type string → integer count |
| `plansByStatus` | object | Map of plan status string → integer count |
| `totalPlans` | integer | Sum of all plans across all hosts |
### Existing Atlas Cache Table (no changes)
The `atlas_action_plans_cache` table is unchanged. The metrics endpoint reads from it:
| Column | Type | Used by metrics endpoint |
|--------|------|--------------------------|
| `has_action_plan` | INTEGER | Counting hosts with/without plans |
| `plans_json` | TEXT | Parsed to count plans by type and status |
### Plan Object Shape (within `plans_json`)
The metrics endpoint reads `plan_type` and `status` from each plan object in the JSON array. The Atlas API returns these fields as strings. Example:
```json
{
"action_plan_id": "ap-123",
"plan_type": "remediation",
"status": "active",
"commit_date": "2026-07-01"
}
```
Known `plan_type` values: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`
Known `status` values: `active`, `expired`, `completed` (the frontend handles unknown values with a neutral color)
## 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: Metrics aggregation correctness
*For any* array of cache rows — each with a `has_action_plan` flag (0 or 1) and a `plans_json` string that is either valid JSON containing an array of plan objects (each with `plan_type` and `status` fields) or invalid JSON — the metrics aggregation function SHALL produce:
- `totalHosts` equal to the number of rows
- `hostsWithPlans + hostsWithoutPlans` equal to `totalHosts`
- `hostsWithPlans` equal to the count of rows where `has_action_plan === 1`
- `totalPlans` equal to the sum of plan array lengths across all rows with valid JSON
- Each key in `plansByType` maps to the count of plans with that `plan_type` across all valid rows
- Each key in `plansByStatus` maps to the count of plans with that `status` across all valid rows
- Rows with invalid `plans_json` are counted in `totalHosts` and `hostsWithPlans`/`hostsWithoutPlans` but their plans are excluded from `plansByType`, `plansByStatus`, and `totalPlans`
**Validates: Requirements 1.3, 1.4, 1.5**
### Property 2: Coverage donut data correctness
*For any* non-negative integer pair `(hostsWithPlans, hostsWithoutPlans)` where `totalHosts = hostsWithPlans + hostsWithoutPlans > 0`, the Coverage Donut SHALL display `totalHosts` as center text, and the legend SHALL show counts matching the input values with percentages that equal `(count / totalHosts) * 100` for each segment.
**Validates: Requirements 3.3, 3.4**
### Property 3: Plan type donut data correctness
*For any* `plansByType` object mapping plan type strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Type Donut SHALL display `totalPlans` as center text, the legend SHALL include only types with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`.
**Validates: Requirements 4.3, 4.4**
### Property 4: Plan status donut data correctness
*For any* `plansByStatus` object mapping status strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Status Donut SHALL display `totalPlans` as center text, the legend SHALL include only statuses with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`.
**Validates: Requirements 5.3, 5.4**
### Property 5: Plan status color assignment
*For any* status string, the Plan Status Donut color assignment function SHALL return `#10B981` if the status is `"active"`, `#EF4444` if `"expired"`, `#0EA5E9` if `"completed"`, and `#64748B` for any other string value.
**Validates: Requirements 5.2**
## Error Handling
### Backend Errors
| Error Scenario | Handling | HTTP Status | Response |
|----------------|----------|-------------|----------|
| Atlas not configured (missing env vars) | Return 503 with descriptive message | 503 | `{ error: 'Atlas API is not configured...' }` |
| Database query failure | Catch error, log, return 500 | 500 | `{ error: 'Failed to fetch Atlas metrics.' }` |
| Invalid JSON in `plans_json` column | Skip that row's plan details, continue processing | N/A (handled internally) | Metrics still returned with correct counts for valid rows |
| Empty cache table | Return metrics object with all zeros | 200 | `{ totalHosts: 0, hostsWithPlans: 0, hostsWithoutPlans: 0, plansByType: {}, plansByStatus: {}, totalPlans: 0 }` |
| Unauthenticated request | Auth middleware rejects | 401 | `{ error: 'Authentication required' }` |
### Frontend Errors
| Error Scenario | Handling | User Impact |
|----------------|----------|-------------|
| Metrics fetch returns non-200 | Store error message in `atlasMetricsError` state | Error message displayed in Atlas Coverage tab content area |
| Metrics fetch network failure | Catch error, store message | Error message displayed with failure reason |
| Metrics fetch in progress | `atlasMetricsLoading = true` | Loading spinner shown in Atlas Coverage tab content area |
| Atlas metrics data is null (not yet fetched) | Donut components check for null/undefined | Loading state or empty state shown |
## Testing Strategy
### Unit Tests
Unit tests cover specific examples, edge cases, and integration points:
**Backend — Metrics Aggregation (`GET /api/atlas/metrics`)**:
- Returns all-zero metrics when cache table is empty
- Correctly counts hosts with and without plans from seeded data
- Correctly aggregates plansByType from multiple hosts
- Correctly aggregates plansByStatus from multiple hosts
- Skips plan details for rows with invalid JSON but still counts the host
- Returns 503 when Atlas is not configured
- Requires authentication (returns 401 without session)
**Frontend — Tab System**:
- Renders two tabs with correct labels
- Defaults to "Ivanti Findings" tab on mount
- Switches content when tab is clicked
- Active tab has correct ARIA attributes (`aria-selected="true"`)
- Tab panel has `role="tabpanel"` attribute
- Keyboard navigation works (Tab + Enter)
**Frontend — Atlas Donut Charts**:
- Coverage donut shows "No data — run Atlas Sync" when totalHosts is 0
- Plan type donut shows "No plans — run Atlas Sync" when totalPlans is 0
- Plan status donut shows "No plans — run Atlas Sync" when totalPlans is 0
- SVG dimensions are 180px with 72px outer and 48px inner radius
- Color assignments match specification for each plan type
- Color assignments match specification for known statuses
**Frontend — Data Fetching**:
- Fetches metrics on mount
- Re-fetches metrics after successful Atlas sync
- Does not re-fetch on tab switch
- Shows loading indicator while fetch is in progress
- Shows error message when fetch fails
**Frontend — IvantiCountsChart Visibility**:
- Rendered when Ivanti tab is active
- Not rendered when Atlas tab is active
### Property-Based Tests
Property-based tests verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
**Library**: [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js.
**Configuration**: Each property test runs with `{ numRuns: 100 }` minimum.
**Tag format**: Each test includes a comment referencing its design property:
```javascript
// Feature: atlas-metrics-report, Property 1: Metrics aggregation correctness
```
| Property | Test Description | Generator Strategy |
|----------|-----------------|-------------------|
| Property 1 | Metrics aggregation correctness | Generate arrays of objects with `has_action_plan` (0 or 1) and `plans_json` (either valid JSON array of `{plan_type, status}` objects or random invalid strings). Extract the aggregation logic into a pure function, call it with generated input, verify all invariants. |
| Property 2 | Coverage donut data correctness | Generate random `(hostsWithPlans, hostsWithoutPlans)` pairs of non-negative integers (at least one > 0). Render `AtlasCoverageDonut`, verify center text equals sum and legend percentages are mathematically correct. |
| Property 3 | Plan type donut data correctness | Generate random `plansByType` objects with 15 plan type keys mapped to positive integers. Render `AtlasPlanTypeDonut`, verify center text equals sum and legend entries match input. |
| Property 4 | Plan status donut data correctness | Generate random `plansByStatus` objects with 14 status keys mapped to positive integers. Render `AtlasPlanStatusDonut`, verify center text equals sum and legend entries match input. |
| Property 5 | Plan status color assignment | Generate random strings (mix of known statuses and arbitrary strings). Verify the color function returns the correct color for known statuses and the fallback for unknowns. |
### Integration Tests
- Full metrics flow: seed `atlas_action_plans_cache` with varied data → call `GET /api/atlas/metrics` → verify response matches expected aggregation
- Empty cache flow: ensure empty cache returns all-zero metrics
- Corrupt data flow: seed cache with mix of valid and invalid `plans_json` → verify metrics are correct for valid rows