# 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
Ivanti Findings | Atlas Coverage]
ID[Ivanti Donuts
StatusDonut, ActionCoverageDonut,
FPWorkflowDonut x2]
AD[Atlas Donuts
CoverageDonut, PlanTypeDonut,
PlanStatusDonut]
IC[IvantiCountsChart]
end
subgraph Backend - Atlas Router
ME[GET /api/atlas/metrics]
AC[(atlas_action_plans_cache
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 1–5 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 1–4 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