363 lines
18 KiB
Markdown
363 lines
18 KiB
Markdown
|
|
# 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 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
|