Compare commits
9 Commits
feature/mu
...
feature/at
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c04c9870a | ||
|
|
e1b000870c | ||
|
|
f3ba322403 | ||
|
|
0bea387ac9 | ||
|
|
aa3ce3bae9 | ||
|
|
0cdaecf890 | ||
|
|
043c85cc69 | ||
|
|
6082721452 | ||
|
|
a214393723 |
BIN
.compliance-staging/.gitkeep
Normal file
BIN
.compliance-staging/.gitkeep
Normal file
Binary file not shown.
7
.gitignore
vendored
7
.gitignore
vendored
@@ -52,5 +52,12 @@ backend/fix_multivendor_constraint.js
|
||||
backend/server.js-backup
|
||||
backend/setup.js-backup
|
||||
|
||||
# Compliance staging — keep folder, ignore contents
|
||||
.compliance-staging/*
|
||||
!.compliance-staging/.gitkeep
|
||||
|
||||
# Kiro agents (local only)
|
||||
.kiro/agents/
|
||||
|
||||
# Kiro implementation summary (internal only)
|
||||
docs/kiro-implementation-summary.md
|
||||
|
||||
13
.kiro/hooks/compliance-schema-watcher.kiro.hook
Normal file
13
.kiro/hooks/compliance-schema-watcher.kiro.hook
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Compliance Schema Watcher",
|
||||
"description": "Manually triggered before uploading a compliance xlsx to the dashboard. Diffs the xlsx structure against the parser's hand-maintained dicts (METRIC_CATEGORIES, CORE_COLS, SKIP_SHEETS) and flags anything that would cause silent data loss or misclassification. Prompts for the xlsx path and report mode.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "You are the Compliance Schema Watcher agent. Follow the instructions in `.kiro/agents/compliance-schema-watcher.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Path to the xlsx file:** Absolute path, or a filename to look for in `.compliance-staging/` then `~/Downloads/`. Example: `.compliance-staging/NTS_AEO_2026_04_15.xlsx`\n2. **Mode:** \"report only\" (surface drift findings in chat, no file edits) or \"report + propose edits\" (surface drift and draft specific dict changes for `backend/scripts/parse_compliance_xlsx.py`)\n\nOnce you have both inputs, follow the full schema drift workflow described in `.kiro/agents/compliance-schema-watcher.md`: resolve the file path, read the parser dicts, run the helper script to extract xlsx structure, diff against the parser's expectations, and output a categorised report with a pre-upload verdict."
|
||||
}
|
||||
}
|
||||
13
.kiro/hooks/doc-review-trigger.kiro.hook
Normal file
13
.kiro/hooks/doc-review-trigger.kiro.hook
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Doc Review",
|
||||
"description": "Manually triggered after merging to master. Reads the recent git diff, classifies the changes, and proposes documentation updates following the doc-updater decision tree and doc-standards.md conventions.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "Run a documentation review against the latest changes on master. Follow these steps exactly:\n\n1. Run `git log --oneline -10` to see recent commits. If any commit message contains `[skip-docs]`, stop and report NO_DOC_UPDATE_NEEDED.\n\n2. Run `git diff HEAD~1 --stat` to get the list of changed files, then `git diff HEAD~1` to get the full diff. If the diff is larger than 500 lines, report NEEDS_HUMAN_REVIEW with a summary of which areas likely need docs.\n\n3. Read `.kiro/agents/doc-updater.md` for the full decision tree and `.kiro/steering/doc-standards.md` for formatting conventions.\n\n4. Follow the doc-updater decision tree: triage the change, decide if docs need updating, survey existing docs (README.md, docs/ folder), and propose surgical edits.\n\n5. For any proposed changes, apply them directly to the doc files. Only touch README.md and files under docs/. Never touch code files.\n\n6. After applying changes, output the SUMMARY block from the decision tree so Jordan can review what was changed and why."
|
||||
}
|
||||
}
|
||||
13
.kiro/hooks/ivanti-api-debugger.kiro.hook
Normal file
13
.kiro/hooks/ivanti-api-debugger.kiro.hook
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Ivanti API Debugger",
|
||||
"description": "Manually triggered when debugging a failing Ivanti API call. Prompts for the endpoint, request payload, and error response, then invokes the ivanti-api-debugger agent to diagnose the issue and update ivanti-api-reference.md with any findings.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "You are the Ivanti API Debugger agent. Follow the instructions in `.kiro/agents/ivanti-api-debugger.md` exactly.\n\nAsk the user to provide the following (one clarifying question, not five — accept whatever they paste and infer the rest):\n\n1. The failing endpoint or route — either the Ivanti API path (e.g. `/workflowBatch/falsePositive/request`) or the backend route file/handler that makes the call (e.g. `backend/routes/ivantiWorkflows.js`)\n2. The request payload they sent (curl, JSON body, or code snippet)\n3. The response or error they got back (HTTP status, response body, or error message)\n\nOnce you have that context, follow the full diagnostic workflow described in `.kiro/agents/ivanti-api-debugger.md`: read the relevant route/service code, cross-reference `docs/ivanti-api-reference.md`, check for common Ivanti failure modes, form a hypothesis, and propose a concrete next request to try. If the user confirms a finding, update `docs/ivanti-api-reference.md` using its existing structure."
|
||||
}
|
||||
}
|
||||
13
.kiro/hooks/security-audit-tracker.kiro.hook
Normal file
13
.kiro/hooks/security-audit-tracker.kiro.hook
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Security Audit Tracker",
|
||||
"description": "Manually triggered to scan the codebase for security issues and maintain a living audit tracker document. Prompts for scan scope (full repo or specific path) and mode (report only or report + update tracker). Invokes the security-audit-tracker agent for static analysis and doc tracking.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "You are the Security Audit Tracker agent. Follow the instructions in `.kiro/agents/security-audit-tracker.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Scope:** \"full repo\" to scan the entire codebase, or a specific path/module to focus on (e.g. `backend/routes/`, `frontend/src/components/`, `backend/helpers/ivantiApi.js`)\n2. **Mode:** \"scan only\" (report findings to chat, no file writes) or \"scan + update tracker\" (report findings and merge them into the tracker doc at `docs/security-audit-tracker.md`)\n\nOnce you have both inputs, follow the full diagnostic and tracking workflow described in `.kiro/agents/security-audit-tracker.md`: determine scope, check for the tracker doc (create it if missing), scan for the security failure modes listed in the agent spec, cross-reference against previously tracked findings, and output a prioritised report. In \"scan + update tracker\" mode, also merge findings into the tracker doc and update its metadata."
|
||||
}
|
||||
}
|
||||
1
.kiro/specs/admin-page-overhaul/.config.kiro
Normal file
1
.kiro/specs/admin-page-overhaul/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "30e46443-e636-4df1-bb98-886f403b2e32", "workflowType": "requirements-first", "specType": "feature"}
|
||||
423
.kiro/specs/admin-page-overhaul/design.md
Normal file
423
.kiro/specs/admin-page-overhaul/design.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Design Document: Admin Page Overhaul
|
||||
|
||||
## Overview
|
||||
|
||||
The Admin Page Overhaul replaces the current inline `UserManagement` modal rendering on the admin page with a full-page, themed admin panel. The new `AdminPage` component follows the same layout conventions as `CompliancePage`, `ExportsPage`, and `KnowledgeBasePage` — a top-level page component rendered in the main content area of `App.js` when `currentPage === 'admin'`.
|
||||
|
||||
The page consolidates three admin functions into a single tabbed interface:
|
||||
|
||||
1. **User Management** — themed table with inline add/edit forms, group badges, and active status toggles
|
||||
2. **Audit Log** — paginated, filterable log table with action-type badges and date range filters
|
||||
3. **System Info** — stat cards showing user counts, audit log totals, and recent activity
|
||||
|
||||
All sections use the dark tactical intelligence theme defined in `DESIGN_SYSTEM.md` and `App.css` — `intel-card` containers, `intel-button` controls, `intel-input` form fields, `status-badge` action labels, and `stat-card` stat displays.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **New component, not a wrapper.** The existing `UserManagement.js` and `AuditLog.js` are white-background modals with Tailwind utility classes. Wrapping them would create visual inconsistency. The `AdminPage` component builds themed versions of both panels from scratch, reusing the same backend API endpoints.
|
||||
- **Existing modals preserved.** The `UserMenu` quick-access links ("Manage Users", "Audit Log") continue to open the existing modal components. This keeps the quick-access workflow intact while the admin page provides the full-featured experience.
|
||||
- **No new backend endpoints.** All data comes from existing routes: `GET /api/users`, `POST/PATCH/DELETE /api/users/:id`, `GET /api/audit-logs`, `GET /api/audit-logs/actions`.
|
||||
- **Inline styles + App.css classes.** Follows the project convention of defining style constants in the component file and referencing `App.css` classes (`intel-card`, `intel-button`, `data-row`, etc.) where available.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[App.js] -->|currentPage === 'admin' && isAdmin| B[AdminPage]
|
||||
B --> C[Tab Navigation]
|
||||
C -->|"User Management"| D[UserManagementPanel]
|
||||
C -->|"Audit Log"| E[AuditLogPanel]
|
||||
C -->|"System Info"| F[SystemInfoPanel]
|
||||
|
||||
D -->|GET /api/users| G[Backend: users.js]
|
||||
D -->|POST /api/users| G
|
||||
D -->|PATCH /api/users/:id| G
|
||||
D -->|DELETE /api/users/:id| G
|
||||
|
||||
E -->|GET /api/audit-logs| H[Backend: auditLog.js]
|
||||
E -->|GET /api/audit-logs/actions| H
|
||||
|
||||
F -->|GET /api/users| G
|
||||
F -->|GET /api/audit-logs?limit=10| H
|
||||
```
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
AdminPage
|
||||
├── PageHeader (title + accent glow)
|
||||
├── TabNavigation (User Management | Audit Log | System Info)
|
||||
├── UserManagementPanel
|
||||
│ ├── AddUserButton / InlineForm
|
||||
│ ├── UserTable
|
||||
│ │ └── UserRow (group badge, status toggle, edit/delete actions)
|
||||
│ ├── ErrorBanner
|
||||
│ └── SuccessToast
|
||||
├── AuditLogPanel
|
||||
│ ├── FilterBar (username, action, entity type, start date, end date)
|
||||
│ ├── LogTable
|
||||
│ │ └── LogRow (timestamp, user, action badge, entity, details, IP)
|
||||
│ ├── Pagination
|
||||
│ ├── EmptyState
|
||||
│ └── ErrorBanner
|
||||
└── SystemInfoPanel
|
||||
├── StatCards (total users, active users, audit entries, recent logins)
|
||||
└── RecentActivityList (10 most recent audit entries)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. `App.js` renders `<AdminPage />` when `currentPage === 'admin'` and `isAdmin()` returns true. Non-admin users are redirected to home.
|
||||
2. `AdminPage` manages the active tab in local state (default: `'users'`).
|
||||
3. Each panel fetches its own data on mount using `fetch()` with `credentials: 'include'`.
|
||||
4. Mutations (create, update, delete user) trigger a re-fetch of the user list. Success/error feedback is shown inline.
|
||||
5. Audit log panel manages its own pagination and filter state, re-fetching on filter apply or page change.
|
||||
6. System info panel fetches user list and recent audit logs on mount, computing derived stats client-side.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### AdminPage (main component)
|
||||
|
||||
```javascript
|
||||
// frontend/src/components/pages/AdminPage.js
|
||||
export default function AdminPage() {
|
||||
// Props: none (reads auth context internally)
|
||||
// State:
|
||||
// activeTab: 'users' | 'audit' | 'system'
|
||||
// Renders: PageHeader, TabNavigation, conditional panel
|
||||
}
|
||||
```
|
||||
|
||||
### TabNavigation
|
||||
|
||||
```javascript
|
||||
// Internal to AdminPage
|
||||
// Props:
|
||||
// activeTab: string
|
||||
// onTabChange: (tab: string) => void
|
||||
// Tabs: [
|
||||
// { id: 'users', label: 'User Management', icon: Shield },
|
||||
// { id: 'audit', label: 'Audit Log', icon: Clock },
|
||||
// { id: 'system', label: 'System Info', icon: Activity },
|
||||
// ]
|
||||
```
|
||||
|
||||
Styling: monospace uppercase text, `--intel-accent` border and background on active tab, transparent with muted text on inactive tabs. Matches the tab pattern used in `CompliancePage` (team tabs).
|
||||
|
||||
### UserManagementPanel
|
||||
|
||||
```javascript
|
||||
// Internal to AdminPage
|
||||
// State:
|
||||
// users: Array<User>
|
||||
// loading: boolean
|
||||
// error: string | null
|
||||
// showForm: boolean
|
||||
// editingUser: User | null
|
||||
// formData: { username, email, password, group }
|
||||
// formError: string
|
||||
// successMessage: string
|
||||
//
|
||||
// API calls:
|
||||
// GET /api/users → fetch all users
|
||||
// POST /api/users → create user
|
||||
// PATCH /api/users/:id → update user (fields, group, is_active)
|
||||
// DELETE /api/users/:id → delete user
|
||||
//
|
||||
// Group badge colors (themed):
|
||||
// Admin: --intel-danger (#EF4444)
|
||||
// Standard_User: --intel-accent (#0EA5E9)
|
||||
// Leadership: --intel-warning (#F59E0B)
|
||||
// Read_Only: --text-muted (#94A3B8)
|
||||
```
|
||||
|
||||
### AuditLogPanel
|
||||
|
||||
```javascript
|
||||
// Internal to AdminPage
|
||||
// State:
|
||||
// logs: Array<AuditLogEntry>
|
||||
// loading: boolean
|
||||
// error: string | null
|
||||
// pagination: { page, limit, total, totalPages }
|
||||
// filters: { user, action, entityType, startDate, endDate }
|
||||
// actions: string[] (populated from /api/audit-logs/actions)
|
||||
//
|
||||
// API calls:
|
||||
// GET /api/audit-logs?page=&limit=25&user=&action=&entityType=&startDate=&endDate=
|
||||
// GET /api/audit-logs/actions
|
||||
//
|
||||
// Action badge colors (themed):
|
||||
// login/logout: --intel-success (#10B981)
|
||||
// *_create: --intel-accent (#0EA5E9)
|
||||
// *_update/*_edit: --intel-warning (#F59E0B)
|
||||
// *_delete: --intel-danger (#EF4444)
|
||||
// default: --text-muted (#94A3B8)
|
||||
```
|
||||
|
||||
### SystemInfoPanel
|
||||
|
||||
```javascript
|
||||
// Internal to AdminPage
|
||||
// State:
|
||||
// users: Array<User>
|
||||
// recentLogs: Array<AuditLogEntry>
|
||||
// loading: boolean
|
||||
// errors: { users: string | null, logs: string | null }
|
||||
//
|
||||
// Derived stats:
|
||||
// totalUsers: users.length
|
||||
// activeUsers: users.filter(u => u.is_active).length
|
||||
// recentLogins: users.filter(u => u.last_login && withinLast7Days(u.last_login)).length
|
||||
// totalAuditEntries: fetched from audit-logs pagination.total
|
||||
//
|
||||
// API calls:
|
||||
// GET /api/users
|
||||
// GET /api/audit-logs?limit=10&page=1
|
||||
```
|
||||
|
||||
### Integration with App.js
|
||||
|
||||
```javascript
|
||||
// In App.js, replace:
|
||||
// {currentPage === 'admin' && isAdmin() && (
|
||||
// <div className="space-y-6">
|
||||
// <UserManagement onClose={() => setCurrentPage('home')} />
|
||||
// </div>
|
||||
// )}
|
||||
//
|
||||
// With:
|
||||
// {currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||
// {currentPage === 'admin' && !isAdmin() && /* redirect to home */}
|
||||
//
|
||||
// Keep existing modal triggers:
|
||||
// {showUserManagement && <UserManagement onClose={...} />}
|
||||
// {showAuditLog && <AuditLog onClose={...} />}
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### User (from GET /api/users)
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
group: 'Admin' | 'Standard_User' | 'Leadership' | 'Read_Only',
|
||||
is_active: 0 | 1,
|
||||
created_at: string, // ISO datetime
|
||||
last_login: string | null // ISO datetime
|
||||
}
|
||||
```
|
||||
|
||||
### AuditLogEntry (from GET /api/audit-logs)
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: number,
|
||||
user_id: number,
|
||||
username: string,
|
||||
action: string, // e.g. 'login', 'user_create', 'cve_delete'
|
||||
entity_type: string, // e.g. 'auth', 'user', 'cve', 'document'
|
||||
entity_id: string | null,
|
||||
details: string | null, // JSON string
|
||||
ip_address: string | null,
|
||||
created_at: string // ISO datetime
|
||||
}
|
||||
```
|
||||
|
||||
### AuditLogPagination (from GET /api/audit-logs response)
|
||||
|
||||
```javascript
|
||||
{
|
||||
logs: AuditLogEntry[],
|
||||
pagination: {
|
||||
page: number,
|
||||
limit: number,
|
||||
total: number,
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Configuration
|
||||
|
||||
```javascript
|
||||
const TABS = [
|
||||
{ id: 'users', label: 'User Management', icon: Shield },
|
||||
{ id: 'audit', label: 'Audit Log', icon: Clock },
|
||||
{ id: 'system', label: 'System Info', icon: Activity },
|
||||
];
|
||||
```
|
||||
|
||||
### Group Badge Theme Map
|
||||
|
||||
```javascript
|
||||
const GROUP_BADGE_THEMED = {
|
||||
Admin: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' },
|
||||
Standard_User: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' },
|
||||
Leadership: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' },
|
||||
Read_Only: { bg: 'rgba(148,163,184,0.15)', border: '#94A3B8', text: '#CBD5E1' },
|
||||
};
|
||||
```
|
||||
|
||||
### Action Badge Theme Map
|
||||
|
||||
```javascript
|
||||
const ACTION_BADGE_THEMED = {
|
||||
login: { bg: 'rgba(16,185,129,0.15)', border: '#10B981', text: '#6EE7B7' },
|
||||
logout: { bg: 'rgba(148,163,184,0.15)', border: '#94A3B8', text: '#CBD5E1' },
|
||||
login_failed: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' },
|
||||
user_create: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' },
|
||||
user_update: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' },
|
||||
user_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' },
|
||||
cve_create: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' },
|
||||
cve_edit: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' },
|
||||
cve_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' },
|
||||
document_upload: { bg: 'rgba(139,92,246,0.15)', border: '#8B5CF6', text: '#C4B5FD' },
|
||||
document_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' },
|
||||
};
|
||||
```
|
||||
|
||||
## 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: Group badge color mapping is total and correct
|
||||
|
||||
*For any* valid user group string (`Admin`, `Standard_User`, `Leadership`, `Read_Only`), the group badge styling function SHALL return a non-null object with `bg`, `border`, and `text` fields matching the themed color for that group. *For any* string that is not one of the four valid groups, the function SHALL return the default muted styling.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 2: Edit form population preserves user data
|
||||
|
||||
*For any* user object with arbitrary `username`, `email`, and `group` values, populating the edit form from that user SHALL result in `formData.username === user.username`, `formData.email === user.email`, and `formData.group === user.group`, with `formData.password` set to an empty string.
|
||||
|
||||
**Validates: Requirements 3.5**
|
||||
|
||||
### Property 3: Self-modification prevention
|
||||
|
||||
*For any* user list that contains the currently authenticated admin user, the admin user's own row SHALL have the group dropdown disabled and the active status toggle disabled. *For any* other user in the list, those controls SHALL be enabled.
|
||||
|
||||
**Validates: Requirements 3.8**
|
||||
|
||||
### Property 4: Action badge color mapping is total and correct
|
||||
|
||||
*For any* known audit log action string (from the set of defined actions: `login`, `logout`, `login_failed`, `user_create`, `user_update`, `user_delete`, `cve_create`, `cve_edit`, `cve_delete`, `document_upload`, `document_delete`), the action badge styling function SHALL return the correct themed color object. *For any* unknown action string, the function SHALL return the default muted styling.
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 5: Applying filters resets pagination to page 1
|
||||
|
||||
*For any* combination of filter values (username text, action type, entity type, start date, end date) and *for any* current page number, applying the filters SHALL result in a fetch call with `page=1`.
|
||||
|
||||
**Validates: Requirements 4.7**
|
||||
|
||||
### Property 6: Recent login count computation
|
||||
|
||||
*For any* list of user objects with random `last_login` timestamps (including null values), the computed "recent logins" count SHALL equal the number of users whose `last_login` is non-null and falls within the last 7 days from the current time.
|
||||
|
||||
**Validates: Requirements 5.1**
|
||||
|
||||
### Property 7: Admin-only access control
|
||||
|
||||
*For any* user object, the admin page content SHALL be rendered if and only if `user.group === 'Admin'`. When `user.group` is any value other than `'Admin'`, the system SHALL redirect to the home page.
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### User Management Panel
|
||||
|
||||
| Error Scenario | Handling |
|
||||
|---|---|
|
||||
| `GET /api/users` fails | Display error banner with `--intel-danger` styling. User table is hidden. Retry on next tab switch or manual refresh. |
|
||||
| `POST /api/users` fails (validation) | Display `formError` message below the form in danger color. Form remains open for correction. |
|
||||
| `POST /api/users` fails (409 conflict) | Display "Username or email already exists" in `formError`. |
|
||||
| `PATCH /api/users/:id` fails | Display inline error. Revert optimistic UI changes if any. |
|
||||
| `DELETE /api/users/:id` fails | Display alert with error message. User list unchanged. |
|
||||
| Self-demotion attempt | Group dropdown disabled for current user. Backend returns 400 if bypassed. |
|
||||
| Self-deactivation attempt | Toggle disabled for current user. Backend returns 400 if bypassed. |
|
||||
|
||||
### Audit Log Panel
|
||||
|
||||
| Error Scenario | Handling |
|
||||
|---|---|
|
||||
| `GET /api/audit-logs` fails | Display error banner with `--intel-danger` styling. Table hidden. |
|
||||
| `GET /api/audit-logs/actions` fails | Action filter dropdown shows no options. Non-critical — silently ignored. |
|
||||
| Invalid date range (start > end) | Client-side: no validation needed — backend handles gracefully by returning empty results. |
|
||||
| Empty result set | Display "No audit log entries found" message in `--text-muted` color. |
|
||||
|
||||
### System Info Panel
|
||||
|
||||
| Error Scenario | Handling |
|
||||
|---|---|
|
||||
| `GET /api/users` fails | Affected stat cards (total users, active users, recent logins) show "Unable to load" fallback text. |
|
||||
| `GET /api/audit-logs` fails | Audit entries stat card and recent activity list show "Unable to load" fallback. |
|
||||
| Partial failure (one endpoint fails) | Only the affected cards show fallback. Successfully loaded cards display normally. |
|
||||
|
||||
### Success Feedback
|
||||
|
||||
- Create user: green success toast "User created successfully" auto-dismisses after 2 seconds.
|
||||
- Update user: green success toast "User updated successfully" auto-dismisses after 2 seconds.
|
||||
- Delete user: green success toast "User deleted" auto-dismisses after 2 seconds.
|
||||
- Toggle active status: immediate UI update, no toast (inline visual feedback is sufficient).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
Unit tests cover specific rendering, interaction, and integration scenarios:
|
||||
|
||||
**AdminPage structure:**
|
||||
- Renders page header with "Admin Panel" title
|
||||
- Defaults to User Management tab on mount
|
||||
- Switches panels when tabs are clicked
|
||||
- Only renders when user is admin (access control)
|
||||
|
||||
**UserManagementPanel:**
|
||||
- Renders user table with all required columns
|
||||
- Displays themed group badges for each group type
|
||||
- Shows inline form when "Add User" is clicked
|
||||
- Populates form with user data when edit is clicked
|
||||
- Shows confirmation dialog on delete
|
||||
- Disables self-modification controls for current user
|
||||
- Displays error banner on API failure
|
||||
- Displays success toast on successful operations
|
||||
|
||||
**AuditLogPanel:**
|
||||
- Renders log table with all required columns
|
||||
- Displays themed action badges
|
||||
- Renders filter controls (username, action, entity type, dates)
|
||||
- Fetches page 1 when filters are applied
|
||||
- Navigates pages with pagination controls
|
||||
- Shows empty state when no results
|
||||
- Shows error banner on API failure
|
||||
|
||||
**SystemInfoPanel:**
|
||||
- Renders four stat cards with correct labels
|
||||
- Computes derived stats correctly from mock data
|
||||
- Shows recent activity list with up to 10 entries
|
||||
- Shows fallback message when an API call fails
|
||||
|
||||
### 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 group strings (valid + invalid), verify badge function returns correct colors | Feature: admin-page-overhaul, Property 1: Group badge color mapping is total and correct |
|
||||
| Property 2 | Generate random user objects, verify edit form population matches user fields exactly | Feature: admin-page-overhaul, Property 2: Edit form population preserves user data |
|
||||
| Property 3 | Generate random user lists containing the current admin, verify self-edit controls are disabled | Feature: admin-page-overhaul, Property 3: Self-modification prevention |
|
||||
| Property 4 | Generate random action strings (known + unknown), verify badge function returns correct colors | Feature: admin-page-overhaul, Property 4: Action badge color mapping is total and correct |
|
||||
| Property 5 | Generate random filter states and current page numbers, verify fetch is called with page=1 | Feature: admin-page-overhaul, Property 5: Applying filters resets pagination to page 1 |
|
||||
| Property 6 | Generate random user lists with random last_login timestamps, verify recent login count matches manual computation | Feature: admin-page-overhaul, Property 6: Recent login count computation |
|
||||
| Property 7 | Generate random user objects with random groups, verify admin page renders iff group === 'Admin' | Feature: admin-page-overhaul, Property 7: Admin-only access control |
|
||||
|
||||
### 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
|
||||
108
.kiro/specs/admin-page-overhaul/requirements.md
Normal file
108
.kiro/specs/admin-page-overhaul/requirements.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The STEAM Security Dashboard currently has an Admin page (`currentPage === 'admin'`) that renders the `UserManagement` modal component inline — the same modal triggered from the top-right `UserMenu`. The page does not follow the dashboard's dark "tactical intelligence" theme and provides no audit log viewing or other administrative capabilities. This feature overhauls the admin page into a dedicated, full-page admin panel that matches the design system and consolidates user management, audit log viewing, and system administration into a single cohesive interface accessible only to Admin-group users.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Admin_Page**: The full-page admin panel rendered when `currentPage === 'admin'`, replacing the current inline modal rendering
|
||||
- **Dashboard**: The STEAM Security Dashboard application
|
||||
- **Design_System**: The color palette, typography, component specs, and interaction patterns defined in `DESIGN_SYSTEM.md` and `App.css`
|
||||
- **Audit_Log_Panel**: The section of the Admin_Page that displays paginated, filterable audit log entries
|
||||
- **User_Management_Panel**: The section of the Admin_Page that displays the user list and provides create, edit, delete, and activate/deactivate operations
|
||||
- **Admin_User**: A user whose `user_group` is `Admin`
|
||||
- **Tab_Navigation**: The in-page navigation component that switches between Admin_Page sections (User Management, Audit Log, System Info)
|
||||
- **System_Info_Panel**: The section of the Admin_Page that displays system metadata such as active user count, recent login activity, and database statistics
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Admin Page Layout and Theme Compliance
|
||||
|
||||
**User Story:** As an admin, I want the admin page to follow the same dark tactical intelligence theme as the rest of the dashboard, so that the experience is visually consistent.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Admin_Page SHALL use the Design_System color palette — `--intel-darkest` for the page background, `--intel-dark` and `--intel-medium` for card backgrounds, and `--intel-accent` for interactive elements
|
||||
2. THE Admin_Page SHALL render as a full-page view within the main content area, matching the layout pattern used by other page components (CompliancePage, ExportsPage, KnowledgeBasePage)
|
||||
3. THE Admin_Page SHALL display a page header with the title "Admin Panel" styled in monospace uppercase with the accent text glow defined in the Design_System
|
||||
4. THE Admin_Page SHALL use `intel-card` styled containers for each content section, with the standard gradient backgrounds, border glow, and shadow depth defined in the Design_System
|
||||
5. THE Admin_Page SHALL use `intel-button` styled controls for all interactive buttons, with primary, danger, and success variants as appropriate
|
||||
6. THE Admin_Page SHALL use `intel-input` styled form fields for all text inputs, selects, and date pickers
|
||||
|
||||
### Requirement 2: Tab-Based Section Navigation
|
||||
|
||||
**User Story:** As an admin, I want to navigate between admin sections using tabs, so that I can quickly switch between user management, audit logs, and system information without leaving the page.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Admin_Page SHALL display a Tab_Navigation component with tabs for "User Management", "Audit Log", and "System Info"
|
||||
2. WHEN an Admin_User clicks a tab, THE Tab_Navigation SHALL switch the visible content section to the selected tab and visually indicate the active tab using the `--intel-accent` color
|
||||
3. THE Admin_Page SHALL default to the "User Management" tab when first loaded
|
||||
4. THE Tab_Navigation SHALL use monospace uppercase text with letter spacing consistent with the Design_System label typography
|
||||
|
||||
### Requirement 3: Themed User Management Panel
|
||||
|
||||
**User Story:** As an admin, I want to manage users directly within the themed admin page instead of a white modal overlay, so that user management feels integrated into the dashboard.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE User_Management_Panel SHALL display a table of all users with columns for username, email, group, active status, and last login
|
||||
2. THE User_Management_Panel SHALL style the user table with dark theme rows using `data-row` hover effects and `--text-primary` / `--text-secondary` text colors from the Design_System
|
||||
3. THE User_Management_Panel SHALL display group badges using severity-style badge coloring — Admin in danger color, Standard_User in accent color, Leadership in warning color, Read_Only in muted color
|
||||
4. WHEN an Admin_User clicks "Add User", THE User_Management_Panel SHALL display an inline form styled with `intel-input` fields and `intel-button` controls
|
||||
5. WHEN an Admin_User clicks the edit action on a user row, THE User_Management_Panel SHALL populate the inline form with that user's current data for editing
|
||||
6. WHEN an Admin_User clicks the delete action on a user row, THE User_Management_Panel SHALL display a confirmation prompt before sending the delete request
|
||||
7. WHEN an Admin_User toggles a user's active status, THE User_Management_Panel SHALL send a PATCH request and update the displayed status without a full page reload
|
||||
8. THE User_Management_Panel SHALL prevent an Admin_User from changing their own group or deactivating their own account
|
||||
9. IF a user management API request fails, THEN THE User_Management_Panel SHALL display an error message styled with the `--intel-danger` color
|
||||
|
||||
### Requirement 4: Themed Audit Log Panel
|
||||
|
||||
**User Story:** As an admin, I want to view audit logs in a themed, filterable table within the admin page, so that I can monitor system activity without opening a separate modal.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Audit_Log_Panel SHALL fetch and display paginated audit log entries from the `/api/audit-logs` endpoint
|
||||
2. THE Audit_Log_Panel SHALL display columns for timestamp, username, action, entity type, entity ID, details, and IP address
|
||||
3. THE Audit_Log_Panel SHALL style the log table with dark theme rows, monospace font for timestamps and IP addresses, and `data-row` hover effects
|
||||
4. THE Audit_Log_Panel SHALL display action type badges using color-coded `status-badge` styling — login actions in success color, delete actions in danger color, create actions in accent color, update actions in warning color
|
||||
5. THE Audit_Log_Panel SHALL provide filter controls for username (text search), action type (dropdown populated from `/api/audit-logs/actions`), entity type (dropdown), start date, and end date
|
||||
6. THE Audit_Log_Panel SHALL style all filter controls using `intel-input` and `intel-button` components from the Design_System
|
||||
7. WHEN an Admin_User applies filters, THE Audit_Log_Panel SHALL re-fetch audit logs from page 1 with the selected filter parameters
|
||||
8. WHEN an Admin_User clicks a pagination control, THE Audit_Log_Panel SHALL fetch the requested page and display a page indicator showing current page, total pages, and total entry count
|
||||
9. THE Audit_Log_Panel SHALL display a "No audit log entries found" message styled with `--text-muted` color when the query returns zero results
|
||||
10. IF the audit log API request fails, THEN THE Audit_Log_Panel SHALL display an error message styled with the `--intel-danger` color
|
||||
|
||||
### Requirement 5: System Info Panel
|
||||
|
||||
**User Story:** As an admin, I want to see a summary of system health and usage statistics, so that I can quickly assess the state of the dashboard.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE System_Info_Panel SHALL display stat cards showing: total user count, active user count, total audit log entries, and count of users who logged in within the last 7 days
|
||||
2. THE System_Info_Panel SHALL style each stat card using the `stat-card` pattern from the Design_System with the accent-colored top bar and hover lift effect
|
||||
3. THE System_Info_Panel SHALL display a "Recent Activity" section showing the 10 most recent audit log entries in a compact list format
|
||||
4. WHEN the System_Info_Panel loads, THE System_Info_Panel SHALL fetch statistics from the existing `/api/users` and `/api/audit-logs` endpoints
|
||||
5. IF any statistics API request fails, THEN THE System_Info_Panel SHALL display a fallback "Unable to load" message in the affected stat card
|
||||
|
||||
### Requirement 6: Access Control
|
||||
|
||||
**User Story:** As a non-admin user, I want to be prevented from accessing the admin page, so that sensitive administrative functions are protected.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL render the Admin_Page content only when the authenticated user belongs to the Admin group
|
||||
2. WHEN a non-admin user navigates to the admin page, THE Dashboard SHALL redirect the user to the home page
|
||||
3. THE NavDrawer SHALL continue to display the "Admin Panel" navigation item only for Admin-group users
|
||||
4. THE UserMenu SHALL continue to provide "Manage Users" and "Audit Log" quick-access links for Admin-group users, opening the respective modals as before
|
||||
|
||||
### Requirement 7: Loading and Error States
|
||||
|
||||
**User Story:** As an admin, I want to see clear loading indicators and error messages, so that I know when data is being fetched and when something goes wrong.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE data is being fetched for any Admin_Page section, THE Admin_Page SHALL display a loading spinner styled with the `loading-spinner` class and `--intel-accent` color
|
||||
2. IF an API request returns an error, THEN THE Admin_Page SHALL display the error message in a container styled with `--intel-danger` border and text color
|
||||
3. WHEN an Admin_User performs a successful create, update, or delete operation, THE Admin_Page SHALL display a brief success notification styled with `--intel-success` color
|
||||
160
.kiro/specs/admin-page-overhaul/tasks.md
Normal file
160
.kiro/specs/admin-page-overhaul/tasks.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Implementation Plan: Admin Page Overhaul
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current inline `UserManagement` modal rendering on the admin page with a full-page, themed `AdminPage` component. The new component lives at `frontend/src/components/pages/AdminPage.js` and provides three tabbed panels — User Management, Audit Log, and System Info — all styled with the dark tactical intelligence theme. No new backend endpoints are needed; the component reuses existing `/api/users` and `/api/audit-logs` routes. Existing modal components (`UserManagement`, `AuditLog`) are preserved for quick-access from `UserMenu`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Create AdminPage component with page header and tab navigation
|
||||
- [x] 1.1 Create `frontend/src/components/pages/AdminPage.js` with the page shell
|
||||
- Import React, useState, useAuth from AuthContext, and lucide-react icons (Shield, Clock, Activity)
|
||||
- Define `API_BASE` constant matching project convention
|
||||
- Define `TABS` array: `[{ id: 'users', label: 'User Management', icon: Shield }, { id: 'audit', label: 'Audit Log', icon: Clock }, { id: 'system', label: 'System Info', icon: Activity }]`
|
||||
- Render page header with "Admin Panel" title in monospace uppercase with `--intel-accent` text glow
|
||||
- Render tab navigation bar with monospace uppercase text, `--intel-accent` active styling, and muted inactive styling matching the CompliancePage team-tab pattern
|
||||
- Manage `activeTab` state defaulting to `'users'`
|
||||
- Conditionally render placeholder `<div>` for each panel based on `activeTab`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 1.2 Integrate AdminPage into App.js
|
||||
- Import `AdminPage` from `./components/pages/AdminPage`
|
||||
- Replace the existing `{currentPage === 'admin' && isAdmin() && (<div className="space-y-6"><UserManagement onClose={() => setCurrentPage('home')} /></div>)}` block with `{currentPage === 'admin' && isAdmin() && <AdminPage />}`
|
||||
- Add non-admin redirect: `{currentPage === 'admin' && !isAdmin() && setCurrentPage('home')}` (or useEffect equivalent)
|
||||
- Keep existing `{showUserManagement && <UserManagement onClose={...} />}` and `{showAuditLog && <AuditLog onClose={...} />}` modal triggers unchanged
|
||||
- _Requirements: 1.2, 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [-] 2. Implement UserManagementPanel
|
||||
- [x] 2.1 Build the themed user table and group badges
|
||||
- Define `GROUP_BADGE_THEMED` map with themed colors: Admin → danger, Standard_User → accent, Leadership → warning, Read_Only → muted
|
||||
- Fetch users from `GET /api/users` with `credentials: 'include'` on panel mount
|
||||
- Render user table with columns: username, email, group, active status, last login
|
||||
- Style table with dark theme rows using `data-row` hover effects and `--text-primary` / `--text-secondary` text colors
|
||||
- Render group badges using the themed color map with severity-style badge coloring
|
||||
- Display loading spinner (`loading-spinner` class, `--intel-accent` color) while fetching
|
||||
- Display error banner with `--intel-danger` styling on fetch failure
|
||||
- _Requirements: 3.1, 3.2, 3.3, 7.1, 7.2_
|
||||
|
||||
- [x] 2.2 Implement inline add/edit form and CRUD operations
|
||||
- Add "Add User" button styled with `intel-button` primary variant
|
||||
- Show inline form with `intel-input` styled fields for username, email, password, and group dropdown
|
||||
- On edit action: populate form with selected user's data (username, email, group; password blank)
|
||||
- On form submit: POST (create) or PATCH (update) to `/api/users` or `/api/users/:id`
|
||||
- On delete action: show confirmation prompt, then DELETE to `/api/users/:id`
|
||||
- On active status toggle: PATCH to `/api/users/:id` with `is_active` toggled, update UI without full reload
|
||||
- Prevent self-modification: disable group dropdown and active toggle for the current authenticated user's row
|
||||
- Display form validation errors with `--intel-danger` color
|
||||
- Display success toast with `--intel-success` color, auto-dismiss after 2 seconds
|
||||
- _Requirements: 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 7.3_
|
||||
|
||||
- [ ] 2.3 Write property test: Group badge color mapping is total and correct
|
||||
- **Property 1: Group badge color mapping is total and correct**
|
||||
- Install `fast-check` as a dev dependency in `frontend/`
|
||||
- Create test file `frontend/src/components/pages/__tests__/AdminPage.property.test.js`
|
||||
- Generate random strings including the four valid groups and arbitrary invalid strings
|
||||
- Verify the badge function returns correct themed colors for valid groups and default muted styling for invalid groups
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 3.3**
|
||||
|
||||
- [ ] 2.4 Write property test: Edit form population preserves user data
|
||||
- **Property 2: Edit form population preserves user data**
|
||||
- Generate random user objects with arbitrary username, email, and group values
|
||||
- Verify that populating the edit form results in `formData.username === user.username`, `formData.email === user.email`, `formData.group === user.group`, and `formData.password === ''`
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 3.5**
|
||||
|
||||
- [ ] 2.5 Write property test: Self-modification prevention
|
||||
- **Property 3: Self-modification prevention**
|
||||
- Generate random user lists that include a user matching the current admin's ID
|
||||
- Verify the admin's own row has group dropdown disabled and active toggle disabled
|
||||
- Verify all other users have those controls enabled
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 3.8**
|
||||
|
||||
- [x] 3. Checkpoint — Verify user management panel
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [-] 4. Implement AuditLogPanel
|
||||
- [x] 4.1 Build the themed audit log table with action badges and filters
|
||||
- Define `ACTION_BADGE_THEMED` map with themed colors: login/success → green, delete → danger, create → accent, update → warning, default → muted
|
||||
- Fetch audit logs from `GET /api/audit-logs?page=1&limit=25` with `credentials: 'include'` on panel mount
|
||||
- Fetch action types from `GET /api/audit-logs/actions` for the action filter dropdown
|
||||
- Render log table with columns: timestamp, username, action, entity type, entity ID, details, IP address
|
||||
- Style timestamps and IP addresses with monospace font
|
||||
- Render action type badges using the themed color map
|
||||
- Style table with dark theme rows and `data-row` hover effects
|
||||
- Display loading spinner while fetching, error banner on failure
|
||||
- Display "No audit log entries found" message with `--text-muted` color when results are empty
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.9, 4.10, 7.1, 7.2_
|
||||
|
||||
- [x] 4.2 Implement filter controls and pagination
|
||||
- Render filter bar with: username text input, action type dropdown, entity type dropdown, start date picker, end date picker
|
||||
- Style all filter controls with `intel-input` and `intel-button` components
|
||||
- On filter apply: re-fetch audit logs from page 1 with selected filter parameters
|
||||
- Render pagination controls showing current page, total pages, and total entry count
|
||||
- On page change: fetch the requested page
|
||||
- _Requirements: 4.5, 4.6, 4.7, 4.8_
|
||||
|
||||
- [ ] 4.3 Write property test: Action badge color mapping is total and correct
|
||||
- **Property 4: Action badge color mapping is total and correct**
|
||||
- Generate random action strings including all known actions and arbitrary unknown strings
|
||||
- Verify the badge function returns correct themed colors for known actions and default muted styling for unknown actions
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 4.4**
|
||||
|
||||
- [ ] 4.4 Write property test: Applying filters resets pagination to page 1
|
||||
- **Property 5: Applying filters resets pagination to page 1**
|
||||
- Generate random filter combinations (username text, action type, entity type, start date, end date) and random current page numbers
|
||||
- Verify that applying filters results in a fetch call with `page=1`
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 4.7**
|
||||
|
||||
- [-] 5. Implement SystemInfoPanel
|
||||
- [x] 5.1 Build stat cards and recent activity list
|
||||
- Fetch users from `GET /api/users` and recent audit logs from `GET /api/audit-logs?limit=10&page=1` on panel mount
|
||||
- Compute derived stats: total users (`users.length`), active users (`users.filter(u => u.is_active)`), recent logins (users with `last_login` within last 7 days), total audit entries (from pagination.total)
|
||||
- Render four stat cards using the `stat-card` pattern with accent-colored top bar and hover lift effect
|
||||
- Render "Recent Activity" section showing the 10 most recent audit log entries in a compact list format
|
||||
- Show "Unable to load" fallback in affected stat cards when individual API requests fail
|
||||
- Display loading spinner while fetching
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_
|
||||
|
||||
- [ ] 5.2 Write property test: Recent login count computation
|
||||
- **Property 6: Recent login count computation**
|
||||
- Generate random user lists with random `last_login` timestamps (including null values)
|
||||
- Verify the computed "recent logins" count equals the number of users whose `last_login` is non-null and falls within the last 7 days
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 5.1**
|
||||
|
||||
- [x] 6. Checkpoint — Verify all panels and integration
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [-] 7. Access control and final wiring
|
||||
- [x] 7.1 Verify access control integration
|
||||
- Confirm `AdminPage` reads auth context via `useAuth()` and only renders content for Admin-group users
|
||||
- Confirm `App.js` redirects non-admin users to home when `currentPage === 'admin'`
|
||||
- Confirm `NavDrawer` continues to show "Admin Panel" only for Admin-group users (no changes needed — verify existing behavior)
|
||||
- Confirm `UserMenu` quick-access links ("Manage Users", "Audit Log") continue to open existing modal components (no changes needed — verify existing behavior)
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [ ] 7.2 Write property test: Admin-only access control
|
||||
- **Property 7: Admin-only access control**
|
||||
- Generate random user objects with random group values
|
||||
- Verify admin page content renders if and only if `user.group === 'Admin'`
|
||||
- Verify non-Admin groups trigger redirect to home
|
||||
- Use `fc.assert(property, { numRuns: 100 })`
|
||||
- **Validates: Requirements 6.1, 6.2**
|
||||
|
||||
- [x] 8. 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
|
||||
- Existing `UserManagement.js` and `AuditLog.js` modal components are not modified — they remain for UserMenu quick-access
|
||||
- All styling follows the project convention of inline styles + App.css classes (no Tailwind in the new component)
|
||||
- The `fast-check` library must be installed as a dev dependency before running property tests
|
||||
1
.kiro/specs/atlas-action-plans/.config.kiro
Normal file
1
.kiro/specs/atlas-action-plans/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "aa138cae-9fbf-47bf-9dc3-1169456f5706", "workflowType": "requirements-first", "specType": "feature"}
|
||||
497
.kiro/specs/atlas-action-plans/design.md
Normal file
497
.kiro/specs/atlas-action-plans/design.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# Design Document: Atlas Action Plans Integration
|
||||
|
||||
## Overview
|
||||
|
||||
This feature integrates the Atlas InfoSec action plans API into the STEAM Security Dashboard, allowing users to view and manage compliance action plans for host findings directly from the ReportingPage. The integration follows the existing proxy-and-cache pattern used by the Ivanti integration — a backend helper handles HTTP communication with the external API, a SQLite cache stores host-level status for fast page loads, and Express routes expose both cached status and proxied CRUD operations to the React frontend.
|
||||
|
||||
The frontend adds two visual elements to the ReportingPage: a small badge in the Host column indicating action plan coverage, and a slide-out panel for viewing, creating, and updating plans. A manual sync button — matching the existing Ivanti sync button pattern — lets users refresh cached Atlas data on demand.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Proxy pattern over direct frontend calls**: The frontend never talks to Atlas directly. All Atlas API calls go through the STEAM backend, which handles authentication, TLS configuration, and audit logging. This keeps Atlas credentials server-side and provides a single audit trail.
|
||||
- **Cache-then-fetch**: The ReportingPage loads cached badge data from SQLite on mount (fast), and users trigger a manual sync to refresh from Atlas (slow, one API call per host). This matches the existing Ivanti sync UX.
|
||||
- **Sequential host sync (not bulk GET)**: The Atlas API only exposes per-host `GET /hosts/{host_id}/action-plans`. There is no bulk status endpoint, so the sync iterates over unique host IDs from the Ivanti cache. Concurrency is limited to avoid overwhelming Atlas.
|
||||
- **Basic Auth with runtime base64 encoding**: Atlas uses `Authorization: Basic <base64(user:pass)>` rather than the API key pattern used by Ivanti. The helper computes this at request time from environment variables.
|
||||
- **ID mapping**: Ivanti `host.hostId` maps directly to Atlas `host_id` in URL paths. Ivanti `f.id` maps to Atlas `active_host_findings_id` in request bodies. No translation layer is needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Frontend
|
||||
RP[ReportingPage]
|
||||
AB[AtlasBadge]
|
||||
SP[AtlasSlideOutPanel]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
AR[Atlas Router<br/>/api/atlas/*]
|
||||
AH[Atlas Helper<br/>atlasApi.js]
|
||||
AC[(Atlas Cache<br/>SQLite)]
|
||||
IC[(Ivanti Cache<br/>SQLite)]
|
||||
AL[Audit Log]
|
||||
end
|
||||
|
||||
subgraph External
|
||||
ATLAS[Atlas InfoSec API<br/>https://atlas-infosec.caas.charterlab.com]
|
||||
end
|
||||
|
||||
RP -->|GET /api/atlas/status| AR
|
||||
RP -->|POST /api/atlas/sync| AR
|
||||
AB -->|click| SP
|
||||
SP -->|GET /api/atlas/hosts/:id/action-plans| AR
|
||||
SP -->|PUT /api/atlas/hosts/:id/action-plans| AR
|
||||
SP -->|PATCH /api/atlas/hosts/:id/action-plans| AR
|
||||
|
||||
AR -->|read cached status| AC
|
||||
AR -->|read host IDs| IC
|
||||
AR -->|GET, PUT, PATCH, POST| AH
|
||||
AR -->|logAudit| AL
|
||||
AH -->|HTTPS + Basic Auth| ATLAS
|
||||
AR -->|upsert cache| AC
|
||||
```
|
||||
|
||||
### Data Flow: Page Load
|
||||
|
||||
1. ReportingPage mounts, fetches Ivanti findings from existing cache (existing behavior)
|
||||
2. ReportingPage fetches `GET /api/atlas/status` — returns all cached Atlas rows
|
||||
3. Frontend builds a `Map<hostId, atlasStatus>` and passes it to table rendering
|
||||
4. Each Host column cell checks the map — if a match exists, renders an AtlasBadge
|
||||
|
||||
### Data Flow: Manual Sync
|
||||
|
||||
1. User clicks Atlas sync button
|
||||
2. Frontend sends `POST /api/atlas/sync`
|
||||
3. Backend extracts unique `hostId` values from `ivanti_findings_cache.findings_json`
|
||||
4. Backend calls `GET /hosts/{host_id}/action-plans` for each host (with concurrency limit of 5)
|
||||
5. Backend upserts each result into `atlas_action_plans_cache`
|
||||
6. Backend returns summary `{ synced, withPlans, failed }`
|
||||
7. Frontend re-fetches `GET /api/atlas/status` and updates badges
|
||||
|
||||
### Data Flow: Create/Update Plan
|
||||
|
||||
1. User clicks AtlasBadge → slide-out panel opens
|
||||
2. Panel fetches `GET /api/atlas/hosts/:hostId/action-plans` for live data
|
||||
3. User fills create form or edits existing plan
|
||||
4. Frontend sends `PUT` (create) or `PATCH` (update) to `/api/atlas/hosts/:hostId/action-plans`
|
||||
5. Backend validates request body, proxies to Atlas API, logs audit entry
|
||||
6. On success, panel refreshes plan list and frontend re-fetches cached status
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: Atlas API Helper (`backend/helpers/atlasApi.js`)
|
||||
|
||||
A new helper module following the same pattern as `ivantiApi.js` — promise-based HTTP using Node's `https` module, with TLS skip support.
|
||||
|
||||
```javascript
|
||||
// Exported functions
|
||||
function atlasRequest(method, urlPath, body, options)
|
||||
// method: 'GET' | 'PUT' | 'PATCH' | 'POST'
|
||||
// urlPath: e.g. '/hosts/29329662/action-plans'
|
||||
// body: object | null (null for GET)
|
||||
// options: { timeout?: number }
|
||||
// Returns: Promise<{ status: number, body: string }>
|
||||
|
||||
// Convenience wrappers
|
||||
function atlasGet(urlPath, options)
|
||||
function atlasPut(urlPath, body, options)
|
||||
function atlasPatch(urlPath, body, options)
|
||||
function atlasPost(urlPath, body, options)
|
||||
```
|
||||
|
||||
**Configuration** (read from `process.env` at module load):
|
||||
- `ATLAS_API_URL` — base URL (e.g. `https://atlas-infosec.caas.charterlab.com`)
|
||||
- `ATLAS_API_USER` — service account username
|
||||
- `ATLAS_API_PASS` — service account password
|
||||
- `ATLAS_SKIP_TLS` — `'true'` to disable certificate verification
|
||||
|
||||
**Auth header**: `Authorization: Basic ${Buffer.from(user + ':' + pass).toString('base64')}`
|
||||
|
||||
**Timeouts**: 15s default for single-host endpoints, 60s for bulk. Passed via `options.timeout`.
|
||||
|
||||
**Error handling**: Network errors and timeouts reject the promise. Non-2xx responses resolve normally with `{ status, body }` — the caller decides how to handle them.
|
||||
|
||||
### Backend: Migration (`backend/migrations/add_atlas_action_plans_cache.js`)
|
||||
|
||||
Creates the `atlas_action_plans_cache` table following the existing migration pattern.
|
||||
|
||||
```javascript
|
||||
// Table schema
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL UNIQUE,
|
||||
has_action_plan INTEGER NOT NULL DEFAULT 0,
|
||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
|
||||
ON atlas_action_plans_cache(host_id)
|
||||
`);
|
||||
```
|
||||
|
||||
### Backend: Atlas Router (`backend/routes/atlas.js`)
|
||||
|
||||
Factory function pattern: `createAtlasRouter(db, requireAuth)` returns an Express Router mounted at `/api/atlas`.
|
||||
|
||||
| Method | Path | Auth | Group | Description |
|
||||
|--------|------|------|-------|-------------|
|
||||
| `GET` | `/status` | requireAuth | any | Return all cached Atlas rows |
|
||||
| `POST` | `/sync` | requireAuth | Admin, Standard_User | Sync Atlas data for all Ivanti hosts |
|
||||
| `GET` | `/hosts/:hostId/action-plans` | requireAuth | any | Proxy to Atlas GET plans |
|
||||
| `PUT` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas create plan |
|
||||
| `PATCH` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas update plan |
|
||||
| `POST` | `/hosts/bulk-action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas bulk create |
|
||||
|
||||
**Sync implementation**:
|
||||
1. Parse `ivanti_findings_cache.findings_json` to extract unique `hostId` values (skip nulls)
|
||||
2. Process hosts in batches of 5 concurrent requests using `Promise.allSettled`
|
||||
3. For each host, call `atlasGet('/hosts/' + hostId + '/action-plans')`
|
||||
4. On 2xx: upsert cache row with plan count and summary JSON
|
||||
5. On non-2xx: increment failure counter, log warning, continue
|
||||
6. Return `{ synced: N, withPlans: N, failed: N }`
|
||||
|
||||
**Validation (PUT create)**:
|
||||
- `plan_type` must be one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`
|
||||
- `commit_date` must match `/^\d{4}-\d{2}-\d{2}$/`
|
||||
- `hostId` param must be a positive integer
|
||||
|
||||
**Validation (PATCH update)**:
|
||||
- `action_plan_id` must be a non-empty string
|
||||
- `updates` must be a non-null object
|
||||
|
||||
**Validation (POST bulk)**:
|
||||
- `host_ids` must be a non-empty array of positive integers
|
||||
- `plan_type` and `commit_date` validated same as PUT
|
||||
|
||||
### Frontend: AtlasBadge Component
|
||||
|
||||
A small inline badge rendered inside the Host column cell, next to the hostname. Clicking it opens the slide-out panel.
|
||||
|
||||
**Props**: `{ hostId, atlasStatus, onClick }`
|
||||
|
||||
**Rendering logic**:
|
||||
- If `atlasStatus` is `undefined` (host not in Atlas cache): render nothing
|
||||
- If `atlasStatus.has_action_plan === 0`: render warning badge (amber border, "0" text)
|
||||
- If `atlasStatus.plan_count > 0`: render success badge (emerald border, count text)
|
||||
|
||||
**Style**: Small pill badge using the design system's badge pattern — monospace font, 0.58rem, inline-flex, with border color indicating status. Positioned after the hostname text in the OverrideCell wrapper.
|
||||
|
||||
### Frontend: AtlasSlideOutPanel Component
|
||||
|
||||
A right-side drawer panel, similar in concept to the existing FP submission detail panels. Renders over the table content with a semi-transparent backdrop.
|
||||
|
||||
**Props**: `{ hostId, hostName, onClose, canWrite }`
|
||||
|
||||
**Sections**:
|
||||
1. **Header**: hostname, host ID, close button
|
||||
2. **Plan list**: fetched from `GET /api/atlas/hosts/:hostId/action-plans` on open. Each plan shows type, commit date, status, and optional VNR/EXC references
|
||||
3. **Create form** (if `canWrite`): plan type dropdown, commit date picker, optional fields (qualys_id, active_host_findings_id, jira_vnr, archer_exc)
|
||||
4. **Edit capability** (if `canWrite`): inline edit on existing plans, submits via PATCH
|
||||
|
||||
**State management**: Local component state — plan list, loading, error, form values. No global state needed since the panel is ephemeral.
|
||||
|
||||
### Frontend: Atlas Sync Button
|
||||
|
||||
A new button in the ReportingPage toolbar, placed adjacent to the existing Ivanti sync button. Uses the same styling pattern — `RefreshCw` icon, monospace uppercase text, sky blue accent color. Differentiated by a `Database` icon prefix and "Atlas" label.
|
||||
|
||||
**State**: `atlasSyncing` boolean, `atlasStatus` map (keyed by hostId), `atlasError` string.
|
||||
|
||||
### Server.js Integration
|
||||
|
||||
```javascript
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
// ...
|
||||
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Atlas Cache Table (`atlas_action_plans_cache`)
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
|
||||
| `host_id` | INTEGER | NOT NULL UNIQUE | Ivanti host ID (= Atlas host_id) |
|
||||
| `has_action_plan` | INTEGER | NOT NULL DEFAULT 0 | 1 if any plans exist, 0 otherwise |
|
||||
| `plan_count` | INTEGER | NOT NULL DEFAULT 0 | Number of action plans |
|
||||
| `plans_json` | TEXT | NOT NULL DEFAULT '[]' | JSON array of plan summaries |
|
||||
| `synced_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Last sync timestamp |
|
||||
|
||||
**Index**: `idx_atlas_cache_host_id` on `host_id`
|
||||
|
||||
### Plan Summary JSON Shape (stored in `plans_json`)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action_plan_id": "ap-123",
|
||||
"plan_type": "remediation",
|
||||
"commit_date": "2026-07-01",
|
||||
"status": "active",
|
||||
"qualys_id": "QID-12345",
|
||||
"active_host_findings_id": 2281281250,
|
||||
"jira_vnr": null,
|
||||
"archer_exc": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The exact shape depends on what the Atlas API returns. The backend stores the raw response array as-is, extracting only `plan_count` and `has_action_plan` for the cache columns.
|
||||
|
||||
### Atlas API Request/Response Shapes
|
||||
|
||||
**Create (PUT `/hosts/{host_id}/action-plans`)**:
|
||||
```json
|
||||
{
|
||||
"plan_type": "remediation",
|
||||
"commit_date": "2026-07-01",
|
||||
"active_host_findings_id": 2281281250
|
||||
}
|
||||
```
|
||||
|
||||
**Update (PATCH `/hosts/{host_id}/action-plans`)**:
|
||||
```json
|
||||
{
|
||||
"action_plan_id": "ap-123",
|
||||
"updates": {
|
||||
"commit_date": "2026-08-01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bulk Create (POST `/hosts/create-bulk-action-plans`)**:
|
||||
```json
|
||||
{
|
||||
"host_ids": [29329662, 29329663],
|
||||
"plan_type": "decommission",
|
||||
"commit_date": "2026-07-01"
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `ATLAS_API_URL` | Yes | Atlas InfoSec API base URL |
|
||||
| `ATLAS_API_USER` | Yes | Service account username for Basic Auth |
|
||||
| `ATLAS_API_PASS` | Yes | Service account password for Basic Auth |
|
||||
| `ATLAS_SKIP_TLS` | No | Set to `true` to skip TLS cert verification (default: `false`) |
|
||||
|
||||
### Frontend State Shape
|
||||
|
||||
```javascript
|
||||
// Atlas status map — keyed by hostId (number)
|
||||
const atlasStatusMap = new Map([
|
||||
[29329662, { host_id: 29329662, has_action_plan: 1, plan_count: 2, synced_at: '2026-07-01 12:00:00' }],
|
||||
[29329663, { host_id: 29329663, has_action_plan: 0, plan_count: 0, synced_at: '2026-07-01 12:00:00' }],
|
||||
]);
|
||||
```
|
||||
|
||||
|
||||
## 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: Basic Auth header round-trip
|
||||
|
||||
*For any* pair of (username, password) strings, the Authorization header produced by the Atlas helper should decode (via base64) to exactly `username:password`.
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 2: Non-2xx responses resolve with status and body
|
||||
|
||||
*For any* HTTP status code in the range 400–599 and any response body string, the Atlas helper should resolve the promise with an object containing that exact status code and body, rather than rejecting.
|
||||
|
||||
**Validates: Requirements 1.7**
|
||||
|
||||
### Property 3: Error messages contain method and path
|
||||
|
||||
*For any* HTTP method string and URL path string, when a network error or timeout occurs, the Atlas helper's rejection error message should contain both the method and the path.
|
||||
|
||||
**Validates: Requirements 1.8**
|
||||
|
||||
### Property 4: Unique host ID extraction
|
||||
|
||||
*For any* array of finding objects (each with an optional `hostId` field), the sync operation should extract exactly the set of unique, non-null `hostId` values — no duplicates, no nulls.
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 5: Cache upsert derives correct plan_count and has_action_plan
|
||||
|
||||
*For any* host ID and any array of action plan objects returned by the Atlas API, after upserting into the cache, the stored `plan_count` should equal the array length and `has_action_plan` should equal 1 if the array is non-empty, 0 otherwise.
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
### Property 6: Sync response count invariant
|
||||
|
||||
*For any* sync operation over N unique hosts where M hosts fail, the response should satisfy: `synced + failed = N` and `withPlans <= synced`.
|
||||
|
||||
**Validates: Requirements 3.6**
|
||||
|
||||
### Property 7: Status endpoint returns all cached rows with required fields
|
||||
|
||||
*For any* set of rows inserted into the Atlas cache table, the `GET /api/atlas/status` endpoint should return exactly that many rows, and each row should contain `host_id`, `has_action_plan`, `plan_count`, and `synced_at` fields.
|
||||
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
### Property 8: plan_type validation
|
||||
|
||||
*For any* string, the PUT endpoint's plan_type validation should accept the string if and only if it is one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`.
|
||||
|
||||
**Validates: Requirements 5.3**
|
||||
|
||||
### Property 9: commit_date validation
|
||||
|
||||
*For any* string, the PUT endpoint's commit_date validation should accept the string if and only if it matches the pattern `YYYY-MM-DD` (four digits, hyphen, two digits, hyphen, two digits).
|
||||
|
||||
**Validates: Requirements 5.4**
|
||||
|
||||
### Property 10: PATCH body validation
|
||||
|
||||
*For any* request body object, the PATCH endpoint should accept it if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object.
|
||||
|
||||
**Validates: Requirements 5.7**
|
||||
|
||||
### Property 11: Bulk request validation
|
||||
|
||||
*For any* request body object, the bulk POST endpoint should accept it if and only if `host_ids` is a non-empty array of positive integers, `plan_type` is one of the five valid types, and `commit_date` matches `YYYY-MM-DD`.
|
||||
|
||||
**Validates: Requirements 5.10**
|
||||
|
||||
### Property 12: Non-2xx Atlas response passthrough
|
||||
|
||||
*For any* non-2xx status code and error body returned by the Atlas API, the proxy route should return that same status code and body to the frontend client.
|
||||
|
||||
**Validates: Requirements 5.12**
|
||||
|
||||
### Property 13: Badge visibility and content
|
||||
|
||||
*For any* finding with a `hostId` and any atlas status map, the AtlasBadge should render if and only if the `hostId` exists as a key in the map. When rendered with `plan_count > 0`, the badge text should contain the plan count value.
|
||||
|
||||
**Validates: Requirements 6.2, 6.5, 6.6**
|
||||
|
||||
### Property 14: Panel displays all plan fields
|
||||
|
||||
*For any* action plan object containing `plan_type`, `commit_date`, `status`, and optional reference fields (`jira_vnr`, `archer_exc`), the rendered slide-out panel should include all non-null field values in its output.
|
||||
|
||||
**Validates: Requirements 7.3**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Atlas API Communication Errors
|
||||
|
||||
| Error Scenario | Handling | User Impact |
|
||||
|----------------|----------|-------------|
|
||||
| Network timeout (15s/60s) | Helper rejects promise with descriptive error | Sync: host skipped, counted as failed. Proxy: 502 returned to frontend with error message |
|
||||
| DNS resolution failure | Helper rejects promise | Same as timeout |
|
||||
| TLS certificate error (when `ATLAS_SKIP_TLS` is false) | Helper rejects promise | Same as timeout |
|
||||
| Atlas API returns 401 (bad credentials) | Helper resolves with `{ status: 401, body }` | Sync: all hosts fail. Proxy: 401 forwarded to frontend. Error banner shown |
|
||||
| Atlas API returns 404 (host not found) | Helper resolves with `{ status: 404, body }` | Sync: host skipped. Proxy: 404 forwarded to frontend |
|
||||
| Atlas API returns 422 (validation error) | Helper resolves with `{ status: 422, body }` | Proxy: 422 forwarded to frontend. Panel shows validation error |
|
||||
| Atlas API returns 500 (server error) | Helper resolves with `{ status: 500, body }` | Sync: host skipped. Proxy: 500 forwarded to frontend |
|
||||
|
||||
### Backend Validation Errors
|
||||
|
||||
| Error Scenario | HTTP Status | Response |
|
||||
|----------------|-------------|----------|
|
||||
| Missing or invalid `plan_type` | 400 | `{ error: 'plan_type must be one of: decommission, remediation, ...' }` |
|
||||
| Missing or invalid `commit_date` | 400 | `{ error: 'commit_date must be a valid YYYY-MM-DD date string' }` |
|
||||
| Missing `action_plan_id` on PATCH | 400 | `{ error: 'action_plan_id is required and must be a non-empty string' }` |
|
||||
| Missing `updates` on PATCH | 400 | `{ error: 'updates is required and must be an object' }` |
|
||||
| Empty or invalid `host_ids` on bulk | 400 | `{ error: 'host_ids must be a non-empty array of positive integers' }` |
|
||||
| Non-integer `hostId` URL param | 400 | `{ error: 'hostId must be a positive integer' }` |
|
||||
| Unauthenticated request | 401 | `{ error: 'Authentication required' }` |
|
||||
| Viewer group on restricted endpoint | 403 | `{ error: 'Insufficient permissions', required: [...], current: 'Viewer' }` |
|
||||
|
||||
### Frontend Error Handling
|
||||
|
||||
- **Sync failure**: Error banner displayed below the Atlas sync button (matching existing Ivanti sync error pattern). Button re-enabled.
|
||||
- **Panel fetch failure**: "Failed to load action plans" message inside the panel with a retry button.
|
||||
- **Create/update failure**: Error message displayed inline in the form, preserving user input for correction.
|
||||
- **Network error**: Generic "Unable to reach server" message with retry option.
|
||||
|
||||
### Environment Configuration Errors
|
||||
|
||||
- If `ATLAS_API_URL`, `ATLAS_API_USER`, or `ATLAS_API_PASS` are not set, the Atlas helper logs a warning at module load time. All Atlas API calls will fail with a descriptive error rather than crashing the server.
|
||||
- The Atlas router checks for helper availability and returns 503 if Atlas is not configured.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests cover specific examples, edge cases, and integration points:
|
||||
|
||||
**Atlas Helper (`atlasApi.js`)**:
|
||||
- Correct URL construction from base URL + path
|
||||
- Basic Auth header format for known credentials
|
||||
- TLS skip flag respected (rejectUnauthorized option)
|
||||
- Timeout values for single vs bulk endpoints
|
||||
- GET, PUT, PATCH, POST methods set correctly
|
||||
|
||||
**Atlas Router validation**:
|
||||
- Valid plan_type values accepted, invalid rejected
|
||||
- Valid commit_date formats accepted, invalid rejected
|
||||
- PATCH body with missing action_plan_id rejected
|
||||
- Bulk request with empty host_ids rejected
|
||||
- Non-integer hostId param rejected
|
||||
- Auth middleware applied to correct endpoints (401/403 responses)
|
||||
|
||||
**Atlas Cache operations**:
|
||||
- Upsert creates new row when host_id doesn't exist
|
||||
- Upsert updates existing row when host_id exists
|
||||
- Status endpoint returns empty array when cache is empty
|
||||
- Migration is idempotent (runs twice without error)
|
||||
|
||||
**Frontend components**:
|
||||
- AtlasBadge renders nothing when host not in status map
|
||||
- AtlasBadge renders warning style when plan_count is 0
|
||||
- AtlasBadge renders success style when plan_count > 0
|
||||
- AtlasSlideOutPanel shows create form for Admin/Standard_User
|
||||
- AtlasSlideOutPanel hides create form for Viewer
|
||||
- Sync button disabled during sync, re-enabled after
|
||||
|
||||
### 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-action-plans, Property 1: Basic Auth header round-trip
|
||||
```
|
||||
|
||||
| Property | Test Description | Generator Strategy |
|
||||
|----------|-----------------|-------------------|
|
||||
| Property 1 | Encode then decode Basic Auth header | Generate random (user, pass) string pairs, verify round-trip |
|
||||
| Property 2 | Non-2xx status codes resolve | Generate integers 400–599 and random body strings |
|
||||
| Property 3 | Error messages contain method and path | Generate random method names and URL path strings |
|
||||
| Property 4 | Unique host ID extraction | Generate arrays of objects with optional numeric hostId fields |
|
||||
| Property 5 | Cache upsert correctness | Generate (hostId, planArray) pairs, verify derived fields |
|
||||
| Property 6 | Sync count invariant | Generate (totalHosts, failureCount) pairs, verify arithmetic |
|
||||
| Property 7 | Status returns all cached rows | Generate N cache rows, verify response count and fields |
|
||||
| Property 8 | plan_type validation | Generate random strings, verify acceptance matches valid set |
|
||||
| Property 9 | commit_date validation | Generate random strings, verify acceptance matches date pattern |
|
||||
| Property 10 | PATCH body validation | Generate random objects with varying field presence |
|
||||
| Property 11 | Bulk validation | Generate objects with varying host_ids, plan_type, commit_date |
|
||||
| Property 12 | Error passthrough | Generate non-2xx codes and body strings, verify forwarding |
|
||||
| Property 13 | Badge visibility and content | Generate findings and status maps, verify render logic |
|
||||
| Property 14 | Panel plan field display | Generate plan objects, verify all non-null fields appear |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Integration tests verify end-to-end behavior with mocked Atlas API:
|
||||
|
||||
- Full sync flow: populate Ivanti cache → trigger sync → verify Atlas cache populated
|
||||
- Create plan flow: send PUT → verify Atlas API called → verify audit logged
|
||||
- Update plan flow: send PATCH → verify Atlas API called → verify audit logged
|
||||
- Bulk create flow: send POST → verify Atlas API called with correct body
|
||||
- Error resilience: mix of successful and failing hosts during sync
|
||||
- Auth enforcement: verify 401/403 for each endpoint with wrong credentials/group
|
||||
164
.kiro/specs/atlas-action-plans/requirements.md
Normal file
164
.kiro/specs/atlas-action-plans/requirements.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard so that users can view and manage compliance action plans for host findings directly from the ReportingPage. This eliminates the need to context-switch to the separate Atlas InfoSec web tool. The integration uses STEAM's Ivanti findings as the source of truth and checks which hosts also exist in Atlas, displaying action plan status badges and providing a slide-out panel for plan creation and management.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Dashboard**: The STEAM Security Dashboard frontend React application
|
||||
- **Backend**: The STEAM Security Dashboard Express.js API server
|
||||
- **Atlas_API**: The Atlas InfoSec REST API at `https://atlas-infosec.caas.charterlab.com`, documented in `docs/atlasinfosec-api-spec.json`
|
||||
- **Atlas_Helper**: The backend helper module (`backend/helpers/atlasApi.js`) responsible for HTTP communication with the Atlas_API
|
||||
- **Atlas_Cache**: A SQLite table storing host-level action plan status per `hostId`, refreshed on-demand via manual sync
|
||||
- **Atlas_Router**: The backend Express route module (`backend/routes/atlas.js`) exposing Atlas-related endpoints under `/api/atlas`
|
||||
- **ReportingPage**: The existing frontend page (`frontend/src/components/pages/ReportingPage.js`) displaying Ivanti host findings
|
||||
- **Action_Plan**: A compliance plan created in Atlas InfoSec for a host finding, with a type (decommission, remediation, false_positive, risk_acceptance, scan_exclusion), a commit date, and optional reference fields
|
||||
- **Host_ID**: The shared numeric identifier linking an Ivanti host finding (`host.hostId`) to an Atlas host (`host_id` URL parameter)
|
||||
- **Finding_ID**: The Ivanti finding-level identifier (`f.id`) that maps to Atlas's `active_host_findings_id`
|
||||
- **Slide_Out_Panel**: A right-side drawer component on the ReportingPage for viewing and managing action plans for a specific host
|
||||
- **Atlas_Badge**: A visual indicator on the ReportingPage Host column showing whether a host exists in Atlas and its action plan coverage status
|
||||
- **Ivanti_Cache**: The existing `ivanti_findings_cache` SQLite table holding synced Ivanti host findings
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Atlas API Helper Module
|
||||
|
||||
**User Story:** As a backend developer, I want a centralized helper module for Atlas InfoSec API communication, so that all Atlas HTTP calls use consistent authentication, TLS handling, and error management.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Helper SHALL send all requests to the Atlas_API base URL configured via the `ATLAS_API_URL` environment variable
|
||||
2. THE Atlas_Helper SHALL include a Basic Auth `Authorization` header computed by base64-encoding the `ATLAS_API_USER` and `ATLAS_API_PASS` environment variable values at runtime
|
||||
3. WHEN the `ATLAS_SKIP_TLS` environment variable is set to `true`, THE Atlas_Helper SHALL disable TLS certificate verification for Atlas_API requests
|
||||
4. WHEN the `ATLAS_SKIP_TLS` environment variable is not set or set to `false`, THE Atlas_Helper SHALL enforce TLS certificate verification for Atlas_API requests
|
||||
5. THE Atlas_Helper SHALL support GET, PUT, PATCH, and POST HTTP methods for communicating with the Atlas_API
|
||||
6. THE Atlas_Helper SHALL set a request timeout of 15 seconds for single-host endpoints and 60 seconds for bulk endpoints
|
||||
7. WHEN the Atlas_API returns a non-2xx status code, THE Atlas_Helper SHALL resolve the promise with the status code and response body without throwing an exception
|
||||
8. WHEN a network error or timeout occurs, THE Atlas_Helper SHALL reject the promise with a descriptive error message including the HTTP method and URL path
|
||||
|
||||
### Requirement 2: Atlas Cache Table and Migration
|
||||
|
||||
**User Story:** As a system administrator, I want Atlas action plan status cached locally in SQLite, so that the ReportingPage can render badges without calling the Atlas_API on every page load.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Backend SHALL provide a migration script (`backend/migrations/add_atlas_action_plans_cache.js`) that creates the Atlas_Cache table
|
||||
2. THE Atlas_Cache table SHALL store one row per Host_ID with columns for: `host_id` (integer, unique), `has_action_plan` (integer, 0 or 1), `plan_count` (integer), `plans_json` (text, JSON array of plan summaries), and `synced_at` (datetime)
|
||||
3. THE migration script SHALL create an index on the `host_id` column of the Atlas_Cache table
|
||||
4. THE migration script SHALL follow the existing migration pattern: open the database at `backend/cve_database.db`, use `db.serialize()`, log progress to the console, and close the database on completion
|
||||
5. WHEN the migration script is run multiple times, THE migration script SHALL complete without errors by using `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`
|
||||
|
||||
### Requirement 3: Atlas Sync Route
|
||||
|
||||
**User Story:** As a dashboard user, I want to trigger a manual sync of Atlas action plan data, so that the badge indicators on the ReportingPage reflect the current state of action plans in Atlas.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Router SHALL expose a `POST /api/atlas/sync` endpoint that requires authentication and membership in the Admin or Standard_User group
|
||||
2. WHEN the sync endpoint is called, THE Atlas_Router SHALL extract unique Host_ID values from the Ivanti_Cache findings
|
||||
3. WHEN unique Host_ID values are extracted, THE Atlas_Router SHALL call the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint for each Host_ID to retrieve action plan data
|
||||
4. WHEN action plan data is retrieved for a Host_ID, THE Atlas_Router SHALL upsert the Atlas_Cache row for that Host_ID with the plan count, plan summary JSON, and current timestamp
|
||||
5. WHEN the Atlas_API returns a non-2xx response for a specific Host_ID, THE Atlas_Router SHALL skip that host and continue processing remaining hosts
|
||||
6. WHEN the sync completes, THE Atlas_Router SHALL return a JSON response containing the count of hosts synced, the count of hosts with action plans, and the count of hosts that failed
|
||||
7. THE Atlas_Router SHALL log an audit entry for each sync operation with the initiating user and result summary
|
||||
|
||||
### Requirement 4: Atlas Status Route
|
||||
|
||||
**User Story:** As a frontend developer, I want a single endpoint that returns cached Atlas status for all hosts, so that the ReportingPage can render badges without individual API calls per row.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Router SHALL expose a `GET /api/atlas/status` endpoint that requires authentication
|
||||
2. WHEN the status endpoint is called, THE Atlas_Router SHALL return all rows from the Atlas_Cache table as a JSON array
|
||||
3. THE status response SHALL include for each host: `host_id`, `has_action_plan`, `plan_count`, and `synced_at`
|
||||
|
||||
### Requirement 5: Atlas Action Plan Proxy Routes
|
||||
|
||||
**User Story:** As a dashboard user, I want to create, view, and update Atlas action plans from the STEAM Dashboard, so that I do not need to switch to the Atlas InfoSec web tool.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Router SHALL expose a `GET /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and proxies to the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint
|
||||
2. THE Atlas_Router SHALL expose a `PUT /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
|
||||
3. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
||||
4. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `commit_date` is present and is a valid date string in YYYY-MM-DD format
|
||||
5. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PUT /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response
|
||||
6. THE Atlas_Router SHALL expose a `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
|
||||
7. WHEN the PATCH endpoint receives a request body, THE Atlas_Router SHALL validate that `action_plan_id` (string) and `updates` (object) are present
|
||||
8. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PATCH /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response
|
||||
9. THE Atlas_Router SHALL expose a `POST /api/atlas/hosts/bulk-action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
|
||||
10. WHEN the bulk endpoint receives a request body, THE Atlas_Router SHALL validate that `host_ids` is a non-empty array of integers, `plan_type` is valid, and `commit_date` is a valid YYYY-MM-DD date string
|
||||
11. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `POST /hosts/create-bulk-action-plans` endpoint and return the Atlas_API response
|
||||
12. WHEN any proxy endpoint receives a non-2xx response from the Atlas_API, THE Atlas_Router SHALL return the Atlas_API status code and error body to the frontend
|
||||
13. THE Atlas_Router SHALL log an audit entry for each create (PUT) and update (PATCH) action plan operation with the user, Host_ID, and plan type
|
||||
|
||||
### Requirement 6: Atlas Badge on ReportingPage
|
||||
|
||||
**User Story:** As a dashboard user, I want to see at a glance which hosts in the findings table have Atlas action plans, so that I can prioritize hosts that still need compliance attention.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the ReportingPage loads, THE Dashboard SHALL fetch cached Atlas status from `GET /api/atlas/status` and store the result in component state
|
||||
2. WHEN a finding row's Host_ID matches an entry in the Atlas status data, THE Dashboard SHALL display an Atlas_Badge in the Host column next to the hostname
|
||||
3. WHEN a host exists in Atlas but has zero action plans, THE Atlas_Badge SHALL display with a warning style indicating the host needs attention
|
||||
4. WHEN a host exists in Atlas and has one or more active action plans, THE Atlas_Badge SHALL display with a success style indicating the host is covered
|
||||
5. WHEN a finding row's Host_ID does not match any entry in the Atlas status data, THE Dashboard SHALL display no Atlas_Badge for that row
|
||||
6. THE Atlas_Badge SHALL display the action plan count as text within the badge when the host has one or more plans
|
||||
|
||||
### Requirement 7: Atlas Slide-Out Panel
|
||||
|
||||
**User Story:** As a dashboard user, I want to click an Atlas badge to see full action plan details and create or update plans, so that I can manage compliance without leaving the ReportingPage.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user clicks an Atlas_Badge, THE Dashboard SHALL open the Slide_Out_Panel on the right side of the ReportingPage
|
||||
2. WHEN the Slide_Out_Panel opens, THE Dashboard SHALL fetch full action plan details from `GET /api/atlas/hosts/:hostId/action-plans` and display them in the panel
|
||||
3. THE Slide_Out_Panel SHALL display each existing action plan with: plan type, commit date, status, and any associated VNR or EXC reference numbers
|
||||
4. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL display a form to create a new action plan with fields for: plan type (dropdown selector), commit date (date picker), qualys_id (optional text input), active_host_findings_id (optional numeric input), jira_vnr (optional text input), and archer_exc (optional text input)
|
||||
5. WHEN the user submits the create form with valid data, THE Dashboard SHALL send a PUT request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success
|
||||
6. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL provide an edit capability for existing action plans
|
||||
7. WHEN the user submits an update with valid data, THE Dashboard SHALL send a PATCH request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success
|
||||
8. WHEN the Atlas_API returns an error for a create or update operation, THE Slide_Out_Panel SHALL display the error message to the user
|
||||
9. WHEN the user clicks outside the Slide_Out_Panel or clicks a close button, THE Dashboard SHALL close the panel
|
||||
10. WHEN the user is in the Viewer group, THE Slide_Out_Panel SHALL display existing plans in read-only mode without the create or edit forms
|
||||
|
||||
### Requirement 8: Atlas Sync Button on ReportingPage
|
||||
|
||||
**User Story:** As a dashboard user, I want a manual sync button for Atlas data on the ReportingPage, so that I can refresh action plan status on demand.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL display an Atlas sync button on the ReportingPage near the existing Ivanti sync button
|
||||
2. WHEN the user is in the Admin or Standard_User group, THE Atlas sync button SHALL be enabled
|
||||
3. WHEN the user is in the Viewer group, THE Atlas sync button SHALL be disabled with a tooltip indicating insufficient permissions
|
||||
4. WHEN the user clicks the Atlas sync button, THE Dashboard SHALL send a POST request to `/api/atlas/sync`
|
||||
5. WHILE the Atlas sync is in progress, THE Atlas sync button SHALL display a loading indicator and be disabled to prevent duplicate requests
|
||||
6. WHEN the Atlas sync completes successfully, THE Dashboard SHALL refresh the Atlas status data and update all Atlas_Badge indicators on the page
|
||||
7. WHEN the Atlas sync fails, THE Dashboard SHALL display an error notification with the failure reason
|
||||
|
||||
### Requirement 9: Environment Configuration
|
||||
|
||||
**User Story:** As a system administrator, I want Atlas API credentials and configuration documented alongside existing environment variables, so that deployment setup is straightforward.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Backend SHALL read the Atlas_API base URL from the `ATLAS_API_URL` environment variable
|
||||
2. THE Backend SHALL read the Atlas service account username from the `ATLAS_API_USER` environment variable
|
||||
3. THE Backend SHALL read the Atlas service account password from the `ATLAS_API_PASS` environment variable
|
||||
4. THE Backend SHALL read the TLS verification skip flag from the `ATLAS_SKIP_TLS` environment variable
|
||||
5. THE Backend SHALL document all four Atlas environment variables in `backend/.env.example` with descriptive comments
|
||||
|
||||
### Requirement 10: Access Control
|
||||
|
||||
**User Story:** As a security administrator, I want Atlas operations restricted by user group, so that only authorized users can modify action plans or trigger syncs.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/status` endpoint
|
||||
2. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/hosts/:hostId/action-plans` endpoint
|
||||
3. THE Atlas_Router SHALL restrict the `POST /api/atlas/sync` endpoint to users in the Admin or Standard_User group
|
||||
4. THE Atlas_Router SHALL restrict the `PUT /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group
|
||||
5. THE Atlas_Router SHALL restrict the `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group
|
||||
6. THE Atlas_Router SHALL restrict the `POST /api/atlas/hosts/bulk-action-plans` endpoint to users in the Admin or Standard_User group
|
||||
7. WHEN an unauthorized user attempts a restricted operation, THE Atlas_Router SHALL return HTTP 403 with an error message indicating insufficient permissions
|
||||
262
.kiro/specs/atlas-action-plans/tasks.md
Normal file
262
.kiro/specs/atlas-action-plans/tasks.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Implementation Plan: Atlas Action Plans Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard. The implementation follows the existing proxy-and-cache pattern — backend helper for HTTP communication, SQLite cache for fast page loads, Express routes for proxied CRUD, and React frontend components for badge display and plan management. Tasks are ordered for incremental progress: environment config, backend helper, migration, routes, server wiring, then frontend components.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add Atlas environment variables to `.env.example`
|
||||
- Append `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, and `ATLAS_SKIP_TLS` to `backend/.env.example` with descriptive comments, following the existing Ivanti variable block pattern
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
|
||||
|
||||
- [ ] 2. Implement Atlas API helper module
|
||||
- [x] 2.1 Create `backend/helpers/atlasApi.js` with `atlasRequest`, `atlasGet`, `atlasPut`, `atlasPatch`, `atlasPost` functions
|
||||
- Read `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, `ATLAS_SKIP_TLS` from `process.env` at module load
|
||||
- Compute `Authorization: Basic <base64(user:pass)>` header at request time
|
||||
- Use Node.js `https` module following the `ivantiApi.js` pattern
|
||||
- Support GET, PUT, PATCH, POST methods with `rejectUnauthorized` controlled by `ATLAS_SKIP_TLS`
|
||||
- Default timeout 15s for single-host endpoints, 60s via `options.timeout` for bulk
|
||||
- Resolve non-2xx responses with `{ status, body }` without throwing
|
||||
- Reject on network errors/timeouts with a message containing the HTTP method and URL path
|
||||
- Log a warning at module load if required env vars are missing
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_
|
||||
|
||||
- [ ] 2.2 Write property test: Basic Auth header round-trip
|
||||
- **Property 1: Basic Auth header round-trip**
|
||||
- Generate random (username, password) string pairs, verify base64 decode yields `username:password`
|
||||
- **Validates: Requirements 1.2**
|
||||
|
||||
- [ ] 2.3 Write property test: Non-2xx responses resolve with status and body
|
||||
- **Property 2: Non-2xx responses resolve with status and body**
|
||||
- Generate integers 400–599 and random body strings, verify promise resolves with `{ status, body }`
|
||||
- **Validates: Requirements 1.7**
|
||||
|
||||
- [ ] 2.4 Write property test: Error messages contain method and path
|
||||
- **Property 3: Error messages contain method and path**
|
||||
- Generate random method names and URL path strings, verify rejection message includes both
|
||||
- **Validates: Requirements 1.8**
|
||||
|
||||
- [x] 3. Checkpoint — Verify Atlas helper module
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 4. Create Atlas cache migration
|
||||
- [x] 4.1 Create `backend/migrations/add_atlas_action_plans_cache.js`
|
||||
- Follow the existing migration pattern from `add_ivanti_findings_tables.js`: open `backend/cve_database.db`, use `db.serialize()`, log progress, close on completion
|
||||
- Create `atlas_action_plans_cache` table with columns: `id` (INTEGER PRIMARY KEY AUTOINCREMENT), `host_id` (INTEGER NOT NULL UNIQUE), `has_action_plan` (INTEGER NOT NULL DEFAULT 0), `plan_count` (INTEGER NOT NULL DEFAULT 0), `plans_json` (TEXT NOT NULL DEFAULT '[]'), `synced_at` (DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)
|
||||
- Create index `idx_atlas_cache_host_id` on `host_id`
|
||||
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||
|
||||
- [ ]* 4.2 Write unit tests for migration idempotency
|
||||
- Verify migration runs twice without errors
|
||||
- Verify table and index exist after migration
|
||||
- _Requirements: 2.5_
|
||||
|
||||
- [ ] 5. Implement Atlas router with all endpoints
|
||||
- [x] 5.1 Create `backend/routes/atlas.js` with `createAtlasRouter(db, requireAuth)` factory function
|
||||
- Import `requireGroup` from `../middleware/auth`, `logAudit` from `../helpers/auditLog`, and Atlas helper functions from `../helpers/atlasApi`
|
||||
- Check Atlas helper availability; return 503 if Atlas is not configured
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
|
||||
|
||||
- [x] 5.2 Implement `GET /status` endpoint
|
||||
- Require authentication (any group)
|
||||
- Return all rows from `atlas_action_plans_cache` as JSON array with `host_id`, `has_action_plan`, `plan_count`, `synced_at`
|
||||
- _Requirements: 4.1, 4.2, 4.3, 10.1_
|
||||
|
||||
- [x] 5.3 Implement `POST /sync` endpoint
|
||||
- Require authentication and Admin or Standard_User group
|
||||
- Extract unique non-null `hostId` values from `ivanti_findings_cache.findings_json`
|
||||
- Call `atlasGet('/hosts/' + hostId + '/action-plans')` for each host with concurrency limit of 5 using `Promise.allSettled`
|
||||
- On 2xx: upsert cache row with `plan_count`, `has_action_plan`, `plans_json`, and current timestamp
|
||||
- On non-2xx: increment failure counter, log warning, continue
|
||||
- Return `{ synced, withPlans, failed }` summary
|
||||
- Log audit entry with initiating user and result summary
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 10.3_
|
||||
|
||||
- [x] 5.4 Implement `GET /hosts/:hostId/action-plans` proxy endpoint
|
||||
- Require authentication (any group)
|
||||
- Validate `hostId` is a positive integer
|
||||
- Proxy to Atlas API `GET /hosts/{host_id}/action-plans` and return the response
|
||||
- Forward non-2xx Atlas responses to the client
|
||||
- _Requirements: 5.1, 5.12, 10.2_
|
||||
|
||||
- [x] 5.5 Implement `PUT /hosts/:hostId/action-plans` proxy endpoint
|
||||
- Require authentication and Admin or Standard_User group
|
||||
- Validate `hostId` is a positive integer
|
||||
- Validate `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
||||
- Validate `commit_date` is present and matches `YYYY-MM-DD` format
|
||||
- Proxy validated body to Atlas API `PUT /hosts/{host_id}/action-plans`
|
||||
- Log audit entry with user, hostId, and plan type
|
||||
- _Requirements: 5.2, 5.3, 5.4, 5.5, 5.12, 5.13, 10.4_
|
||||
|
||||
- [x] 5.6 Implement `PATCH /hosts/:hostId/action-plans` proxy endpoint
|
||||
- Require authentication and Admin or Standard_User group
|
||||
- Validate `action_plan_id` is a non-empty string and `updates` is a non-null object
|
||||
- Proxy validated body to Atlas API `PATCH /hosts/{host_id}/action-plans`
|
||||
- Log audit entry with user, hostId, and plan type
|
||||
- _Requirements: 5.6, 5.7, 5.8, 5.12, 5.13, 10.5_
|
||||
|
||||
- [x] 5.7 Implement `POST /hosts/bulk-action-plans` proxy endpoint
|
||||
- Require authentication and Admin or Standard_User group
|
||||
- Validate `host_ids` is a non-empty array of positive integers, `plan_type` is valid, `commit_date` matches `YYYY-MM-DD`
|
||||
- Proxy validated body to Atlas API `POST /hosts/create-bulk-action-plans`
|
||||
- _Requirements: 5.9, 5.10, 5.11, 5.12, 10.6_
|
||||
|
||||
- [ ] 5.8 Write property test: Unique host ID extraction
|
||||
- **Property 4: Unique host ID extraction**
|
||||
- Generate arrays of finding objects with optional numeric `hostId` fields, verify extracted set has no duplicates and no nulls
|
||||
- **Validates: Requirements 3.2**
|
||||
|
||||
- [ ] 5.9 Write property test: Cache upsert derives correct plan_count and has_action_plan
|
||||
- **Property 5: Cache upsert derives correct plan_count and has_action_plan**
|
||||
- Generate (hostId, planArray) pairs, verify `plan_count` equals array length and `has_action_plan` equals 1 if non-empty, 0 otherwise
|
||||
- **Validates: Requirements 3.4**
|
||||
|
||||
- [ ] 5.10 Write property test: Sync response count invariant
|
||||
- **Property 6: Sync response count invariant**
|
||||
- Generate (totalHosts, failureCount) pairs, verify `synced + failed = totalHosts` and `withPlans <= synced`
|
||||
- **Validates: Requirements 3.6**
|
||||
|
||||
- [ ] 5.11 Write property test: Status endpoint returns all cached rows with required fields
|
||||
- **Property 7: Status endpoint returns all cached rows with required fields**
|
||||
- Generate N cache rows, insert into DB, verify response count and field presence
|
||||
- **Validates: Requirements 4.2, 4.3**
|
||||
|
||||
- [ ] 5.12 Write property test: plan_type validation
|
||||
- **Property 8: plan_type validation**
|
||||
- Generate random strings, verify acceptance if and only if string is one of the five valid types
|
||||
- **Validates: Requirements 5.3**
|
||||
|
||||
- [ ] 5.13 Write property test: commit_date validation
|
||||
- **Property 9: commit_date validation**
|
||||
- Generate random strings, verify acceptance if and only if string matches `YYYY-MM-DD` pattern
|
||||
- **Validates: Requirements 5.4**
|
||||
|
||||
- [ ] 5.14 Write property test: PATCH body validation
|
||||
- **Property 10: PATCH body validation**
|
||||
- Generate random objects with varying field presence, verify acceptance if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object
|
||||
- **Validates: Requirements 5.7**
|
||||
|
||||
- [ ] 5.15 Write property test: Bulk request validation
|
||||
- **Property 11: Bulk request validation**
|
||||
- Generate objects with varying `host_ids`, `plan_type`, `commit_date`, verify acceptance matches combined validation rules
|
||||
- **Validates: Requirements 5.10**
|
||||
|
||||
- [ ]* 5.16 Write property test: Non-2xx Atlas response passthrough
|
||||
- **Property 12: Non-2xx Atlas response passthrough**
|
||||
- Generate non-2xx status codes and body strings, verify proxy route returns same status and body
|
||||
- **Validates: Requirements 5.12**
|
||||
|
||||
- [x] 6. Checkpoint — Verify backend routes and properties
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 7. Mount Atlas router in server.js
|
||||
- Add `const createAtlasRouter = require('./routes/atlas');` import alongside existing route imports
|
||||
- Add `app.use('/api/atlas', createAtlasRouter(db, requireAuth));` mount alongside existing route mounts
|
||||
- _Requirements: 3.1, 4.1, 5.1, 5.2, 5.6, 5.9_
|
||||
|
||||
- [ ] 8. Implement frontend Atlas components
|
||||
- [x] 8.1 Create AtlasBadge component in `frontend/src/components/AtlasBadge.js`
|
||||
- Accept props: `{ hostId, atlasStatus, onClick }`
|
||||
- Render nothing if `atlasStatus` is undefined (host not in cache)
|
||||
- Render warning badge (amber border, "0" text) if `has_action_plan === 0`
|
||||
- Render success badge (emerald border, plan count text) if `plan_count > 0`
|
||||
- Use design system badge pattern: monospace font, 0.58rem, inline-flex, pill shape
|
||||
- _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6_
|
||||
|
||||
- [ ]* 8.2 Write property test: Badge visibility and content
|
||||
- **Property 13: Badge visibility and content**
|
||||
- Generate findings with `hostId` and atlas status maps, verify badge renders if and only if `hostId` exists in map, and badge text contains plan count when `plan_count > 0`
|
||||
- **Validates: Requirements 6.2, 6.5, 6.6**
|
||||
|
||||
- [x] 8.3 Create AtlasSlideOutPanel component in `frontend/src/components/AtlasSlideOutPanel.js`
|
||||
- Accept props: `{ hostId, hostName, onClose, canWrite }`
|
||||
- Fetch action plans from `GET /api/atlas/hosts/:hostId/action-plans` on open
|
||||
- Display header with hostname, host ID, and close button
|
||||
- Display each plan with: plan type, commit date, status, VNR/EXC references
|
||||
- Show create form (plan type dropdown, commit date picker, optional fields: qualys_id, active_host_findings_id, jira_vnr, archer_exc) when `canWrite` is true
|
||||
- Submit create via `PUT /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success
|
||||
- Show inline edit capability for existing plans when `canWrite` is true
|
||||
- Submit updates via `PATCH /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success
|
||||
- Display error messages from Atlas API inline in the panel
|
||||
- Close on backdrop click or close button
|
||||
- Hide create/edit forms for Viewer group (read-only mode)
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10_
|
||||
|
||||
- [ ]* 8.4 Write property test: Panel displays all plan fields
|
||||
- **Property 14: Panel displays all plan fields**
|
||||
- Generate plan objects with `plan_type`, `commit_date`, `status`, and optional reference fields, verify all non-null field values appear in rendered output
|
||||
- **Validates: Requirements 7.3**
|
||||
|
||||
- [ ] 8.5 Write unit tests for AtlasBadge and AtlasSlideOutPanel
|
||||
- Test AtlasBadge renders nothing when host not in status map
|
||||
- Test AtlasBadge renders warning style when `plan_count` is 0
|
||||
- Test AtlasBadge renders success style when `plan_count > 0`
|
||||
- Test AtlasSlideOutPanel shows create form for Admin/Standard_User
|
||||
- Test AtlasSlideOutPanel hides create form for Viewer
|
||||
- _Requirements: 6.2, 6.3, 6.4, 6.5, 7.4, 7.10_
|
||||
|
||||
- [ ] 9. Integrate Atlas badge and sync button into ReportingPage
|
||||
- [x] 9.1 Add Atlas status state and fetch to ReportingPage
|
||||
- Add `atlasStatus` state (Map keyed by hostId), `atlasSyncing` boolean, `atlasError` string
|
||||
- Fetch `GET /api/atlas/status` on mount and build the status map
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 9.2 Render AtlasBadge in Host column cells
|
||||
- In the Host column cell renderer, check `atlasStatus` map for the finding's `hostId`
|
||||
- Render AtlasBadge inline after the hostname text when a match exists
|
||||
- Wire badge `onClick` to open the AtlasSlideOutPanel with the host's ID and name
|
||||
- _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 7.1_
|
||||
|
||||
- [x] 9.3 Add Atlas sync button to ReportingPage toolbar
|
||||
- Place adjacent to existing Ivanti sync button, using same styling pattern (RefreshCw icon, monospace uppercase text, sky blue accent)
|
||||
- Differentiate with Database icon prefix and "Atlas" label
|
||||
- Enable for Admin and Standard_User groups, disable for Viewer with tooltip
|
||||
- On click: send `POST /api/atlas/sync`, show loading indicator, disable button
|
||||
- On success: re-fetch `GET /api/atlas/status` and update all badges
|
||||
- On failure: display error notification
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_
|
||||
|
||||
- [x] 9.4 Wire AtlasSlideOutPanel into ReportingPage
|
||||
- Add state for selected host (`atlasSelectedHostId`, `atlasSelectedHostName`, `atlasPanelOpen`)
|
||||
- Render AtlasSlideOutPanel conditionally when `atlasPanelOpen` is true
|
||||
- Pass `canWrite` based on user group (Admin or Standard_User)
|
||||
- On panel close: clear selected host state
|
||||
- On plan create/update success: re-fetch atlas status to update badges
|
||||
- _Requirements: 7.1, 7.2, 7.9, 7.10_
|
||||
|
||||
- [x] 10. Checkpoint — Verify full integration
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 11. Write integration tests
|
||||
- [ ]* 11.1 Write integration tests for Atlas sync flow
|
||||
- Populate Ivanti cache with test findings, trigger sync with mocked Atlas API, verify Atlas cache populated correctly
|
||||
- Test error resilience: mix of successful and failing hosts during sync
|
||||
- _Requirements: 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [ ]* 11.2 Write integration tests for Atlas proxy routes
|
||||
- Test create plan flow: send PUT, verify Atlas API called, verify audit logged
|
||||
- Test update plan flow: send PATCH, verify Atlas API called, verify audit logged
|
||||
- Test bulk create flow: send POST, verify Atlas API called with correct body
|
||||
- _Requirements: 5.1, 5.2, 5.5, 5.6, 5.8, 5.11, 5.13_
|
||||
|
||||
- [ ] 11.3 Write integration tests for access control
|
||||
- Verify 401 for unauthenticated requests on all endpoints
|
||||
- Verify 403 for Viewer group on restricted endpoints (sync, PUT, PATCH, bulk POST)
|
||||
- Verify 200 for Viewer group on read endpoints (status, GET plans)
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
|
||||
|
||||
- [x] 12. 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
|
||||
- The backend follows the existing factory function pattern (`createAtlasRouter(db, requireAuth)`)
|
||||
- The Atlas helper follows the existing `ivantiApi.js` pattern (promise-based HTTP with Node.js `https` module)
|
||||
- The migration follows the existing pattern from `add_ivanti_findings_tables.js`
|
||||
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
|
||||
1
.kiro/specs/compliance-schema-drift-check/.config.kiro
Normal file
1
.kiro/specs/compliance-schema-drift-check/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "e83a2e8f-4508-4669-9697-41219c8a7c71", "workflowType": "requirements-first", "specType": "feature"}
|
||||
364
.kiro/specs/compliance-schema-drift-check/design.md
Normal file
364
.kiro/specs/compliance-schema-drift-check/design.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Design Document: Compliance Schema Drift Check
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds schema drift detection to the compliance xlsx upload flow. When a user uploads a weekly NTS_AEO report, the backend extracts the xlsx structural schema (sheet names, column headers, metric values) and compares it against a shared parser configuration file. The comparison produces a categorised drift report with three severity levels: breaking (blocks upload), silent-miss (warns but allows proceeding), and cosmetic (informational). The frontend displays these findings in a new drift review phase inside the upload modal, inserted between the upload spinner and the existing diff preview.
|
||||
|
||||
The parser configuration dicts (`METRIC_CATEGORIES`, `CORE_COLS`, `SKIP_SHEETS`) currently defined inline in `parse_compliance_xlsx.py` are extracted into a shared JSON file (`backend/scripts/compliance_config.json`) that both the Python parser and the Node.js drift checker read. This establishes a single source of truth for parser configuration.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
1. **Shared JSON config over database storage**: The parser config is a developer-maintained mapping, not user data. A JSON file is version-controllable, diffable, and readable by both Python and Node.js without additional dependencies.
|
||||
|
||||
2. **Python subprocess for schema extraction**: The existing `dump_xlsx_schema.py` already uses openpyxl to extract xlsx structure. We adapt this into a new `extract_xlsx_schema.py` script that the Node.js backend invokes as a subprocess, consistent with how `parse_compliance_xlsx.py` is already called.
|
||||
|
||||
3. **Node.js drift comparison logic**: The drift comparison is pure object comparison (sets of strings) with no xlsx parsing. Implementing it in Node.js avoids a second Python subprocess call and keeps the logic co-located with the route handler.
|
||||
|
||||
4. **Graceful degradation**: If the drift check fails, the upload flow proceeds normally with `drift: null` and a `drift_error` message. The drift check is additive and must never block the existing workflow.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Modal as ComplianceUploadModal
|
||||
participant API as POST /api/compliance/preview
|
||||
participant Schema as extract_xlsx_schema.py
|
||||
participant Drift as driftChecker (Node.js)
|
||||
participant Config as compliance_config.json
|
||||
participant Parser as parse_compliance_xlsx.py
|
||||
|
||||
User->>Modal: Drops xlsx file
|
||||
Modal->>API: POST /preview (multipart)
|
||||
API->>Schema: spawn python3 extract_xlsx_schema.py <file>
|
||||
Schema-->>API: JSON { sheets: [...] }
|
||||
API->>Config: fs.readFileSync(compliance_config.json)
|
||||
API->>Drift: compareSchemaToDrift(schema, config)
|
||||
Drift-->>API: { breaking: [...], silent_miss: [...], cosmetic: [...] }
|
||||
API->>Parser: spawn python3 parse_compliance_xlsx.py <file>
|
||||
Parser->>Config: reads compliance_config.json
|
||||
Parser-->>API: JSON { items, summary, ... }
|
||||
API->>API: computeDiff(db, items)
|
||||
API-->>Modal: { drift, diff, tempFile, ... }
|
||||
alt drift has findings
|
||||
Modal->>User: Show drift review phase
|
||||
alt breaking findings exist
|
||||
Modal->>User: Block "Continue to Preview"
|
||||
else no breaking findings
|
||||
User->>Modal: Click "Continue to Preview"
|
||||
Modal->>User: Show diff preview
|
||||
end
|
||||
else no drift findings
|
||||
Modal->>User: Show diff preview directly
|
||||
end
|
||||
```
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
backend/
|
||||
scripts/
|
||||
compliance_config.json # NEW — shared parser config (single source of truth)
|
||||
extract_xlsx_schema.py # NEW — extracts xlsx structure as JSON
|
||||
parse_compliance_xlsx.py # MODIFIED — reads config from JSON file
|
||||
dump_xlsx_schema.py # UNCHANGED — standalone diagnostic tool
|
||||
routes/
|
||||
compliance.js # MODIFIED — drift check in /preview, new driftChecker module
|
||||
helpers/
|
||||
driftChecker.js # NEW — compareSchemaToDrift() function
|
||||
|
||||
frontend/
|
||||
src/components/pages/
|
||||
ComplianceUploadModal.js # MODIFIED — new drift-review phase
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. Shared Parser Configuration (`compliance_config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"metric_categories": {
|
||||
"2.3.4i": "Vulnerability Management",
|
||||
"2.3.6i": "Vulnerability Management",
|
||||
"5.2.4": "Access & MFA"
|
||||
},
|
||||
"core_cols": [
|
||||
"Preferred - Hostname",
|
||||
"GRANITE - IPv4_Address",
|
||||
"GRANITE - Type",
|
||||
"Team",
|
||||
"Compliant",
|
||||
"Source_Network",
|
||||
"Vertical",
|
||||
"GRANITE - Equip_Inst_ID",
|
||||
"GRANITE - RESPONSIBLE_TEAM"
|
||||
],
|
||||
"skip_sheets": ["Summary", "CMDB_9box", "Vulns", "Aging Dashboard"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Schema Extractor (`extract_xlsx_schema.py`)
|
||||
|
||||
**Input**: File path as CLI argument.
|
||||
|
||||
**Output** (stdout JSON):
|
||||
```json
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"name": "Summary",
|
||||
"columns": ["Metric", "Non-Compliant", "..."],
|
||||
"metric_values": ["2.3.4i", "5.2.4", "..."]
|
||||
},
|
||||
{
|
||||
"name": "2.3.4i",
|
||||
"columns": ["Preferred - Hostname", "GRANITE - IPv4_Address", "..."]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Uses openpyxl in read-only mode.
|
||||
- Extracts sheet names, first-row column headers per sheet, and unique metric values from the Summary sheet (header at row 4, data from row 5 onward).
|
||||
- On error, returns `{ "error": "..." }` on stdout and exits with non-zero code.
|
||||
|
||||
### 3. Drift Checker (`backend/helpers/driftChecker.js`)
|
||||
|
||||
**Function**: `compareSchemaToDrift(schema, config) => DriftReport`
|
||||
|
||||
**Parameters**:
|
||||
- `schema` — object returned by `extract_xlsx_schema.py`
|
||||
- `config` — object parsed from `compliance_config.json`
|
||||
|
||||
**Returns** (`DriftReport`):
|
||||
```javascript
|
||||
{
|
||||
breaking: [
|
||||
{ severity: 'breaking', message: 'Detail sheet "2.3.4i" is missing core column "Team"', value: 'Team', sheet: '2.3.4i' }
|
||||
],
|
||||
silent_miss: [
|
||||
{ severity: 'silent_miss', message: 'Unknown metric "9.1.2" in Summary — not in metric_categories', value: '9.1.2' }
|
||||
],
|
||||
cosmetic: [
|
||||
{ severity: 'cosmetic', message: 'New column "Extra_Field" in sheet "2.3.4i" — will be captured in extra_json', value: 'Extra_Field', sheet: '2.3.4i' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Drift rules**:
|
||||
|
||||
| Rule | Severity | Condition |
|
||||
|---|---|---|
|
||||
| Missing core column | `breaking` | A detail sheet (not in `skip_sheets`, present in xlsx) is missing a column from `core_cols` |
|
||||
| Missing detail sheet | `breaking` | A sheet name in `metric_categories` (and not in `skip_sheets`) is absent from the xlsx |
|
||||
| Unknown metric value | `silent_miss` | A metric value in the Summary sheet is not a key in `metric_categories` |
|
||||
| Unknown sheet | `silent_miss` | An xlsx sheet is not in `skip_sheets` and not in `metric_categories` |
|
||||
| New column in detail sheet | `cosmetic` | A detail sheet has columns not in `core_cols` |
|
||||
| Stale metric category | `cosmetic` | A key in `metric_categories` does not appear in the Summary sheet's metric values |
|
||||
|
||||
### 4. Preview Endpoint Changes (`POST /api/compliance/preview`)
|
||||
|
||||
The existing `/preview` handler is modified to:
|
||||
|
||||
1. After receiving the uploaded file, spawn `extract_xlsx_schema.py` to get the xlsx schema.
|
||||
2. Read `compliance_config.json` from disk.
|
||||
3. Call `compareSchemaToDrift(schema, config)` to produce the drift report.
|
||||
4. Proceed with the existing `parseXlsx()` call and `computeDiff()`.
|
||||
5. Include `drift` (the DriftReport object) and optionally `drift_error` (string) in the response.
|
||||
|
||||
If the schema extraction or drift check throws, set `drift: null` and `drift_error: <message>`, then continue with the normal flow.
|
||||
|
||||
**Updated response shape**:
|
||||
```json
|
||||
{
|
||||
"drift": {
|
||||
"breaking": [],
|
||||
"silent_miss": [],
|
||||
"cosmetic": []
|
||||
},
|
||||
"drift_error": null,
|
||||
"diff": { "new_count": 5, "recurring_count": 120, "resolved_count": 3 },
|
||||
"tempFile": "/path/to/temp.json",
|
||||
"filename": "NTS_AEO_2026_03_25.xlsx",
|
||||
"report_date": "2026-03-25",
|
||||
"total_items": 125
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Upload Modal Changes (`ComplianceUploadModal.js`)
|
||||
|
||||
**New phase**: `drift-review` inserted between `uploading` and `preview`.
|
||||
|
||||
**Phase flow**:
|
||||
```
|
||||
idle → uploading → drift-review (if findings) → preview → committing → done
|
||||
→ preview (if no findings)
|
||||
```
|
||||
|
||||
**Drift review UI**:
|
||||
- Findings grouped by severity: breaking first, then silent-miss, then cosmetic.
|
||||
- Each group has a header with severity label and count badge.
|
||||
- Groups with more than 5 findings collapse with a "Show N more" toggle.
|
||||
- Each finding shows the message text and the triggering value.
|
||||
- Breaking findings: red text (`#EF4444`), red left-border accent.
|
||||
- Silent-miss findings: amber text (`#F59E0B`), amber left-border accent.
|
||||
- Cosmetic findings: muted text (`#94A3B8`), subtle left-border accent.
|
||||
- "Cancel" button returns to idle. "Continue to Preview" button advances to diff preview.
|
||||
- "Continue to Preview" is disabled when breaking findings exist, with a message explaining the block.
|
||||
- When `drift` is `null` (drift check failed), skip drift-review and go straight to preview.
|
||||
|
||||
## Data Models
|
||||
|
||||
### DriftFinding
|
||||
|
||||
```javascript
|
||||
{
|
||||
severity: 'breaking' | 'silent_miss' | 'cosmetic',
|
||||
message: string, // Human-readable description
|
||||
value: string, // The specific column/sheet/metric that triggered the finding
|
||||
sheet: string|null // Sheet name context (when applicable)
|
||||
}
|
||||
```
|
||||
|
||||
### DriftReport
|
||||
|
||||
```javascript
|
||||
{
|
||||
breaking: DriftFinding[],
|
||||
silent_miss: DriftFinding[],
|
||||
cosmetic: DriftFinding[]
|
||||
}
|
||||
```
|
||||
|
||||
### ParserConfig
|
||||
|
||||
```javascript
|
||||
{
|
||||
metric_categories: { [metricId: string]: string }, // metric ID → category name
|
||||
core_cols: string[], // column names for main item fields
|
||||
skip_sheets: string[] // sheet names excluded from parsing
|
||||
}
|
||||
```
|
||||
|
||||
### XlsxSchema (output of extract_xlsx_schema.py)
|
||||
|
||||
```javascript
|
||||
{
|
||||
sheets: [
|
||||
{
|
||||
name: string,
|
||||
columns: string[],
|
||||
metric_values?: string[] // only present on Summary sheet
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 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: Breaking drift completeness
|
||||
|
||||
*For any* xlsx schema and parser config, the drift checker SHALL produce a breaking finding for every core column missing from every detail sheet, and for every detail sheet (present in `metric_categories` but not in `skip_sheets`) absent from the xlsx — and no other breaking findings. The set of breaking findings is exactly the union of missing-core-column findings and missing-detail-sheet findings.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3**
|
||||
|
||||
### Property 2: Silent-miss drift completeness
|
||||
|
||||
*For any* xlsx schema and parser config, the drift checker SHALL produce a silent-miss finding for every metric value in the Summary sheet not present in `metric_categories`, and for every xlsx sheet not in `skip_sheets` and not in `metric_categories` — and no other silent-miss findings. The set of silent-miss findings is exactly the union of unknown-metric findings and unknown-sheet findings.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2, 4.3**
|
||||
|
||||
### Property 3: Cosmetic drift completeness
|
||||
|
||||
*For any* xlsx schema and parser config, the drift checker SHALL produce a cosmetic finding for every column in a detail sheet not present in `core_cols`, and for every key in `metric_categories` not present in the Summary sheet's metric values — and no other cosmetic findings. The set of cosmetic findings is exactly the union of new-column findings and stale-metric findings.
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
### Property 4: Drift severity ordering
|
||||
|
||||
*For any* drift report containing a mix of breaking, silent-miss, and cosmetic findings, the grouping function SHALL always return findings ordered by severity: all breaking findings first, then all silent-miss findings, then all cosmetic findings.
|
||||
|
||||
**Validates: Requirements 8.1**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Python Script Failures
|
||||
|
||||
| Failure | Handling |
|
||||
|---|---|
|
||||
| `extract_xlsx_schema.py` exits non-zero | Preview endpoint sets `drift: null`, `drift_error: <stderr message>`, continues with normal parse flow |
|
||||
| `extract_xlsx_schema.py` returns invalid JSON | Same as above — caught in JSON.parse, treated as drift check failure |
|
||||
| `compliance_config.json` missing or invalid (Node.js read) | Preview endpoint returns 500 with message "Configuration file could not be loaded" |
|
||||
| `compliance_config.json` missing or invalid (Python parser read) | Parser exits non-zero, stderr describes the error, preview endpoint returns 500 with parse error |
|
||||
| xlsx file cannot be opened by schema extractor | Schema extractor returns `{ "error": "..." }` on stdout, exits non-zero; drift check skipped gracefully |
|
||||
|
||||
### Frontend Error States
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| `drift` is `null` in preview response | Skip drift-review phase, proceed directly to diff preview |
|
||||
| `drift_error` is present | Optionally display a subtle warning in the diff preview that drift check was skipped |
|
||||
| Network error during upload | Existing error phase handling (unchanged) |
|
||||
|
||||
### Config File Validation
|
||||
|
||||
The Node.js config loader validates that:
|
||||
- The file exists and is readable.
|
||||
- The content parses as valid JSON.
|
||||
- The parsed object contains `metric_categories` (object), `core_cols` (array), and `skip_sheets` (array).
|
||||
|
||||
If any check fails, the loader throws with a descriptive message. The preview handler catches this and returns a 500 response.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Drift checker (`driftChecker.js`)**:
|
||||
- Breaking: missing core column produces finding with correct severity, message, value, and sheet.
|
||||
- Breaking: missing detail sheet produces finding.
|
||||
- Silent-miss: unknown metric value produces finding.
|
||||
- Silent-miss: unknown sheet produces finding.
|
||||
- Cosmetic: new column in detail sheet produces finding.
|
||||
- Cosmetic: stale metric category produces finding.
|
||||
- Empty schema (no sheets) produces appropriate findings.
|
||||
- Config with empty metric_categories, core_cols, or skip_sheets.
|
||||
- Schema and config that are perfectly aligned produce zero findings.
|
||||
|
||||
**Config loader**:
|
||||
- Valid config file loads correctly.
|
||||
- Missing file throws descriptive error.
|
||||
- Invalid JSON throws descriptive error.
|
||||
- Config missing required keys throws descriptive error.
|
||||
|
||||
**Frontend drift review component**:
|
||||
- Drift review phase renders when findings exist.
|
||||
- "Continue to Preview" button disabled when breaking findings present.
|
||||
- "Continue to Preview" button enabled when no breaking findings.
|
||||
- Groups collapse at 5+ findings with correct "Show N more" count.
|
||||
- Cancel returns to idle phase.
|
||||
- Skips drift review when drift is null or has no findings.
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests use `fast-check` (JavaScript) to verify the four correctness properties defined above. Each test generates random schema and config objects and verifies the drift checker output against the expected set-theoretic result.
|
||||
|
||||
**Configuration**:
|
||||
- Minimum 100 iterations per property test.
|
||||
- Each test tagged with: **Feature: compliance-schema-drift-check, Property {N}: {title}**
|
||||
|
||||
**Generators**:
|
||||
- `arbitraryParserConfig`: generates random `metric_categories` (object with 0–20 string keys mapped to category strings), `core_cols` (array of 0–15 unique column name strings), `skip_sheets` (array of 0–5 unique sheet name strings).
|
||||
- `arbitraryXlsxSchema`: generates random sheets array, each with a name, columns array, and optionally metric_values (for the Summary sheet). Sheet names, column names, and metric values drawn from a shared pool to ensure meaningful overlap with the config.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Preview endpoint returns drift report alongside existing diff data.
|
||||
- Preview endpoint returns 200 with breaking drift (does not error).
|
||||
- Preview endpoint gracefully degrades when drift check fails (`drift: null`, `drift_error` present).
|
||||
- Preview endpoint returns 500 when config file is missing.
|
||||
- Python parser reads from `compliance_config.json` and produces same output as before.
|
||||
- Commit endpoint is unchanged and does not reference drift.
|
||||
128
.kiro/specs/compliance-schema-drift-check/requirements.md
Normal file
128
.kiro/specs/compliance-schema-drift-check/requirements.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The compliance upload flow in the STEAM Security Dashboard parses weekly NTS_AEO xlsx reports using a Python parser (`parse_compliance_xlsx.py`) that relies on three hand-maintained configuration dicts: `METRIC_CATEGORIES` (metric ID to category mapping), `CORE_COLS` (column names that become main item fields), and `SKIP_SHEETS` (sheet names excluded from parsing). When the xlsx report structure changes — new metrics appear, sheets are renamed, columns are added or removed — the parser silently miscategorises data, drops fields, or fails outright. Currently, detecting this drift requires a separate manual agent workflow.
|
||||
|
||||
This feature builds schema drift detection directly into the upload flow. During the preview step, the backend extracts the xlsx structure and compares it against the parser configuration. The frontend displays categorised drift findings (breaking, silent-miss, cosmetic) in the upload modal before the user sees the diff preview. Breaking findings block the upload; silent-miss findings warn but allow proceeding; cosmetic findings are informational. The parser configuration dicts are extracted into a shared JSON config file that both the Python parser and the Node.js backend can read, establishing a single source of truth.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Drift_Checker**: The backend module that compares an xlsx file's structural schema against the Parser_Config and produces a categorised Drift_Report.
|
||||
- **Parser_Config**: A shared JSON configuration file (`backend/scripts/compliance_config.json`) containing `metric_categories`, `core_cols`, and `skip_sheets`. This file is the single source of truth read by both the Python parser and the Node.js backend.
|
||||
- **Drift_Report**: A structured object returned by the Drift_Checker containing arrays of findings grouped by severity: `breaking`, `silent_miss`, and `cosmetic`.
|
||||
- **Drift_Finding**: A single entry in the Drift_Report, containing a severity level, a human-readable message, and the specific value that triggered the finding (e.g., a column name, sheet name, or metric ID).
|
||||
- **Breaking_Finding**: A Drift_Finding indicating the xlsx structure will cause parse errors or data loss. Examples: a core column missing from a detail sheet, a previously existing sheet removed or renamed.
|
||||
- **Silent_Miss_Finding**: A Drift_Finding indicating data exists in the xlsx but will be dropped or miscategorised by the parser. Examples: a new metric value in the Summary sheet not present in `metric_categories`, a new sheet not in `skip_sheets` and not in `metric_categories`.
|
||||
- **Cosmetic_Finding**: A Drift_Finding indicating a minor discrepancy worth noting but not blocking. Examples: new columns in known sheets (automatically captured in `extra_json`), stale entries in `metric_categories` that no longer appear in the xlsx.
|
||||
- **Upload_Modal**: The `ComplianceUploadModal.js` component that manages the file upload flow through phases: idle, uploading, drift-review, preview, committing, done, and error.
|
||||
- **Preview_Endpoint**: The `POST /api/compliance/preview` endpoint that parses the uploaded xlsx, runs the drift check, computes the diff, and returns both the Drift_Report and diff counts.
|
||||
- **Schema_Extractor**: The logic (adapted from `dump_xlsx_schema.py`) that reads an xlsx file using openpyxl and extracts sheet names, column headers per sheet, and metric values from the Summary sheet.
|
||||
- **Detail_Sheet**: Any sheet in the xlsx that is not in the `skip_sheets` set and is parsed for non-compliant item rows.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Shared Parser Configuration File
|
||||
|
||||
**User Story:** As a developer, I want the parser configuration dicts extracted into a shared JSON file, so that both the Python parser and the Node.js backend read from a single source of truth.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Parser_Config SHALL be stored at `backend/scripts/compliance_config.json` as a JSON file containing three keys: `metric_categories` (object mapping metric ID strings to category name strings), `core_cols` (array of column name strings), and `skip_sheets` (array of sheet name strings).
|
||||
2. THE Parser_Config SHALL contain the same values currently defined inline in `METRIC_CATEGORIES`, `CORE_COLS`, and `SKIP_SHEETS` in `parse_compliance_xlsx.py`.
|
||||
3. WHEN the Python parser starts, THE Python parser SHALL read `metric_categories`, `core_cols`, and `skip_sheets` from the Parser_Config file instead of using inline dict definitions.
|
||||
4. IF the Parser_Config file is missing or contains invalid JSON, THEN THE Python parser SHALL exit with a non-zero exit code and print a descriptive error message to stderr.
|
||||
5. WHEN the Node.js backend handles a preview request, THE Drift_Checker SHALL read the Parser_Config file to obtain the current metric categories, core columns, and skip sheets.
|
||||
6. IF the Parser_Config file is missing or contains invalid JSON when the Node.js backend reads it, THEN THE Preview_Endpoint SHALL return a 500 error with a message indicating the configuration file could not be loaded.
|
||||
|
||||
### Requirement 2: Schema Extraction from Uploaded xlsx
|
||||
|
||||
**User Story:** As a developer, I want the backend to extract the structural schema from an uploaded xlsx file, so that the drift checker can compare it against the parser configuration.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN an xlsx file is uploaded to the Preview_Endpoint, THE Schema_Extractor SHALL extract the list of sheet names, the column headers from the first row of each sheet, and the unique metric values from the Summary sheet's Metric column (header at row 4, data from row 5 onward).
|
||||
2. THE Schema_Extractor SHALL use openpyxl in read-only mode to extract the xlsx structure, reusing the approach from `dump_xlsx_schema.py`.
|
||||
3. THE Schema_Extractor SHALL run as a Python subprocess invoked by the Node.js backend, returning the extracted schema as JSON on stdout.
|
||||
4. IF the xlsx file cannot be opened or contains no sheets, THEN THE Schema_Extractor SHALL return a JSON error object on stdout and exit with a non-zero exit code.
|
||||
|
||||
### Requirement 3: Drift Detection — Breaking Findings
|
||||
|
||||
**User Story:** As a compliance analyst, I want the system to detect structural changes that will cause parse failures or data loss, so that I do not upload a report that produces corrupt data.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a Detail_Sheet is missing one or more columns listed in `core_cols` of the Parser_Config, THE Drift_Checker SHALL produce a Breaking_Finding for each missing column, identifying the sheet name and column name.
|
||||
2. WHEN a sheet name that previously existed as a Detail_Sheet (present in `metric_categories` but not in `skip_sheets`) is absent from the uploaded xlsx, THE Drift_Checker SHALL produce a Breaking_Finding identifying the missing sheet name.
|
||||
3. THE Drift_Checker SHALL classify all Breaking_Findings with severity `"breaking"`.
|
||||
|
||||
### Requirement 4: Drift Detection — Silent-Miss Findings
|
||||
|
||||
**User Story:** As a compliance analyst, I want the system to detect when new data in the xlsx will be silently miscategorised or dropped, so that I can update the parser configuration before proceeding.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Summary sheet contains metric values not present as keys in `metric_categories` of the Parser_Config, THE Drift_Checker SHALL produce a Silent_Miss_Finding for each unknown metric value.
|
||||
2. WHEN the xlsx contains sheets that are not in `skip_sheets` and whose names do not appear as keys in `metric_categories`, THE Drift_Checker SHALL produce a Silent_Miss_Finding for each unknown sheet, indicating it will be parsed with an 'Other' category.
|
||||
3. THE Drift_Checker SHALL classify all Silent_Miss_Findings with severity `"silent_miss"`.
|
||||
|
||||
### Requirement 5: Drift Detection — Cosmetic Findings
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see informational notes about minor schema differences, so that I have full visibility into how the xlsx structure has evolved.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a Detail_Sheet contains columns not present in `core_cols` of the Parser_Config, THE Drift_Checker SHALL produce a Cosmetic_Finding for each new column, noting that the column data will be captured in `extra_json`.
|
||||
2. WHEN `metric_categories` in the Parser_Config contains metric IDs that do not appear in the Summary sheet's metric values, THE Drift_Checker SHALL produce a Cosmetic_Finding for each stale metric ID.
|
||||
3. THE Drift_Checker SHALL classify all Cosmetic_Findings with severity `"cosmetic"`.
|
||||
|
||||
### Requirement 6: Preview Endpoint Drift Integration
|
||||
|
||||
**User Story:** As a developer, I want the preview endpoint to include the drift report in its response, so that the frontend can display drift findings before showing the diff preview.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Preview_Endpoint processes an uploaded xlsx file, THE Preview_Endpoint SHALL run the Schema_Extractor and Drift_Checker before running the existing parser and diff computation.
|
||||
2. THE Preview_Endpoint SHALL include a `drift` field in the JSON response containing the Drift_Report with `breaking`, `silent_miss`, and `cosmetic` arrays.
|
||||
3. WHEN the drift check produces Breaking_Findings, THE Preview_Endpoint SHALL still return a 200 response with the Drift_Report, allowing the frontend to display the findings and block the commit.
|
||||
4. IF the Schema_Extractor or Drift_Checker fails unexpectedly, THEN THE Preview_Endpoint SHALL proceed with the normal parse and diff flow, returning a `drift` field set to `null` and a `drift_error` field with a descriptive message, so that the upload flow is not blocked by drift check failures.
|
||||
|
||||
### Requirement 7: Upload Modal Drift Review Phase
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see drift findings in the upload modal after file upload and before the diff preview, so that I can assess schema compatibility before deciding to proceed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Preview_Endpoint returns a Drift_Report with one or more findings, THE Upload_Modal SHALL display a drift review phase between the uploading spinner and the diff preview.
|
||||
2. THE Upload_Modal SHALL display Breaking_Findings with red text and a red left-border accent, using the dashboard danger color (`#EF4444`).
|
||||
3. THE Upload_Modal SHALL display Silent_Miss_Findings with amber text and an amber left-border accent, using the dashboard warning color (`#F59E0B`).
|
||||
4. THE Upload_Modal SHALL display Cosmetic_Findings with muted text and a subtle left-border accent, using the dashboard muted text color (`#94A3B8`).
|
||||
5. WHEN the Drift_Report contains one or more Breaking_Findings, THE Upload_Modal SHALL disable the "Continue to Preview" button and display a message indicating the upload is blocked until the parser configuration is updated.
|
||||
6. WHEN the Drift_Report contains Silent_Miss_Findings but no Breaking_Findings, THE Upload_Modal SHALL enable the "Continue to Preview" button and display a warning message advising the user to review the findings.
|
||||
7. WHEN the Drift_Report contains only Cosmetic_Findings, THE Upload_Modal SHALL enable the "Continue to Preview" button without a warning message.
|
||||
8. WHEN the Drift_Report contains no findings, THE Upload_Modal SHALL skip the drift review phase and proceed directly to the diff preview.
|
||||
|
||||
### Requirement 8: Drift Review UI Layout and Interaction
|
||||
|
||||
**User Story:** As a compliance analyst, I want the drift findings to be clearly organised and scannable, so that I can quickly understand what changed in the xlsx structure.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Upload_Modal SHALL group drift findings by severity, displaying Breaking_Findings first, then Silent_Miss_Findings, then Cosmetic_Findings.
|
||||
2. THE Upload_Modal SHALL display a count badge next to each severity group header showing the number of findings in that group.
|
||||
3. WHEN a severity group contains more than five findings, THE Upload_Modal SHALL collapse the group to show the first five findings with an expandable "Show N more" toggle.
|
||||
4. EACH Drift_Finding displayed in the Upload_Modal SHALL include the finding message and the specific value (column name, sheet name, or metric ID) that triggered the finding.
|
||||
5. THE Upload_Modal SHALL display a "Cancel" button that returns the modal to the idle phase, and a "Continue to Preview" button (when enabled) that advances to the diff preview phase.
|
||||
6. THE Upload_Modal drift review phase SHALL follow the dashboard's dark theme and monospace typography conventions defined in `DESIGN_SYSTEM.md`.
|
||||
|
||||
### Requirement 9: Existing Upload Flow Preservation
|
||||
|
||||
**User Story:** As a compliance analyst, I want the existing upload flow to remain intact, so that the drift check is an additive enhancement and does not disrupt the current preview-then-commit workflow.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user clicks "Continue to Preview" from the drift review phase, THE Upload_Modal SHALL display the same diff preview (recurring, new, resolved counts) and "Confirm Upload" button as the current implementation.
|
||||
2. THE Preview_Endpoint SHALL continue to return `diff`, `tempFile`, `filename`, `report_date`, and `total_items` fields in the response alongside the new `drift` field.
|
||||
3. THE commit flow (`POST /api/compliance/commit`) SHALL remain unchanged and SHALL NOT perform any drift checking.
|
||||
4. WHEN the `drift` field in the preview response is `null` (drift check failed or was skipped), THE Upload_Modal SHALL proceed directly to the diff preview phase as if no drift was detected.
|
||||
|
||||
154
.kiro/specs/compliance-schema-drift-check/tasks.md
Normal file
154
.kiro/specs/compliance-schema-drift-check/tasks.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Implementation Plan: Compliance Schema Drift Check
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements schema drift detection in the compliance upload flow. The work proceeds in layers: first extract the shared config file, then build the Python schema extractor, then the Node.js drift checker, then wire it into the preview endpoint, and finally update the upload modal with the drift-review phase. Property-based tests validate the drift checker's correctness properties using fast-check.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Create shared parser configuration file and update Python parser
|
||||
- [x] 1.1 Create `backend/scripts/compliance_config.json` with `metric_categories`, `core_cols`, and `skip_sheets`
|
||||
- Extract the exact values from the inline dicts `METRIC_CATEGORIES`, `CORE_COLS`, and `SKIP_SHEETS` in `parse_compliance_xlsx.py`
|
||||
- `metric_categories` is an object mapping metric ID strings to category strings
|
||||
- `core_cols` is an array of column name strings
|
||||
- `skip_sheets` is an array of sheet name strings
|
||||
- _Requirements: 1.1, 1.2_
|
||||
|
||||
- [x] 1.2 Modify `backend/scripts/parse_compliance_xlsx.py` to read config from JSON file
|
||||
- Remove the inline `METRIC_CATEGORIES`, `CORE_COLS`, and `SKIP_SHEETS` definitions
|
||||
- Load them from `compliance_config.json` (resolved relative to the script's directory)
|
||||
- If the config file is missing or contains invalid JSON, print a descriptive error to stderr and exit with non-zero code
|
||||
- Ensure `CORE_COLS` is converted to a set after loading from the JSON array
|
||||
- _Requirements: 1.3, 1.4_
|
||||
|
||||
- [ ]* 1.3 Write unit tests for Python parser config loading
|
||||
- Test that parser loads config correctly and produces same output as before
|
||||
- Test that missing config file causes non-zero exit with descriptive stderr
|
||||
- Test that invalid JSON in config file causes non-zero exit with descriptive stderr
|
||||
- _Requirements: 1.3, 1.4_
|
||||
|
||||
- [x] 2. Create Python schema extractor script
|
||||
- [x] 2.1 Create `backend/scripts/extract_xlsx_schema.py`
|
||||
- Accept file path as CLI argument
|
||||
- Use openpyxl in read-only mode to extract: sheet names, first-row column headers per sheet, and unique metric values from the Summary sheet (header at row 4, data from row 5 onward)
|
||||
- Output JSON to stdout with shape `{ "sheets": [{ "name", "columns", "metric_values?" }] }`
|
||||
- On error, return `{ "error": "..." }` on stdout and exit with non-zero code
|
||||
- Reuse the approach from `dump_xlsx_schema.py` for Summary sheet metric extraction
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [ ]* 2.2 Write unit tests for schema extractor
|
||||
- Test that valid xlsx produces correct schema JSON
|
||||
- Test that missing file returns error JSON and non-zero exit
|
||||
- Test that file with no sheets returns error JSON
|
||||
- _Requirements: 2.1, 2.4_
|
||||
|
||||
- [x] 3. Implement Node.js drift checker module
|
||||
- [x] 3.1 Create `backend/helpers/driftChecker.js` with `compareSchemaToDrift(schema, config)` function
|
||||
- Implement breaking rules: missing core column in detail sheets, missing detail sheet (in `metric_categories` but not `skip_sheets` and absent from xlsx)
|
||||
- Implement silent-miss rules: unknown metric value in Summary not in `metric_categories`, unknown sheet not in `skip_sheets` and not in `metric_categories`
|
||||
- Implement cosmetic rules: new column in detail sheet not in `core_cols`, stale metric in `metric_categories` not in Summary metric values
|
||||
- Each finding has shape `{ severity, message, value, sheet }` (sheet is null when not applicable)
|
||||
- Return `{ breaking: [], silent_miss: [], cosmetic: [] }`
|
||||
- Export `compareSchemaToDrift` and a `loadConfig(configPath)` function that reads and validates `compliance_config.json`
|
||||
- Config loader validates: file exists, parses as JSON, contains `metric_categories` (object), `core_cols` (array), `skip_sheets` (array)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 4.1, 4.2, 4.3, 5.1, 5.2, 5.3, 1.5, 1.6_
|
||||
|
||||
- [ ] 3.2 Write property test: Breaking drift completeness (Property 1)
|
||||
- **Property 1: Breaking drift completeness**
|
||||
- For any generated schema and config, the set of breaking findings equals exactly the union of missing-core-column findings and missing-detail-sheet findings — no more, no fewer
|
||||
- Use fast-check with arbitrary generators for schema and config objects
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3**
|
||||
|
||||
- [ ]* 3.3 Write property test: Silent-miss drift completeness (Property 2)
|
||||
- **Property 2: Silent-miss drift completeness**
|
||||
- For any generated schema and config, the set of silent-miss findings equals exactly the union of unknown-metric findings and unknown-sheet findings
|
||||
- Use fast-check with arbitrary generators for schema and config objects
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 4.1, 4.2, 4.3**
|
||||
|
||||
- [ ]* 3.4 Write property test: Cosmetic drift completeness (Property 3)
|
||||
- **Property 3: Cosmetic drift completeness**
|
||||
- For any generated schema and config, the set of cosmetic findings equals exactly the union of new-column findings and stale-metric findings
|
||||
- Use fast-check with arbitrary generators for schema and config objects
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
- [ ]* 3.5 Write property test: Drift severity ordering (Property 4)
|
||||
- **Property 4: Drift severity ordering**
|
||||
- For any drift report, the grouped output always returns all breaking findings first, then all silent-miss, then all cosmetic
|
||||
- Use fast-check to generate mixed drift reports and verify ordering
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 8.1**
|
||||
|
||||
- [ ]* 3.6 Write unit tests for drift checker and config loader
|
||||
- Test each drift rule individually with hand-crafted schema/config pairs
|
||||
- Test config loader with valid file, missing file, invalid JSON, and missing required keys
|
||||
- Test that perfectly aligned schema and config produce zero findings
|
||||
- Test edge cases: empty metric_categories, empty core_cols, empty skip_sheets
|
||||
- _Requirements: 3.1, 3.2, 3.3, 4.1, 4.2, 4.3, 5.1, 5.2, 5.3, 1.5, 1.6_
|
||||
|
||||
- [x] 4. Checkpoint — Verify backend modules
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Integrate drift check into preview endpoint
|
||||
- [x] 5.1 Modify `backend/routes/compliance.js` to add drift checking in `POST /preview`
|
||||
- After receiving the uploaded file, spawn `extract_xlsx_schema.py` as a Python subprocess to get the xlsx schema
|
||||
- Read `compliance_config.json` using the `loadConfig()` function from `driftChecker.js`
|
||||
- Call `compareSchemaToDrift(schema, config)` to produce the drift report
|
||||
- Proceed with the existing `parseXlsx()` call and `computeDiff()`
|
||||
- Include `drift` (DriftReport object) and `drift_error` (string or null) in the response
|
||||
- If schema extraction or drift check throws, set `drift: null` and `drift_error: <message>`, then continue with normal flow
|
||||
- If config file is missing or invalid, return 500 with descriptive message
|
||||
- Preserve all existing response fields: `diff`, `tempFile`, `filename`, `report_date`, `total_items`
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 9.2_
|
||||
|
||||
- [ ]* 5.2 Write integration tests for preview endpoint drift behavior
|
||||
- Test that preview response includes `drift` field alongside existing `diff` data
|
||||
- Test that breaking drift still returns 200 (not an error)
|
||||
- Test graceful degradation when drift check fails (`drift: null`, `drift_error` present)
|
||||
- Test 500 response when config file is missing
|
||||
- Test that commit endpoint is unchanged and does not reference drift
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 9.3_
|
||||
|
||||
- [x] 6. Update upload modal with drift-review phase
|
||||
- [x] 6.1 Modify `frontend/src/components/pages/ComplianceUploadModal.js` to add drift-review phase
|
||||
- Add `drift-review` phase between `uploading` and `preview` in the phase flow
|
||||
- After upload response, check if `drift` is non-null and has findings — if so, enter `drift-review`; otherwise skip to `preview`
|
||||
- When `drift` is `null` (drift check failed), skip drift-review and go straight to preview
|
||||
- Display findings grouped by severity: breaking first, then silent-miss, then cosmetic
|
||||
- Each severity group has a header with label and count badge
|
||||
- Groups with more than 5 findings collapse with a "Show N more" toggle
|
||||
- Each finding shows the message and the triggering value
|
||||
- Breaking findings: red text (`#EF4444`), red left-border accent
|
||||
- Silent-miss findings: amber text (`#F59E0B`), amber left-border accent
|
||||
- Cosmetic findings: muted text (`#94A3B8`), subtle left-border accent
|
||||
- "Cancel" button returns to idle phase; "Continue to Preview" button advances to diff preview
|
||||
- "Continue to Preview" disabled when breaking findings exist, with a message explaining the block
|
||||
- When no breaking findings but silent-miss exist, show warning message and enable "Continue to Preview"
|
||||
- When only cosmetic findings, enable "Continue to Preview" without warning
|
||||
- Follow dashboard dark theme and monospace typography from `DESIGN_SYSTEM.md`
|
||||
- Preserve existing diff preview, commit flow, done, and error phases unchanged
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 9.1, 9.4_
|
||||
|
||||
- [ ]* 6.2 Write unit tests for upload modal drift-review phase
|
||||
- Test drift-review phase renders when findings exist
|
||||
- Test "Continue to Preview" button disabled when breaking findings present
|
||||
- Test "Continue to Preview" button enabled when no breaking findings
|
||||
- Test groups collapse at 5+ findings with correct "Show N more" count
|
||||
- Test cancel returns to idle phase
|
||||
- Test skips drift-review when drift is null or has no findings
|
||||
- _Requirements: 7.1, 7.5, 7.6, 7.7, 7.8, 8.3_
|
||||
|
||||
- [x] 7. 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 (3.2–3.5) validate the four correctness properties from the design using fast-check
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The Python parser modification (1.2) must produce identical output to the current inline-dict version — this is a refactor, not a behavior change
|
||||
- The commit endpoint (`POST /api/compliance/commit`) is intentionally unchanged
|
||||
190
.kiro/steering/doc-standards.md
Normal file
190
.kiro/steering/doc-standards.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Documentation Standards — CVE Dashboard
|
||||
|
||||
These standards are reverse-engineered from the existing README.md and should be treated as the canonical style guide for all documentation in this repository. When updating docs, match these conventions exactly — consistency matters more than any individual stylistic preference.
|
||||
|
||||
---
|
||||
|
||||
## Language and Style
|
||||
|
||||
- Write in **present tense, active voice**. "The sync fetches findings" — not "Findings will be fetched by the sync."
|
||||
- Do not use marketing language. No "powerful", "seamless", "robust", "cutting-edge", "unleash". Describe what the thing does, not how great it is.
|
||||
- No emoji anywhere in documentation. None.
|
||||
- Prefer precise technical vocabulary over casual phrasing. "Session tokens are stored in `httpOnly` cookies" — not "we keep the login thing in a secure cookie."
|
||||
- **Spelling:** match whatever spelling variant already appears in the surrounding prose. Do not rewrite existing words to switch between US and British English — consistency within a section matters more than which variant is used.
|
||||
|
||||
---
|
||||
|
||||
## Punctuation and Formatting
|
||||
|
||||
- **Em-dashes (—)** for mid-sentence clarifications or appositives, surrounded by spaces: `sync requires Admin or Standard_User group — the sync then fetches findings`.
|
||||
- **En-dashes (–)** for numeric ranges: `8.5–9.9 VRR`, `20 attempts per 15-minute window`.
|
||||
- Use **inline backticks** for: file paths, environment variables, CLI flags, function names, field names, HTTP status codes, and any literal value the reader should type or recognise verbatim.
|
||||
- Use **bold** (`**text**`) for UI element names (button labels, tab names, form fields) and for label-style emphasis at the start of a sub-point.
|
||||
- Avoid italics except for rare genuine emphasis. Do not use italics decoratively.
|
||||
|
||||
---
|
||||
|
||||
## Heading Hierarchy
|
||||
|
||||
Use a strict three-tier hierarchy per document:
|
||||
|
||||
```
|
||||
# Document Title (one per file, matches repo/feature name)
|
||||
## Top-Level Section (Overview, Features, Architecture, etc.)
|
||||
### Subsection (a named feature, page, or concept)
|
||||
**Sub-subsection label** (bold paragraph labels — NOT an #### heading)
|
||||
```
|
||||
|
||||
- Do **not** use `####` or deeper headings. If you need a fourth level, it should be a bold inline label followed by content, matching the existing README pattern.
|
||||
- Separate top-level `##` sections with a horizontal rule (`---`) on its own line, with blank lines above and below.
|
||||
- Do not place horizontal rules between `###` subsections unless visually necessary.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Any document over ~200 lines should open with a Table of Contents using markdown anchor links.
|
||||
- Nest TOC entries to match heading depth (`##` entries flush-left, `###` entries indented two spaces).
|
||||
- Anchor slugs: lowercase, spaces become hyphens, em-dashes become `--` (two hyphens). Example: `## Home — CVE Management` becomes `#home--cve-management`.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
Use tables for any reference material with two or more parallel attributes. This includes:
|
||||
|
||||
- Permission matrices (group → capabilities)
|
||||
- Tech stack listings (layer → technology)
|
||||
- Column descriptions (column → meaning)
|
||||
- Environment variable references (name → purpose → default)
|
||||
- Route references (method + path → description → auth requirement)
|
||||
|
||||
Table format:
|
||||
|
||||
```markdown
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| `field_name` | What it does |
|
||||
```
|
||||
|
||||
- Always use `---` separators (not `:---:` centred alignment) unless centring is specifically warranted.
|
||||
- Keep cell content on a single line. If a value is long, prefer prose above or below the table.
|
||||
- Use inline backticks for literal values inside cells.
|
||||
|
||||
---
|
||||
|
||||
## Code Blocks
|
||||
|
||||
- **Always include a language tag** on fenced code blocks: ` ```bash `, ` ```javascript `, ` ```json `, ` ```python `, ` ```sql `. Bare ` ``` ` is not acceptable.
|
||||
- Shell commands use `bash` — even for single-line commands.
|
||||
- Place code blocks on their own line, with blank lines before and after.
|
||||
- Inside code blocks, do not use markdown formatting (no bold, no italics). Treat them as literal terminal/file content.
|
||||
- Prefer showing the actual command over describing what to do. "Run `node setup.js` from `backend/`" with a code block — not "initialize the database."
|
||||
|
||||
---
|
||||
|
||||
## Callouts and Warnings
|
||||
|
||||
- Use a blockquote (`>`) for callouts, notes, and warnings. Do not use GitHub-flavoured admonition syntax (`> [!NOTE]`), Docusaurus admonitions, or custom HTML.
|
||||
- Keep callouts to one or two sentences. If longer, promote to prose.
|
||||
- Example: `> IVANTI_API_KEY must be set in backend/.env for sync to work.`
|
||||
|
||||
Bold prefixes for emphasis are acceptable inside a blockquote: `> **Warning:** This deletes all audit records older than 90 days.`
|
||||
|
||||
---
|
||||
|
||||
## Feature Documentation Pattern
|
||||
|
||||
When documenting a feature or page, follow this structure:
|
||||
|
||||
1. **One-sentence summary** of what the feature is and who uses it.
|
||||
2. **Capability bullets** — what a user can do, grouped by role if access-controlled.
|
||||
3. **Workflow or interaction details** — how the feature is used in practice (inline editing behaviour, filter semantics, persistence rules).
|
||||
4. **Integration notes** — what external systems it touches, what credentials it needs, what it caches.
|
||||
5. **Edge cases and restrictions** — delete rules, rate limits, role requirements.
|
||||
|
||||
Bold paragraph labels (`**Inline editing:**`, `**CVE Tooltips:**`, `**Filtering:**`) are the preferred pattern for calling out sub-behaviours within a feature section.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Entries
|
||||
|
||||
Every troubleshooting entry uses the **Symptom → Cause → Fix** triad:
|
||||
|
||||
```markdown
|
||||
### Short description of the problem
|
||||
|
||||
**Symptom:** What the user sees or experiences. Be specific about error messages, console output, and observed behaviour.
|
||||
|
||||
**Cause:** The underlying reason, explained in one or two sentences. Include the technical mechanism (cookie flag, rate limiter, missing migration) — not just "it's broken."
|
||||
|
||||
**Fix:** Either:
|
||||
1. Concrete action with a command or config change, **or**
|
||||
2. Alternative action.
|
||||
```
|
||||
|
||||
- Short entries (fix is one command) may collapse Cause into Fix, but Symptom must always appear explicitly.
|
||||
- Use **Fix:** as the label, not "Solution" or "Resolution."
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
This project does not currently maintain a `CHANGELOG.md`. The doc-updater agent should **not** create one unless explicitly instructed.
|
||||
|
||||
When a change ships, rely instead on:
|
||||
|
||||
- **Git commit messages** as the primary change history. Write them as complete sentences describing user-observable behaviour, not implementation detail. "Add inline editing to CVE reporting table" — not "fix table.jsx."
|
||||
- **README updates** as the user-facing record of what the app does now. The README is the source of truth for current behaviour; git log is the source of truth for when and why it changed.
|
||||
|
||||
If a `CHANGELOG.md` is introduced later, this section should be rewritten to declare the chosen format. Until then, the agent should leave changelog concerns out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Migration Documentation
|
||||
|
||||
When a feature requires a new database migration:
|
||||
|
||||
1. Add the migration filename to the README's **Migrations** section in the order it must be run.
|
||||
2. If the migration is required for an existing deployment (not just fresh installs), add a Troubleshooting entry using the Symptom → Cause → Fix pattern for the error users will see if they skip it.
|
||||
3. Note any data-transforming migrations explicitly — users need to know whether the migration is additive (safe to re-run) or transformative (destructive if re-run).
|
||||
|
||||
---
|
||||
|
||||
## API Reference Documentation
|
||||
|
||||
- Document routes as: `METHOD /path/with/:params`
|
||||
- Group routes by resource or page (CVE routes, Ivanti routes, Audit routes).
|
||||
- For each route, specify: purpose, required group, request body or query params, response shape summary. Keep it to 2–4 lines per route.
|
||||
- Do not duplicate route implementation details. Link to the source file if deeper reference is needed.
|
||||
- When a route enforces group-based authorisation, state the required group explicitly — do not imply it from context.
|
||||
|
||||
---
|
||||
|
||||
## What to Update When a Feature Ships
|
||||
|
||||
A new feature or meaningful change to existing behaviour should touch:
|
||||
|
||||
1. **README.md — Features section:** add or update the relevant `###` subsection, matching the Feature Documentation Pattern above.
|
||||
2. **README.md — Table of Contents:** if a new feature added a new heading, update the TOC to match.
|
||||
3. **README.md — Configuration:** if new env vars were introduced, add them to the Configuration table.
|
||||
4. **README.md — API Reference:** if new routes were added, document them under the appropriate resource group.
|
||||
5. **README.md — Database Schema:** if tables or columns changed, update the schema section.
|
||||
6. **README.md — Migrations:** if a new migration was added, append it to the ordered list.
|
||||
7. **docs/**: if the feature has its own standalone guide (setup guide, integration guide), create or update the relevant file in `docs/`.
|
||||
|
||||
If a change does not alter user-observable behaviour, configuration, data model, or API surface, it does not require doc changes. Internal refactors, test additions, and dependency bumps are exempt unless they change how the app is run or deployed.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Change
|
||||
|
||||
The doc-updater agent and human contributors alike should **leave alone**:
|
||||
|
||||
- Tone, voice, and spelling conventions of existing prose. Match, do not rewrite.
|
||||
- Section ordering in the README — the current order is deliberate and reader-tested.
|
||||
- Heading wording, unless the underlying feature has genuinely been renamed.
|
||||
- Examples and command snippets that still work, even if they could be "more elegant."
|
||||
- The overall shape of the Troubleshooting section — append new entries, do not reorganise existing ones.
|
||||
|
||||
Documentation churn is a cost. Only change what the code change requires.
|
||||
71
README.md
71
README.md
@@ -60,9 +60,8 @@ The application provides:
|
||||
| Database | SQLite3 |
|
||||
| File uploads | Multer 2 |
|
||||
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
|
||||
| Frontend | React 19, lucide-react, xlsx, rehype-sanitize |
|
||||
| Frontend | React 19, lucide-react, recharts, xlsx, react-markdown, rehype-sanitize, mermaid |
|
||||
| Compliance xlsx parsing | Python 3, pandas, openpyxl |
|
||||
| Bulk notes import | Python 3 (stdlib only) |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,7 +105,7 @@ apt install -y python3-pandas python3-openpyxl
|
||||
|
||||
> If apt packages are unavailable or you need a specific version, see `docs/python-venv-setup.md` for the venv fallback approach.
|
||||
|
||||
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
|
||||
> A bulk notes import script (`import_notes_from_csv.py`) is also available in `backend/scripts/` for maintenance tasks like backfilling notes from a CSV. It uses only Python stdlib.
|
||||
|
||||
### 5. Configure environment variables
|
||||
|
||||
@@ -153,6 +152,9 @@ node migrations/add_ivanti_counts_history_table.js
|
||||
node migrations/add_fp_submissions_table.js
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_fp_submission_editing.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
```
|
||||
|
||||
### 8. Build the frontend
|
||||
@@ -362,7 +364,9 @@ Each row represents a single Ivanti host finding.
|
||||
|
||||
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
|
||||
|
||||
**Export:** Click **Export** to download the current filtered view as CSV or XLSX. Requires Admin, Standard_User, or Leadership group.
|
||||
**Row visibility:** Hide individual rows by clicking the `EyeOff` icon on any row, or select multiple rows via checkboxes and click **Hide Selected** in the bulk action toolbar. Hidden rows are excluded from the table, the Action Coverage chart, and exports. Use the **Hidden (N)** button in the toolbar to view and restore hidden rows individually or all at once. Hidden row state persists to `localStorage` across sessions. Row hiding is a personal view preference available to all user groups.
|
||||
|
||||
**Export:** Click **Export** to download the current filtered view as CSV or XLSX. Hidden rows and filtered rows are both excluded from exports. Requires Admin, Standard_User, or Leadership group.
|
||||
|
||||
---
|
||||
|
||||
@@ -385,7 +389,7 @@ A personal staging list for batch-processing FP, Archer, and CARD workflows with
|
||||
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
||||
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
||||
- **Clear Completed** removes all marked-complete items at once
|
||||
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Successful submission marks the queue items as complete and records the submission locally.
|
||||
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Attachments can be local file uploads or documents selected from the CVE document library — library documents are read from disk and sent to Ivanti identically to local uploads. Successful submission marks the queue items as complete and records the submission locally.
|
||||
|
||||
**Redirecting completed items:**
|
||||
- Completed items show a redirect button (↱) next to the delete icon
|
||||
@@ -509,45 +513,6 @@ Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx repo
|
||||
|
||||
---
|
||||
|
||||
### `backend/scripts/import_notes_from_csv.py`
|
||||
|
||||
Bulk-import notes into the findings cache from a CSV file. Useful for onboarding existing notes or migrating from a spreadsheet.
|
||||
|
||||
**CSV format:**
|
||||
```csv
|
||||
ID,NOTES
|
||||
12345678,EXC-5754
|
||||
87654321,Patched in Feb maintenance window
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend/scripts
|
||||
|
||||
# Preview what would be imported (no writes)
|
||||
python3 import_notes_from_csv.py input.csv --dry-run
|
||||
|
||||
# Import against the default database path
|
||||
python3 import_notes_from_csv.py input.csv
|
||||
|
||||
# Import against a specific database
|
||||
python3 import_notes_from_csv.py input.csv --db /path/to/cve_database.db
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|---|---|
|
||||
| `csv_file` | Path to the input CSV (required) |
|
||||
| `--db` | Path to the SQLite database (default: `../cve_database.db`) |
|
||||
| `--dry-run` | Preview changes without writing to the database |
|
||||
|
||||
- Notes longer than 255 characters are truncated with a warning
|
||||
- Finding IDs not present in the active Ivanti cache are skipped
|
||||
- Uses UPSERT — running the same CSV twice is safe
|
||||
|
||||
**Dependencies:** Python stdlib only (no pip install required).
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie. Group requirements are listed per endpoint.
|
||||
@@ -623,7 +588,13 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/ivanti/fp-workflow` | Admin, Standard_User | Submit an FP workflow batch to Ivanti API (multipart/form-data with attachments) |
|
||||
| GET | `/api/ivanti/fp-workflow/documents/search` | Any | Search the CVE document library by name, CVE ID, or vendor; returns up to 50 matches |
|
||||
| POST | `/api/ivanti/fp-workflow` | Admin, Standard_User | Submit an FP workflow batch to Ivanti API (multipart/form-data with local attachments and/or `libraryDocIds`) |
|
||||
| GET | `/api/ivanti/fp-workflow/submissions` | Any | List FP submissions for the current user |
|
||||
| PUT | `/api/ivanti/fp-workflow/submissions/:id` | Admin, Standard_User | Update an FP submission (edit form fields) |
|
||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/findings` | Admin, Standard_User | Add or remove findings on an existing submission |
|
||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/attachments` | Admin, Standard_User | Upload additional attachments (local files and/or `libraryDocIds`) to an existing submission |
|
||||
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/status` | Admin, Standard_User | Update submission lifecycle status |
|
||||
|
||||
### Ivanti — Todo Queue
|
||||
|
||||
@@ -654,7 +625,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
| GET | `/api/compliance/items` | Any | Device list; `?team=STEAM&status=active` |
|
||||
| GET | `/api/compliance/items/:hostname` | Any | Full detail for a device (metrics + notes) |
|
||||
| GET | `/api/compliance/notes/:hostname/:metricId` | Any | Notes for a specific hostname/metric |
|
||||
| POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric |
|
||||
| POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric; accepts `metric_ids` array for multi-metric notes |
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
@@ -815,7 +786,7 @@ cve-dashboard/
|
||||
|
||||
**`compliance_items`** — One row per device/metric violation. Tracks hostname, IP, device type, team, metric ID, category, `extra_json` (all non-core xlsx columns), status (active/resolved), first seen upload, and times seen. Identity key: `(hostname, metric_id)`.
|
||||
|
||||
**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. Foreign-key linked to compliance items.
|
||||
**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. `group_id` column links notes created in the same multi-metric submission. Foreign-key linked to compliance items.
|
||||
|
||||
### View
|
||||
|
||||
@@ -930,6 +901,9 @@ node migrations/add_ivanti_counts_history_table.js
|
||||
node migrations/add_fp_submissions_table.js
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_fp_submission_editing.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
cd ..
|
||||
|
||||
# 7. Rebuild the frontend
|
||||
@@ -970,6 +944,9 @@ node migrations/add_ivanti_counts_history_table.js
|
||||
node migrations/add_fp_submissions_table.js
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_fp_submission_editing.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
```
|
||||
|
||||
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
||||
|
||||
@@ -15,3 +15,11 @@ IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
# Atlas InfoSec API (atlas-infosec.caas.charterlab.com)
|
||||
# Service account credentials for Basic Auth — used to sync and manage action plans
|
||||
ATLAS_API_URL=
|
||||
ATLAS_API_USER=
|
||||
ATLAS_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy (disables TLS cert verification)
|
||||
ATLAS_SKIP_TLS=false
|
||||
|
||||
BIN
backend/cve_database.db.backupNVD
Normal file
BIN
backend/cve_database.db.backupNVD
Normal file
Binary file not shown.
104
backend/helpers/atlasApi.js
Normal file
104
backend/helpers/atlasApi.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Shared Atlas InfoSec API helpers
|
||||
// Centralizes HTTP calls so the atlas router uses a single implementation.
|
||||
// Follows the same promise-based pattern as ivantiApi.js.
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const ATLAS_API_URL = process.env.ATLAS_API_URL || '';
|
||||
const ATLAS_API_USER = process.env.ATLAS_API_USER || '';
|
||||
const ATLAS_API_PASS = process.env.ATLAS_API_PASS || '';
|
||||
const ATLAS_SKIP_TLS = process.env.ATLAS_SKIP_TLS === 'true';
|
||||
|
||||
const requiredVars = ['ATLAS_API_URL', 'ATLAS_API_USER', 'ATLAS_API_PASS'];
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[atlas-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Atlas API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET, PUT, PATCH, POST
|
||||
// ---------------------------------------------------------------------------
|
||||
function atlasRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const authString = Buffer.from(ATLAS_API_USER + ':' + ATLAS_API_PASS).toString('base64');
|
||||
const fullUrl = new URL(ATLAS_API_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Basic ' + authString
|
||||
};
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: method,
|
||||
headers: headers,
|
||||
timeout: timeout
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !ATLAS_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||||
});
|
||||
|
||||
if (bodyStr) {
|
||||
req.write(bodyStr);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function atlasGet(urlPath, options) {
|
||||
return atlasRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function atlasPut(urlPath, body, options) {
|
||||
return atlasRequest('PUT', urlPath, body, options);
|
||||
}
|
||||
|
||||
function atlasPatch(urlPath, body, options) {
|
||||
return atlasRequest('PATCH', urlPath, body, options);
|
||||
}
|
||||
|
||||
function atlasPost(urlPath, body, options) {
|
||||
return atlasRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
atlasRequest,
|
||||
atlasGet,
|
||||
atlasPut,
|
||||
atlasPatch,
|
||||
atlasPost
|
||||
};
|
||||
332
backend/helpers/driftChecker.js
Normal file
332
backend/helpers/driftChecker.js
Normal file
@@ -0,0 +1,332 @@
|
||||
// Drift Checker — compares xlsx schema against parser config to detect structural drift
|
||||
// Returns categorised findings: breaking, silent_miss, cosmetic
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Load and validate the compliance parser configuration file.
|
||||
* @param {string} configPath — absolute or relative path to compliance_config.json
|
||||
* @returns {object} parsed config with metric_categories, core_cols, skip_sheets
|
||||
* @throws {Error} descriptive error if file missing, invalid JSON, or missing required keys
|
||||
*/
|
||||
function loadConfig(configPath) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error(`Configuration file not found: ${configPath}`);
|
||||
}
|
||||
throw new Error(`Failed to read configuration file: ${err.message}`);
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Configuration file contains invalid JSON: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!config.metric_categories || typeof config.metric_categories !== 'object' || Array.isArray(config.metric_categories)) {
|
||||
throw new Error('Configuration file is missing required key "metric_categories" (must be an object)');
|
||||
}
|
||||
if (!Array.isArray(config.core_cols)) {
|
||||
throw new Error('Configuration file is missing required key "core_cols" (must be an array)');
|
||||
}
|
||||
if (!Array.isArray(config.skip_sheets)) {
|
||||
throw new Error('Configuration file is missing required key "skip_sheets" (must be an array)');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare an xlsx schema against the parser config and produce a drift report.
|
||||
* @param {object} schema — output of extract_xlsx_schema.py: { sheets: [{ name, columns, metric_values? }] }
|
||||
* @param {object} config — parsed compliance_config.json: { metric_categories, core_cols, skip_sheets }
|
||||
* @returns {{ breaking: Array, silent_miss: Array, cosmetic: Array }}
|
||||
*/
|
||||
function compareSchemaToDrift(schema, config) {
|
||||
const breaking = [];
|
||||
const silent_miss = [];
|
||||
const cosmetic = [];
|
||||
|
||||
const metricCategoryKeys = new Set(Object.keys(config.metric_categories));
|
||||
const coreCols = new Set(config.core_cols);
|
||||
const skipSheets = new Set(config.skip_sheets);
|
||||
|
||||
// Build lookup of xlsx sheet names and find the Summary sheet
|
||||
const xlsxSheetNames = new Set();
|
||||
let summarySheet = null;
|
||||
|
||||
for (const sheet of schema.sheets) {
|
||||
xlsxSheetNames.add(sheet.name);
|
||||
if (sheet.name === 'Summary') {
|
||||
summarySheet = sheet;
|
||||
}
|
||||
}
|
||||
|
||||
// Identify detail sheets: present in xlsx AND not in skip_sheets
|
||||
const detailSheets = schema.sheets.filter(s => !skipSheets.has(s.name));
|
||||
|
||||
// Build set of metric values from the Summary sheet (used by multiple rules)
|
||||
const summaryMetrics = new Set(
|
||||
(summarySheet && Array.isArray(summarySheet.metric_values)) ? summarySheet.metric_values : []
|
||||
);
|
||||
|
||||
// --- Breaking rules ---
|
||||
|
||||
// Missing core column: a detail sheet is missing a column from core_cols.
|
||||
// Collect per-column stats first, then classify: if a column is missing from
|
||||
// ALL detail sheets it's breaking. If missing from only some (e.g. 5.8.1 uses
|
||||
// CMDB columns), it's cosmetic — the parser handles it via extra_json.
|
||||
const coreColMissingMap = {}; // col -> [sheet names missing it]
|
||||
for (const sheet of detailSheets) {
|
||||
const sheetCols = new Set(sheet.columns || []);
|
||||
for (const coreCol of config.core_cols) {
|
||||
if (!sheetCols.has(coreCol)) {
|
||||
if (!coreColMissingMap[coreCol]) coreColMissingMap[coreCol] = [];
|
||||
coreColMissingMap[coreCol].push(sheet.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const coreCol of Object.keys(coreColMissingMap)) {
|
||||
const missingSheets = coreColMissingMap[coreCol];
|
||||
if (detailSheets.length > 0 && missingSheets.length >= detailSheets.length) {
|
||||
// Missing from ALL detail sheets — genuinely breaking
|
||||
breaking.push({
|
||||
severity: 'breaking',
|
||||
message: `Core column "${coreCol}" is missing from all ${detailSheets.length} detail sheet(s)`,
|
||||
value: coreCol,
|
||||
sheet: null
|
||||
});
|
||||
} else {
|
||||
// Missing from some sheets — structural difference, not drift
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Core column "${coreCol}" is missing from ${missingSheets.length} of ${detailSheets.length} detail sheet(s): ${missingSheets.join(', ')}`,
|
||||
value: coreCol,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Missing detail sheet: a sheet in metric_categories (not in skip_sheets) is absent from xlsx.
|
||||
// If the metric still appears in the Summary's metric_values, it's tracked but has zero
|
||||
// violations this week — downgrade to cosmetic instead of breaking.
|
||||
for (const metricKey of metricCategoryKeys) {
|
||||
if (!skipSheets.has(metricKey) && !xlsxSheetNames.has(metricKey)) {
|
||||
if (summaryMetrics.has(metricKey)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Metric "${metricKey}" has no detail sheet this week — still tracked in Summary (zero violations)`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
} else {
|
||||
breaking.push({
|
||||
severity: 'breaking',
|
||||
message: `Expected detail sheet "${metricKey}" (metric category) is missing from the workbook`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Silent-miss rules ---
|
||||
|
||||
// Unknown metric value: a metric value in Summary is not a key in metric_categories
|
||||
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||
for (const metricVal of summarySheet.metric_values) {
|
||||
if (!metricCategoryKeys.has(metricVal)) {
|
||||
silent_miss.push({
|
||||
severity: 'silent_miss',
|
||||
message: `Unknown metric "${metricVal}" in Summary — not in metric_categories`,
|
||||
value: metricVal,
|
||||
sheet: 'Summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown sheet: an xlsx sheet not in skip_sheets and not in metric_categories
|
||||
for (const sheet of schema.sheets) {
|
||||
if (!skipSheets.has(sheet.name) && !metricCategoryKeys.has(sheet.name)) {
|
||||
silent_miss.push({
|
||||
severity: 'silent_miss',
|
||||
message: `Unknown sheet "${sheet.name}" — not in skip_sheets or metric_categories`,
|
||||
value: sheet.name,
|
||||
sheet: sheet.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cosmetic rules ---
|
||||
|
||||
// New column in detail sheet: a detail sheet has columns not in core_cols
|
||||
for (const sheet of detailSheets) {
|
||||
for (const col of (sheet.columns || [])) {
|
||||
if (!coreCols.has(col)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `New column "${col}" in sheet "${sheet.name}" — will be captured in extra_json`,
|
||||
value: col,
|
||||
sheet: sheet.name
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stale metric category: a key in metric_categories not in Summary metric values
|
||||
for (const metricKey of metricCategoryKeys) {
|
||||
if (!summaryMetrics.has(metricKey)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Stale metric category "${metricKey}" — not found in Summary sheet metric values`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { breaking, silent_miss, cosmetic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the parser config to resolve breaking drift findings.
|
||||
*
|
||||
* Breaking — "missing detail sheet":
|
||||
* A metric_categories key has no matching xlsx sheet. But if the metric
|
||||
* still appears in the Summary sheet's metric_values, it's a legitimate
|
||||
* tracked metric that simply doesn't have violations this week — keep it.
|
||||
* Only remove metrics absent from BOTH the xlsx sheets AND the Summary.
|
||||
*
|
||||
* Breaking — "missing core column":
|
||||
* A core_cols entry is absent from one or more detail sheets. Only remove
|
||||
* if the column is missing from ALL detail sheets (some sheets like 5.8.1
|
||||
* have a completely different column structure and shouldn't cause removal).
|
||||
*
|
||||
* Silent-miss — "unknown metric":
|
||||
* A metric value in the Summary is not in metric_categories. Add it as 'Other'.
|
||||
*
|
||||
* Silent-miss — "unknown sheet":
|
||||
* Left as a warning. Auto-adding unknown sheets creates a reconcile loop.
|
||||
*
|
||||
* @param {string} configPath — path to compliance_config.json
|
||||
* @param {object} driftReport — the drift report from compareSchemaToDrift()
|
||||
* @param {object} [schema] — optional xlsx schema (with sheets[].name and Summary metric_values)
|
||||
* @returns {{ changes: Array<{ action: string, key: string, value: string }>, config: object }}
|
||||
*/
|
||||
function reconcileConfig(configPath, driftReport, schema) {
|
||||
const config = loadConfig(configPath);
|
||||
const changes = [];
|
||||
|
||||
// Build a set of metric values from the Summary sheet (if schema provided)
|
||||
const summaryMetrics = new Set();
|
||||
if (schema && Array.isArray(schema.sheets)) {
|
||||
const summarySheet = schema.sheets.find(function(s) { return s.name === 'Summary'; });
|
||||
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||
summarySheet.metric_values.forEach(function(v) { summaryMetrics.add(v); });
|
||||
}
|
||||
}
|
||||
|
||||
// Build a set of xlsx sheet names (if schema provided)
|
||||
const xlsxSheetNames = new Set();
|
||||
if (schema && Array.isArray(schema.sheets)) {
|
||||
schema.sheets.forEach(function(s) { xlsxSheetNames.add(s.name); });
|
||||
}
|
||||
|
||||
// Count how many detail sheets exist in the xlsx (excluding skip_sheets)
|
||||
const skipSheets = new Set(config.skip_sheets);
|
||||
const detailSheetCount = schema
|
||||
? schema.sheets.filter(function(s) { return !skipSheets.has(s.name); }).length
|
||||
: 0;
|
||||
|
||||
// --- Resolve breaking findings ---
|
||||
|
||||
for (const finding of (driftReport.breaking || [])) {
|
||||
// Missing detail sheet: remove from metric_categories ONLY if the metric
|
||||
// is also absent from the Summary's metric_values. If it's in the Summary,
|
||||
// it's still a tracked metric — the sheet just has zero violations this week.
|
||||
if (finding.message.includes('is missing from the workbook') && finding.value in config.metric_categories) {
|
||||
if (summaryMetrics.has(finding.value)) {
|
||||
// Metric is in the Summary — keep it, just note it's sheet-less this week
|
||||
changes.push({
|
||||
action: 'kept',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Kept metric "${finding.value}" — no detail sheet this week but still tracked in Summary`
|
||||
});
|
||||
} else {
|
||||
const oldCategory = config.metric_categories[finding.value];
|
||||
delete config.metric_categories[finding.value];
|
||||
changes.push({
|
||||
action: 'removed',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Removed stale metric category "${finding.value}" (was "${oldCategory}") — absent from both workbook sheets and Summary`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Missing core column: only remove if the column is missing from ALL detail sheets.
|
||||
// Some sheets (e.g. 5.8.1 with CMDB columns) have a completely different structure
|
||||
// and shouldn't cause removal of columns that exist in most other sheets.
|
||||
if (finding.message.includes('is missing core column') && config.core_cols.includes(finding.value)) {
|
||||
if (!changes.some(function(c) { return c.key === 'core_cols' && c.value === finding.value; })) {
|
||||
const missingFromCount = (driftReport.breaking || []).filter(
|
||||
function(f) { return f.message.includes('is missing core column') && f.value === finding.value; }
|
||||
).length;
|
||||
|
||||
if (detailSheetCount > 0 && missingFromCount >= detailSheetCount) {
|
||||
// Missing from ALL detail sheets — safe to remove
|
||||
config.core_cols = config.core_cols.filter(function(c) { return c !== finding.value; });
|
||||
changes.push({
|
||||
action: 'removed',
|
||||
key: 'core_cols',
|
||||
value: finding.value,
|
||||
detail: `Removed core column "${finding.value}" — missing from all ${detailSheetCount} detail sheet(s)`
|
||||
});
|
||||
} else {
|
||||
// Missing from some sheets but present in others — keep it
|
||||
changes.push({
|
||||
action: 'kept',
|
||||
key: 'core_cols',
|
||||
value: finding.value,
|
||||
detail: `Kept core column "${finding.value}" — missing from ${missingFromCount} of ${detailSheetCount} detail sheet(s)`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve silent-miss findings ---
|
||||
|
||||
for (const finding of (driftReport.silent_miss || [])) {
|
||||
// Unknown metric in Summary: add to metric_categories as 'Other'
|
||||
if (finding.message.includes('not in metric_categories') && !(finding.value in config.metric_categories)) {
|
||||
config.metric_categories[finding.value] = 'Other';
|
||||
changes.push({
|
||||
action: 'added',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Added new metric "${finding.value}" to metric_categories as "Other"`
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown sheet: left as a warning — auto-adding creates a reconcile loop.
|
||||
}
|
||||
|
||||
// Only write if there were actual config mutations (not just 'kept' entries)
|
||||
const hasMutations = changes.some(function(c) { return c.action !== 'kept'; });
|
||||
if (hasMutations) {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
return { changes, config };
|
||||
}
|
||||
|
||||
module.exports = { compareSchemaToDrift, loadConfig, reconcileConfig };
|
||||
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Migration: Add atlas_action_plans_cache table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Atlas action plans cache migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — one row per host, holding cached Atlas action plan status
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL UNIQUE,
|
||||
has_action_plan INTEGER NOT NULL DEFAULT 0,
|
||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating atlas_action_plans_cache table:', err);
|
||||
else console.log('✓ atlas_action_plans_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
|
||||
ON atlas_action_plans_cache(host_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating host_id index:', err);
|
||||
else console.log('✓ idx_atlas_cache_host_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
409
backend/routes/atlas.js
Normal file
409
backend/routes/atlas.js
Normal file
@@ -0,0 +1,409 @@
|
||||
// Atlas InfoSec Action Plans Routes
|
||||
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache
|
||||
// for fast badge rendering on the ReportingPage.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||
|
||||
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createAtlasRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Return all cached Atlas rows for badge rendering.
|
||||
// Auth: any authenticated user
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Error fetching status:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Atlas status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /sync
|
||||
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
|
||||
// Auth: Admin or Standard_User
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read Ivanti findings cache and extract unique non-null hostIds
|
||||
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`);
|
||||
if (!cacheRow || !cacheRow.findings_json) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
let findings;
|
||||
try {
|
||||
findings = JSON.parse(cacheRow.findings_json);
|
||||
} catch (parseErr) {
|
||||
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
|
||||
}
|
||||
|
||||
const hostIdSet = new Set();
|
||||
for (const f of findings) {
|
||||
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
|
||||
hostIdSet.add(f.hostId);
|
||||
}
|
||||
}
|
||||
const hostIds = [...hostIdSet];
|
||||
|
||||
if (hostIds.length === 0) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// 2. Process hosts in batches of 5 concurrent requests
|
||||
let synced = 0;
|
||||
let withPlans = 0;
|
||||
let failed = 0;
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
for (let i = 0; i < hostIds.length; i += BATCH_SIZE) {
|
||||
const batch = hostIds.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (hostId) => {
|
||||
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||
return { hostId, result };
|
||||
})
|
||||
);
|
||||
|
||||
for (const settled of results) {
|
||||
if (settled.status === 'rejected') {
|
||||
failed++;
|
||||
console.warn('[Atlas Sync] Request failed for host:', settled.reason?.message || settled.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { hostId, result } = settled.value;
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let allPlans = [];
|
||||
let activePlans = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.body);
|
||||
// Atlas returns { active: [...], inactive: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||
allPlans = [...activePlans, ...inactive];
|
||||
} else if (Array.isArray(parsed)) {
|
||||
allPlans = parsed;
|
||||
activePlans = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
allPlans = [];
|
||||
activePlans = [];
|
||||
}
|
||||
|
||||
// Badge counts only active plans — inactive are historical
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0 ? 1 : 0;
|
||||
|
||||
try {
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = excluded.has_action_plan,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||
);
|
||||
} catch (dbErr) {
|
||||
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||
}
|
||||
|
||||
synced++;
|
||||
if (hasActionPlan) withPlans++;
|
||||
} else {
|
||||
failed++;
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Log audit entry
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_SYNC',
|
||||
entityType: 'atlas_action_plans',
|
||||
entityId: null,
|
||||
details: { synced, withPlans, failed, totalHosts: hostIds.length },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ synced, withPlans, failed });
|
||||
} catch (err) {
|
||||
console.error('[Atlas Sync] Unexpected error:', err.message);
|
||||
res.status(500).json({ error: 'Atlas sync failed: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /hosts/:hostId/action-plans
|
||||
// Proxy to Atlas API — returns live action plan data for a single host.
|
||||
// Auth: any authenticated user
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
// Forward non-2xx Atlas responses to the client
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] GET action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PUT /hosts/:hostId/action-plans
|
||||
// Create a new action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
// -----------------------------------------------------------------------
|
||||
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
const { plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_CREATE_PLAN',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: String(hostId),
|
||||
details: { hostId, plan_type, commit_date },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] PUT action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PATCH /hosts/:hostId/action-plans
|
||||
// Update an existing action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
// -----------------------------------------------------------------------
|
||||
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
const { action_plan_id, updates } = req.body || {};
|
||||
|
||||
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
|
||||
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
return res.status(400).json({ error: 'updates is required and must be an object' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_UPDATE_PLAN',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: String(hostId),
|
||||
details: { hostId, action_plan_id },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] PATCH action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /hosts/bulk-action-plans
|
||||
// Create action plans for multiple hosts at once.
|
||||
// Auth: Admin or Standard_User
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const { host_ids, plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
|
||||
for (const id of host_ids) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPost('/hosts/create-bulk-action-plans', req.body);
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] POST bulk-action-plans failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createAtlasRouter;
|
||||
@@ -2,22 +2,32 @@
|
||||
// Handles xlsx upload/parse, non-compliant item history, and notes.
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
|
||||
// POST /preview — parse xlsx, run drift check, compute diff (no DB write)
|
||||
// POST /reconcile-config — patch compliance_config.json to resolve drift findings
|
||||
// POST /commit — commit a previewed upload to DB
|
||||
// GET /uploads — list all uploads
|
||||
// POST /rollback/:uploadId — roll back the most recent upload (Admin only)
|
||||
// GET /summary — metric health cards for a team (from latest upload)
|
||||
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||
// POST /notes — add a note to one or more (hostname, metric_id) pairs
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
// GET /trends — per-upload totals + per-team counts for time-series charts
|
||||
// GET /mttr — mean time to resolution per team
|
||||
// GET /top-recurring — chronic compliance gaps sorted by seen_count
|
||||
// GET /category-trend — active counts per category per upload for stacked area chart
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
const SCHEMA_SCRIPT = path.join(__dirname, '../scripts/extract_xlsx_schema.py');
|
||||
const CONFIG_PATH = path.join(__dirname, '..', 'scripts', 'compliance_config.json');
|
||||
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||
@@ -63,6 +73,25 @@ function parseXlsx(filePath) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Python schema extractor, return xlsx schema object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractXlsxSchema(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const py = spawn(PYTHON_BIN, [SCHEMA_SCRIPT, filePath]);
|
||||
let out = '';
|
||||
let err = '';
|
||||
py.stdout.on('data', d => { out += d; });
|
||||
py.stderr.on('data', d => { err += d; });
|
||||
py.on('close', code => {
|
||||
if (code !== 0) return reject(new Error(err || `Schema extractor exited with code ${code}`));
|
||||
try { resolve(JSON.parse(out)); }
|
||||
catch (e) { reject(new Error('Schema extractor returned invalid JSON')); }
|
||||
});
|
||||
py.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate that a temp file path is safely within uploads/temp/
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,6 +257,15 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// POST /preview
|
||||
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||
// Returns diff counts + tempFile path for the commit step.
|
||||
//
|
||||
// Body: multipart/form-data with `file` field (xlsx)
|
||||
// Response: {
|
||||
// drift: { breaking: [], silent_miss: [], cosmetic: [] } | null,
|
||||
// drift_error: string | null,
|
||||
// diff: { new_count, recurring_count, resolved_count },
|
||||
// tempFile: string, filename: string,
|
||||
// report_date: string, total_items: number
|
||||
// }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
upload.single('file')(req, res, async (uploadErr) => {
|
||||
@@ -243,6 +281,31 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Drift check: load config, extract schema, compare ---
|
||||
let drift = null;
|
||||
let drift_error = null;
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig(CONFIG_PATH);
|
||||
} catch (configErr) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(500).json({ error: 'Configuration file could not be loaded: ' + configErr.message });
|
||||
}
|
||||
|
||||
let xlsxSchema = null;
|
||||
try {
|
||||
xlsxSchema = await extractXlsxSchema(req.file.path);
|
||||
if (xlsxSchema.error) {
|
||||
throw new Error(xlsxSchema.error);
|
||||
}
|
||||
drift = compareSchemaToDrift(xlsxSchema, config);
|
||||
} catch (driftErr) {
|
||||
drift = null;
|
||||
drift_error = driftErr.message || 'Drift check failed';
|
||||
}
|
||||
|
||||
// --- Existing parse flow ---
|
||||
const parsed = await parseXlsx(req.file.path);
|
||||
|
||||
if (parsed.error) {
|
||||
@@ -268,6 +331,9 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
|
||||
res.json({
|
||||
drift,
|
||||
drift_error,
|
||||
schema: xlsxSchema,
|
||||
diff: {
|
||||
new_count: diff.newCount,
|
||||
recurring_count: diff.recurringCount,
|
||||
@@ -287,10 +353,63 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /reconcile-config
|
||||
// Admin-only. Patches compliance_config.json to resolve breaking and
|
||||
// silent-miss drift findings, then re-runs the drift check and returns
|
||||
// the updated report. Logs every change to the audit trail.
|
||||
//
|
||||
// Body: { drift: { breaking: [...], silent_miss: [...] } }
|
||||
// Response: { changes: [{ action, key, value, detail }], message: string }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => {
|
||||
const { drift, schema } = req.body;
|
||||
|
||||
if (!drift || typeof drift !== 'object') {
|
||||
return res.status(400).json({ error: 'drift report is required in request body' });
|
||||
}
|
||||
|
||||
const hasFindings = (drift.breaking && drift.breaking.length > 0) ||
|
||||
(drift.silent_miss && drift.silent_miss.length > 0);
|
||||
if (!hasFindings) {
|
||||
return res.status(400).json({ error: 'No breaking or silent-miss findings to reconcile' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { changes } = reconcileConfig(CONFIG_PATH, drift, schema || null);
|
||||
|
||||
if (changes.length === 0) {
|
||||
return res.json({ changes: [], message: 'No changes needed' });
|
||||
}
|
||||
|
||||
// Audit log each change
|
||||
for (const change of changes) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_config_reconcile',
|
||||
entityType: 'compliance_config',
|
||||
entityId: change.value,
|
||||
details: { action: change.action, key: change.key, detail: change.detail },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ changes, message: `Reconciled ${changes.length} config change(s)` });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] Reconcile config error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to reconcile config: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /commit
|
||||
// Commit a previewed upload to the DB.
|
||||
// Body: { tempFile, filename, report_date }
|
||||
//
|
||||
// Body: { tempFile: string, filename: string, report_date: string }
|
||||
// Response: { upload: { id, filename, report_date, uploaded_at,
|
||||
// new_count, resolved_count, recurring_count } }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { tempFile, filename, report_date } = req.body;
|
||||
@@ -341,6 +460,9 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /uploads
|
||||
// List all uploads, most recent first.
|
||||
//
|
||||
// Response: { uploads: [{ id, filename, report_date, uploaded_at,
|
||||
// new_count, resolved_count, recurring_count }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/uploads', async (req, res) => {
|
||||
try {
|
||||
@@ -357,9 +479,133 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /rollback/:uploadId
|
||||
// Admin-only. Rolls back a specific upload. Only the most recent upload
|
||||
// can be rolled back to avoid cascading data integrity issues.
|
||||
//
|
||||
// Params: uploadId — integer ID of the upload to roll back
|
||||
// Response: { message: string, rolled_back: { upload_id, filename,
|
||||
// report_date, items_deleted, items_reactivated } }
|
||||
//
|
||||
// Reversal logic:
|
||||
// 1. Delete items first seen in this upload (new items)
|
||||
// 2. Re-activate items resolved by this upload
|
||||
// 3. Revert recurring items: decrement seen_count, point upload_id
|
||||
// back to the previous upload
|
||||
// 4. Delete the upload record
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => {
|
||||
const uploadId = parseInt(req.params.uploadId, 10);
|
||||
if (isNaN(uploadId)) {
|
||||
return res.status(400).json({ error: 'Invalid upload ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the upload exists
|
||||
const upload = await dbGet(db,
|
||||
`SELECT id, filename, report_date, new_count, resolved_count, recurring_count
|
||||
FROM compliance_uploads WHERE id = ?`,
|
||||
[uploadId]
|
||||
);
|
||||
if (!upload) {
|
||||
return res.status(404).json({ error: 'Upload not found' });
|
||||
}
|
||||
|
||||
// Only allow rolling back the most recent upload
|
||||
const latest = await dbGet(db,
|
||||
`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
if (latest.id !== uploadId) {
|
||||
return res.status(400).json({
|
||||
error: 'Only the most recent upload can be rolled back',
|
||||
latest_upload_id: latest.id
|
||||
});
|
||||
}
|
||||
|
||||
// Find the previous upload (to restore recurring items' upload_id)
|
||||
const previousUpload = await dbGet(db,
|
||||
`SELECT id FROM compliance_uploads WHERE id < ? ORDER BY id DESC LIMIT 1`,
|
||||
[uploadId]
|
||||
);
|
||||
|
||||
await dbRun(db, 'BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// 1. Delete items that were NEW in this upload
|
||||
const deleteNew = await dbRun(db,
|
||||
`DELETE FROM compliance_items WHERE first_seen_upload_id = ? AND upload_id = ?`,
|
||||
[uploadId, uploadId]
|
||||
);
|
||||
|
||||
// 2. Re-activate items that were RESOLVED by this upload
|
||||
const reactivate = await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET status = 'active', resolved_upload_id = NULL
|
||||
WHERE resolved_upload_id = ?`,
|
||||
[uploadId]
|
||||
);
|
||||
|
||||
// 3. Revert RECURRING items: decrement seen_count, restore upload_id
|
||||
if (previousUpload) {
|
||||
await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET upload_id = ?, seen_count = MAX(seen_count - 1, 1)
|
||||
WHERE upload_id = ? AND first_seen_upload_id != ?`,
|
||||
[previousUpload.id, uploadId, uploadId]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Delete the upload record
|
||||
await dbRun(db, `DELETE FROM compliance_uploads WHERE id = ?`, [uploadId]);
|
||||
|
||||
await dbRun(db, 'COMMIT');
|
||||
|
||||
// Audit log
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_upload_rollback',
|
||||
entityType: 'compliance_upload',
|
||||
entityId: String(uploadId),
|
||||
details: {
|
||||
filename: upload.filename,
|
||||
report_date: upload.report_date,
|
||||
items_deleted: deleteNew.changes,
|
||||
items_reactivated: reactivate.changes,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Rolled back upload "${upload.filename}"`,
|
||||
rolled_back: {
|
||||
upload_id: uploadId,
|
||||
filename: upload.filename,
|
||||
report_date: upload.report_date,
|
||||
items_deleted: deleteNew.changes,
|
||||
items_reactivated: reactivate.changes,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] Rollback error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to rollback upload: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /summary?team=STEAM
|
||||
// Return metric health rows for a team from the latest upload's summary_json.
|
||||
//
|
||||
// Query: team — optional, one of ALLOWED_TEAMS
|
||||
// Response: { entries: [...], overall_scores: {}, upload: { id,
|
||||
// report_date, uploaded_at } | null }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/summary', async (req, res) => {
|
||||
const team = req.query.team;
|
||||
@@ -403,6 +649,12 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items?team=STEAM&status=active
|
||||
// Return non-compliant devices grouped by hostname.
|
||||
//
|
||||
// Query: team — required, one of ALLOWED_TEAMS
|
||||
// status — optional, 'active' (default) or 'resolved'
|
||||
// Response: { devices: [{ hostname, ip_address, device_type, team,
|
||||
// status, failing_metrics, seen_count, first_seen, last_seen,
|
||||
// resolved_on, has_notes }], team, status }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items', async (req, res) => {
|
||||
const { team, status = 'active' } = req.query;
|
||||
@@ -448,6 +700,12 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items/:hostname
|
||||
// Detail panel: all metric rows for this hostname + notes + upload history.
|
||||
//
|
||||
// Params: hostname — device hostname string
|
||||
// Response: { hostname, ip_address, device_type, team,
|
||||
// metrics: [{ metric_id, metric_desc, category, status, seen_count,
|
||||
// extra, first_seen, last_seen, resolved_on, ... }],
|
||||
// notes: [{ id, metric_id, note, group_id, created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items/:hostname', async (req, res) => {
|
||||
const hostname = req.params.hostname;
|
||||
@@ -519,7 +777,11 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /notes
|
||||
// Add a note to one or more (hostname, metric_id) pairs.
|
||||
// Body: { hostname, metric_ids: [...], note } — or legacy { hostname, metric_id, note }
|
||||
//
|
||||
// Body: { hostname: string, metric_ids: string[], note: string }
|
||||
// — or legacy: { hostname: string, metric_id: string, note: string }
|
||||
// Response: { notes: [{ id, hostname, metric_id, note, group_id,
|
||||
// created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { hostname, metric_id, metric_ids, note } = req.body;
|
||||
@@ -602,6 +864,10 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /notes/:hostname/:metricId
|
||||
// Return all notes for a (hostname, metric_id) pair.
|
||||
//
|
||||
// Params: hostname — device hostname string
|
||||
// metricId — metric identifier string
|
||||
// Response: { notes: [{ id, note, created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/notes/:hostname/:metricId', async (req, res) => {
|
||||
const { hostname, metricId } = req.params;
|
||||
@@ -625,10 +891,76 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DELETE /notes/:id
|
||||
// Delete a note (or all notes in the same group_id) by note ID.
|
||||
// Only the note author or an Admin can delete.
|
||||
//
|
||||
// Params: id — note row ID
|
||||
// Query: ?group=true — delete all notes sharing the same group_id
|
||||
// Response: { deleted: number }
|
||||
// -----------------------------------------------------------------------
|
||||
router.delete('/notes/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const noteId = parseInt(req.params.id, 10);
|
||||
if (isNaN(noteId)) return res.status(400).json({ error: 'Invalid note ID' });
|
||||
|
||||
const deleteGroup = req.query.group === 'true';
|
||||
|
||||
try {
|
||||
// Fetch the note to verify ownership
|
||||
const note = await dbGet(db,
|
||||
`SELECT id, hostname, metric_id, note, group_id, created_by FROM compliance_notes WHERE id = ?`,
|
||||
[noteId]
|
||||
);
|
||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
// Only the author or an Admin can delete
|
||||
const isAuthor = req.user && String(req.user.id) === String(note.created_by);
|
||||
const isAdminUser = req.user && req.user.group === 'Admin';
|
||||
if (!isAuthor && !isAdminUser) {
|
||||
return res.status(403).json({ error: 'You can only delete your own notes' });
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
if (deleteGroup && note.group_id) {
|
||||
const result = await dbRun(db,
|
||||
`DELETE FROM compliance_notes WHERE group_id = ?`,
|
||||
[note.group_id]
|
||||
);
|
||||
deleted = result.changes || 0;
|
||||
} else {
|
||||
const result = await dbRun(db,
|
||||
`DELETE FROM compliance_notes WHERE id = ?`,
|
||||
[noteId]
|
||||
);
|
||||
deleted = result.changes || 0;
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_note_delete',
|
||||
entityType: 'compliance_note',
|
||||
entityId: String(noteId),
|
||||
details: JSON.stringify({ hostname: note.hostname, group_id: note.group_id, deleted_count: deleted }),
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ deleted });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] DELETE /notes error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete note' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /trends
|
||||
// Per-upload active totals + per-team counts for time-series charts.
|
||||
// Returns rows ordered ascending by report_date.
|
||||
//
|
||||
// Response: { trends: [{ report_date, new_count, recurring_count,
|
||||
// resolved_count, total_active, STEAM, ACCESS-ENG, ACCESS-OPS,
|
||||
// INTELDEV }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/trends', async (req, res) => {
|
||||
try {
|
||||
@@ -681,6 +1013,8 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
//
|
||||
// Response: { mttr: [{ team, avg_days, resolved_count }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
@@ -709,6 +1043,9 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
//
|
||||
// Response: { items: [{ team, metric_id, metric_desc, seen_count,
|
||||
// host_count }] } — limited to top 20
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
@@ -730,6 +1067,8 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /category-trend
|
||||
// Active item counts per category per upload, for stacked area chart.
|
||||
//
|
||||
// Response: { categoryTrend: [{ report_date, category, count }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/category-trend', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -397,6 +397,7 @@ function extractFinding(f) {
|
||||
|
||||
return {
|
||||
id: String(f.id),
|
||||
hostId: f.host?.hostId || null,
|
||||
title: f.title || '',
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||
@@ -782,7 +783,14 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — cached findings with notes merged in
|
||||
/**
|
||||
* GET /api/ivanti/findings
|
||||
*
|
||||
* Return cached Ivanti findings with notes and overrides merged in.
|
||||
*
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
@@ -791,7 +799,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger immediate sync, return fresh state
|
||||
/**
|
||||
* POST /api/ivanti/findings/sync
|
||||
*
|
||||
* Trigger an immediate Ivanti findings sync and return the fresh state.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
||||
* @returns {Object} 500 - { error: string } if sync ran but state could not be read
|
||||
*/
|
||||
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
await syncFindings(db);
|
||||
try {
|
||||
@@ -801,7 +817,14 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts — open vs closed totals for pie chart
|
||||
/**
|
||||
* GET /api/ivanti/findings/counts
|
||||
*
|
||||
* Return open vs closed finding totals for the pie chart.
|
||||
*
|
||||
* @returns {Object} 200 - { open: number, closed: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/counts', async (req, res) => {
|
||||
try {
|
||||
res.json(await readCounts(db));
|
||||
@@ -810,8 +833,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||
/**
|
||||
* GET /api/ivanti/findings/counts/history
|
||||
*
|
||||
* Return the last snapshot per day (ascending) for the trend chart.
|
||||
* Uses a ROW_NUMBER window function to pick the final sync of each calendar day.
|
||||
*
|
||||
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/counts/history', async (req, res) => {
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
@@ -837,7 +867,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||
/**
|
||||
* GET /api/ivanti/findings/fp-workflow-counts
|
||||
*
|
||||
* Return FP finding counts and unique workflow ID counts (open + closed),
|
||||
* broken down by workflow status.
|
||||
*
|
||||
* @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/fp-workflow-counts', async (req, res) => {
|
||||
try {
|
||||
const row = await new Promise((resolve, reject) => {
|
||||
@@ -860,7 +898,20 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||
/**
|
||||
* PUT /api/ivanti/findings/:findingId/override
|
||||
*
|
||||
* Save or clear a field override for a finding. Requires Admin or Standard_User group.
|
||||
* Sending an empty value clears the override (reverts to Ivanti-sourced data).
|
||||
*
|
||||
* @param {string} findingId - The finding identifier (URL param)
|
||||
* @body {string} field - The field to override; must be one of 'hostName', 'dns'
|
||||
* @body {string} [value] - The override value; empty or omitted to clear
|
||||
*
|
||||
* @returns {Object} 200 - { finding_id: string, field: string, value: string|null }
|
||||
* @returns {Object} 400 - { error: string } when field is not in the allowed list
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
@@ -896,7 +947,18 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||
/**
|
||||
* PUT /api/ivanti/findings/:findingId/note
|
||||
*
|
||||
* Save or update a note for a finding (max 255 characters).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} findingId - The finding identifier (URL param)
|
||||
* @body {string} [note] - The note text (truncated to 255 chars)
|
||||
*
|
||||
* @returns {Object} 200 - { finding_id: string, note: string }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const note = String(req.body.note || '').slice(0, 255);
|
||||
|
||||
@@ -67,6 +67,12 @@ function validateFpWorkflowForm(body) {
|
||||
expDay.setHours(0, 0, 0, 0);
|
||||
if (expDay <= today) {
|
||||
errors.expirationDate = 'Expiration date must be in the future.';
|
||||
} else {
|
||||
const maxDate = new Date(today);
|
||||
maxDate.setDate(maxDate.getDate() + 120);
|
||||
if (expDay > maxDate) {
|
||||
errors.expirationDate = 'Expiration date cannot be more than 120 days from today.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
backend/scripts/__pycache__/extract_xlsx_schema.cpython-312.pyc
Normal file
BIN
backend/scripts/__pycache__/extract_xlsx_schema.cpython-312.pyc
Normal file
Binary file not shown.
44
backend/scripts/compliance_config.json
Normal file
44
backend/scripts/compliance_config.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"metric_categories": {
|
||||
"1.1.1": "Logging & Monitoring",
|
||||
"1.1.3": "Logging & Monitoring",
|
||||
"1.4.1": "Logging & Monitoring",
|
||||
"2.3.4i": "Vulnerability Management",
|
||||
"2.3.6i": "Vulnerability Management",
|
||||
"2.3.8i": "Vulnerability Management",
|
||||
"5.2.4": "Access & MFA",
|
||||
"5.2.5": "Access & MFA",
|
||||
"5.2.6": "Access & MFA",
|
||||
"5.2.7": "Access & MFA",
|
||||
"5.2.8": "Access & MFA",
|
||||
"5.3.4": "Endpoint Protection",
|
||||
"5.5.4i": "Vulnerability Management",
|
||||
"5.5.5": "Decommissioned Assets",
|
||||
"5.8.1": "Application Security",
|
||||
"7.1.1": "Logging & Monitoring",
|
||||
"7.1.4": "Logging & Monitoring",
|
||||
"7.6.13": "Disaster Recovery",
|
||||
"7.6.16": "Disaster Recovery",
|
||||
"Missing_AppID": "Asset Data Quality",
|
||||
"Missing_DF": "Asset Data Quality",
|
||||
"Missing_OS": "Asset Data Quality",
|
||||
"5.5.2": "Other"
|
||||
},
|
||||
"core_cols": [
|
||||
"Preferred - Hostname",
|
||||
"GRANITE - IPv4_Address",
|
||||
"GRANITE - Type",
|
||||
"Team",
|
||||
"Compliant",
|
||||
"Source_Network",
|
||||
"Vertical",
|
||||
"GRANITE - Equip_Inst_ID",
|
||||
"GRANITE - RESPONSIBLE_TEAM"
|
||||
],
|
||||
"skip_sheets": [
|
||||
"Summary",
|
||||
"CMDB_9box",
|
||||
"Vulns",
|
||||
"Aging Dashboard"
|
||||
]
|
||||
}
|
||||
84
backend/scripts/dump_xlsx_schema.py
Normal file
84
backend/scripts/dump_xlsx_schema.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dump the structural schema of a compliance xlsx file as JSON.
|
||||
Usage: python3 dump_xlsx_schema.py <path_to_xlsx>
|
||||
|
||||
Output:
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"name": "SheetName",
|
||||
"columns": ["Col A", "Col B", ...],
|
||||
"row_count": 150,
|
||||
"metric_values": ["2.3.4i", "5.2.4", ...] // only if a Metric column exists
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Dependencies: openpyxl (already in requirements.txt)
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({'error': 'No file path provided'}))
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, read_only=True, data_only=True)
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': f'Cannot open file: {str(e)}'}))
|
||||
sys.exit(1)
|
||||
|
||||
sheets = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows = list(ws.iter_rows(max_row=1, values_only=True))
|
||||
columns = [str(c).strip() for c in rows[0] if c is not None] if rows else []
|
||||
|
||||
# Count data rows (excluding header)
|
||||
row_count = 0
|
||||
for _ in ws.iter_rows(min_row=2, values_only=True):
|
||||
row_count += 1
|
||||
|
||||
# Extract metric values if a Metric column exists in the Summary sheet
|
||||
metric_values = []
|
||||
if sheet_name == 'Summary':
|
||||
# Summary has header at row 4 (0-indexed row 3), read from row 5 onward
|
||||
header_rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True))
|
||||
if header_rows:
|
||||
summary_cols = [str(c).strip() if c else '' for c in header_rows[0]]
|
||||
metric_idx = None
|
||||
for i, col in enumerate(summary_cols):
|
||||
if col == 'Metric':
|
||||
metric_idx = i
|
||||
break
|
||||
if metric_idx is not None:
|
||||
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||
if row[metric_idx] is not None:
|
||||
val = str(row[metric_idx]).strip()
|
||||
if val and val != 'Metric':
|
||||
metric_values.append(val)
|
||||
|
||||
entry = {
|
||||
'name': sheet_name,
|
||||
'columns': columns,
|
||||
'row_count': row_count,
|
||||
}
|
||||
if metric_values:
|
||||
entry['metric_values'] = sorted(set(metric_values))
|
||||
|
||||
sheets.append(entry)
|
||||
|
||||
wb.close()
|
||||
print(json.dumps({'sheets': sheets}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
91
backend/scripts/extract_xlsx_schema.py
Normal file
91
backend/scripts/extract_xlsx_schema.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract the structural schema of a compliance xlsx file as JSON.
|
||||
Usage: python3 extract_xlsx_schema.py <path_to_xlsx>
|
||||
|
||||
Output:
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"name": "Summary",
|
||||
"columns": ["Metric", "Non-Compliant", "..."],
|
||||
"metric_values": ["2.3.4i", "5.2.4", "..."]
|
||||
},
|
||||
{
|
||||
"name": "2.3.4i",
|
||||
"columns": ["Preferred - Hostname", "GRANITE - IPv4_Address", "..."]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
- Uses openpyxl in read-only mode.
|
||||
- Extracts sheet names, first-row column headers per sheet, and unique metric
|
||||
values from the Summary sheet (header at row 4, data from row 5 onward).
|
||||
- On error, returns { "error": "..." } on stdout and exits with non-zero code.
|
||||
|
||||
Dependencies: openpyxl (already in requirements.txt)
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "No file path provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, read_only=True, data_only=True)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"Cannot open file: {str(e)}"}))
|
||||
sys.exit(1)
|
||||
|
||||
if not wb.sheetnames:
|
||||
print(json.dumps({"error": "Workbook contains no sheets"}))
|
||||
wb.close()
|
||||
sys.exit(1)
|
||||
|
||||
sheets = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Extract first-row column headers
|
||||
rows = list(ws.iter_rows(max_row=1, values_only=True))
|
||||
columns = [str(c).strip() for c in rows[0] if c is not None] if rows else []
|
||||
|
||||
entry = {
|
||||
"name": sheet_name,
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
# Extract metric values from the Summary sheet
|
||||
# Summary has header at row 4, data from row 5 onward
|
||||
if sheet_name == "Summary":
|
||||
metric_values = []
|
||||
header_rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True))
|
||||
if header_rows:
|
||||
summary_cols = [str(c).strip() if c else "" for c in header_rows[0]]
|
||||
metric_idx = None
|
||||
for i, col in enumerate(summary_cols):
|
||||
if col == "Metric":
|
||||
metric_idx = i
|
||||
break
|
||||
if metric_idx is not None:
|
||||
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||
if row[metric_idx] is not None:
|
||||
val = str(row[metric_idx]).strip()
|
||||
if val and val != "Metric":
|
||||
metric_values.append(val)
|
||||
entry["metric_values"] = sorted(set(metric_values))
|
||||
|
||||
sheets.append(entry)
|
||||
|
||||
wb.close()
|
||||
print(json.dumps({"sheets": sheets}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,39 +12,35 @@ Output:
|
||||
}
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
METRIC_CATEGORIES = {
|
||||
'2.3.4i': 'Vulnerability Management',
|
||||
'2.3.6i': 'Vulnerability Management',
|
||||
'2.3.8i': 'Vulnerability Management',
|
||||
'5.2.4': 'Access & MFA',
|
||||
'5.2.5': 'Access & MFA',
|
||||
'5.2.6': 'Access & MFA',
|
||||
'5.3.4': 'Endpoint Protection',
|
||||
'5.5.2': 'End-of-Life OS',
|
||||
'5.5.4i': 'Vulnerability Management',
|
||||
'5.5.5': 'Decommissioned Assets',
|
||||
'5.8.1': 'Application Security',
|
||||
'7.1.1': 'Logging & Monitoring',
|
||||
'7.6.13': 'Disaster Recovery',
|
||||
'7.6.16': 'Disaster Recovery',
|
||||
'Missing_AppID': 'Asset Data Quality',
|
||||
'Missing_DF': 'Asset Data Quality',
|
||||
'Missing_OS': 'Asset Data Quality',
|
||||
}
|
||||
|
||||
# Columns that go into the main item fields — everything else becomes extra_json
|
||||
CORE_COLS = {
|
||||
'Preferred - Hostname', 'GRANITE - IPv4_Address', 'GRANITE - Type',
|
||||
'Team', 'Compliant', 'Source_Network', 'Vertical',
|
||||
'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM',
|
||||
}
|
||||
def load_config():
|
||||
"""Load parser configuration from compliance_config.json."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, 'compliance_config.json')
|
||||
|
||||
SKIP_SHEETS = {'Summary', 'CMDB_9box'}
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Configuration file not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in configuration file {config_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_config = load_config()
|
||||
METRIC_CATEGORIES = _config['metric_categories']
|
||||
CORE_COLS = set(_config['core_cols'])
|
||||
SKIP_SHEETS = set(_config['skip_sheets'])
|
||||
|
||||
|
||||
def safe_str(val):
|
||||
|
||||
@@ -26,6 +26,7 @@ const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const createComplianceRouter = require('./routes/compliance');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -234,6 +235,9 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth)
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
||||
|
||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
BIN
cve_database.db
Normal file
BIN
cve_database.db
Normal file
Binary file not shown.
0
cve_database.db.backup
Normal file
0
cve_database.db.backup
Normal file
0
database.db
Normal file
0
database.db
Normal file
1316
docs/atlasinfosec-api-spec.json
Normal file
1316
docs/atlasinfosec-api-spec.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,13 +77,13 @@ Click a device to open the detail panel showing:
|
||||
|
||||
### Adding Notes
|
||||
|
||||
You can add notes to any device/metric combination:
|
||||
You can add notes to one or more metrics on a device at once:
|
||||
1. Open the device detail panel
|
||||
2. Find the metric you want to annotate
|
||||
3. Type your note and save
|
||||
2. Select the metrics the note applies to using the chip selector — click individual metric chips to toggle them, or use **Select All** / **Deselect All** for bulk selection
|
||||
3. Type your note and click send
|
||||
4. Notes are timestamped and attributed to the logged-in user
|
||||
|
||||
Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant.
|
||||
When a note is submitted for multiple metrics, it appears as a single grouped entry in the notes history with all associated metric chips displayed together. Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant.
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CVE Tracking & NVD Sync Guide
|
||||
# CVE Tracking & NVD Sync Spec
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
154
docs/security-remediation-plan.md
Normal file
154
docs/security-remediation-plan.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Security Remediation Plan
|
||||
|
||||
Based on the External Data Handling security audit (April 2026). 17 findings total — 0 Critical, 2 High, 6 Medium, 6 Low, 3 Informational. Ordered by priority based on real-world exploitability and effort.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Data Exposure & XSS (High Priority)
|
||||
|
||||
### 1. L-4: Authenticate /uploads static file access
|
||||
**Location:** `server.js:127`
|
||||
**Risk:** Uploaded documents (vulnerability data, compliance files) served without authentication. Anyone with the URL can access them.
|
||||
**Fix:** Replace `express.static('/uploads')` with a route handler that runs `requireAuth(db)` before streaming the file. Use `res.sendFile()` with the validated path.
|
||||
**Effort:** Small — single route change.
|
||||
|
||||
### 2. M-6: Sanitize Mermaid SVG output with DOMPurify
|
||||
**Location:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||
**Risk:** Mermaid renders SVG which is injected via `innerHTML`. If KB content contains malicious markup, this is a stored XSS vector.
|
||||
**Fix:** Install `dompurify`, sanitize the SVG string before assigning to `innerHTML`. Use `DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } })`.
|
||||
**Effort:** Small — add dependency, wrap one line.
|
||||
|
||||
### 3. M-4: Strip server file paths from compliance preview response
|
||||
**Location:** `backend/routes/compliance.js:278`
|
||||
**Risk:** Full server-side file path returned to client. Helps attackers map the filesystem.
|
||||
**Fix:** Return only the filename (use `path.basename()`) instead of the full path. Or return a reference ID that maps to the file server-side.
|
||||
**Effort:** Small — one-line change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Deployment & Setup Hygiene
|
||||
|
||||
### 4. H-2: Add SESSION_SECRET to .env.example and setup-env.sh
|
||||
**Location:** `backend/.env.example`, `backend/setup-env.sh`
|
||||
**Risk:** Fresh deployments fail with no guidance on required env vars.
|
||||
**Fix:** Add `SESSION_SECRET=` to `.env.example` with a comment explaining it should be a random 64+ character string. Add generation logic to `setup-env.sh` (e.g., `openssl rand -hex 32`).
|
||||
**Effort:** Small.
|
||||
|
||||
### 5. I-3: Set user_group on default admin in setup.js
|
||||
**Location:** `backend/setup.js:180`
|
||||
**Risk:** Default admin created without `user_group`, potentially locked out of `requireGroup`-protected routes on fresh install.
|
||||
**Fix:** Set `user_group = 'Admin'` in the INSERT statement for the default admin user.
|
||||
**Effort:** Trivial — one column added to the INSERT.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Error Message Sanitization (Batch)
|
||||
|
||||
### 6. L-2: Sanitize Python parser error messages
|
||||
**Location:** `backend/routes/compliance.js:284`
|
||||
**Risk:** Stack traces and server paths leaked to client when Python parser fails.
|
||||
**Fix:** Catch the error, log the full details server-side, return a generic "Compliance file parsing failed" message to the client.
|
||||
**Effort:** Small.
|
||||
|
||||
### 7. L-3: Sanitize Ivanti API error responses
|
||||
**Location:** `backend/routes/ivantiFpWorkflow.js:393`
|
||||
**Risk:** Raw Ivanti API error body forwarded to client, potentially exposing internal API details.
|
||||
**Fix:** Log the raw error server-side, return a generic "Ivanti API request failed" message to the client.
|
||||
**Effort:** Small.
|
||||
|
||||
### 8. L-6: Remove group name from requireGroup error response
|
||||
**Location:** `backend/middleware/auth.js:60`
|
||||
**Risk:** Error response leaks the user's current group name, which is minor info disclosure.
|
||||
**Fix:** Change the error message from something like "User group 'Viewer' not authorized" to "Insufficient permissions."
|
||||
**Effort:** Trivial.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security Headers
|
||||
|
||||
### 9. M-1: Add Content-Security-Policy header
|
||||
**Location:** `server.js:107-113`
|
||||
**Risk:** No CSP means no browser-side XSS mitigation layer.
|
||||
**Fix:** Add a CSP header via middleware. Start with a report-only policy to avoid breaking things, then tighten. Suggested baseline:
|
||||
```
|
||||
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'
|
||||
```
|
||||
Note: `'unsafe-inline'` for styles is needed because the app uses inline style objects extensively. Evaluate whether `script-src 'self'` breaks anything (it shouldn't with CRA).
|
||||
**Effort:** Medium — needs testing to ensure nothing breaks.
|
||||
|
||||
### 10. M-2: Add Strict-Transport-Security (HSTS) header
|
||||
**Location:** `server.js:107-113`
|
||||
**Risk:** No HSTS means browsers don't enforce HTTPS on subsequent visits.
|
||||
**Fix:** Add `Strict-Transport-Security: max-age=31536000; includeSubDomains` header. Only apply when running behind HTTPS (check `req.secure` or a trusted proxy header). Do NOT enable if the app is accessed over plain HTTP.
|
||||
**Effort:** Small, but verify deployment is HTTPS-only first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Operational Maintenance
|
||||
|
||||
### 11. L-5: Add expired session cleanup
|
||||
**Location:** `backend/middleware/auth.js:271`
|
||||
**Risk:** Sessions table grows indefinitely. Not a security exploit, but degrades performance over time.
|
||||
**Fix:** Add a cleanup function that runs on server startup (and optionally on a setInterval) to DELETE sessions where `expires_at < CURRENT_TIMESTAMP`. Run once at boot, then every 6 hours.
|
||||
**Effort:** Small.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Session Signing (Larger Effort)
|
||||
|
||||
### 12. H-1: Use SESSION_SECRET for HMAC-signed session tokens
|
||||
**Location:** `server.js:33`
|
||||
**Risk:** Session tokens are random bytes stored in DB with no signing. An attacker with DB read access can replay any session. For self-hosted SQLite, DB access already implies full compromise, so this is a defense-in-depth measure.
|
||||
**Fix:** When creating a session, generate a random token and store its HMAC (using SESSION_SECRET) in the DB. On validation, recompute the HMAC and compare. This means a DB dump alone isn't enough to forge sessions — the attacker also needs the secret.
|
||||
**Effort:** Medium — touches session creation, validation, and requires SESSION_SECRET to actually be wired in.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Investigate Before Changing
|
||||
|
||||
### 13. M-3: Review application/octet-stream in MIME allowlist
|
||||
**Location:** `server.js:62`
|
||||
**Risk:** Allows uploads that bypass MIME type checking. May be intentional for specific file types.
|
||||
**Action:** Check what file types are uploaded that resolve to `application/octet-stream`. If none are legitimate, remove it from the allowlist. If some are (e.g., `.db` files, binary exports), consider adding those specific MIME types instead.
|
||||
**Effort:** Investigation first, then trivial change.
|
||||
|
||||
### 14. M-5: Evaluate CORS HTTP origin policy
|
||||
**Location:** `server.js:38-40`
|
||||
**Risk:** CORS allows HTTP origins, no HTTPS enforcement.
|
||||
**Action:** Check if production runs behind a reverse proxy with HTTPS termination. If yes, the backend legitimately sees HTTP origins from the proxy. If production traffic is ever plain HTTP end-to-end, restrict CORS to HTTPS origins only.
|
||||
**Effort:** Investigation first, then small config change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Low Priority / Monitor
|
||||
|
||||
### 15. L-1: Add startup warning for IVANTI_SKIP_TLS=true
|
||||
**Location:** `backend/helpers/ivantiApi.js:28`
|
||||
**Risk:** TLS validation disabled silently. Acceptable in dev, risky if accidentally left on in production.
|
||||
**Fix:** Add a `console.warn('⚠ IVANTI_SKIP_TLS is enabled — TLS certificate validation is disabled')` at startup when the flag is set.
|
||||
**Effort:** Trivial.
|
||||
|
||||
### 16. I-1: Monitor react-scripts version
|
||||
**Location:** `frontend/package.json`
|
||||
**Risk:** Build-time only, not runtime. No immediate action needed.
|
||||
**Action:** Upgrade to latest react-scripts when convenient. Consider migrating to Vite if a major frontend overhaul is planned.
|
||||
|
||||
### 17. I-2: Monitor xlsx dependency
|
||||
**Location:** `frontend/package.json`
|
||||
**Risk:** Community fork, unmaintained since 2022. Used for spreadsheet parsing.
|
||||
**Action:** Monitor for security advisories. If a vulnerability is found, evaluate alternatives (e.g., `exceljs`, `sheetjs` pro). No immediate action needed unless a CVE is published against it.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Items | Effort | Impact |
|
||||
|-------|-------|--------|--------|
|
||||
| 1 — Data Exposure & XSS | L-4, M-6, M-4 | Small | High |
|
||||
| 2 — Deployment Hygiene | H-2, I-3 | Small | Medium |
|
||||
| 3 — Error Sanitization | L-2, L-3, L-6 | Small | Low-Medium |
|
||||
| 4 — Security Headers | M-1, M-2 | Medium | Medium |
|
||||
| 5 — Session Cleanup | L-5 | Small | Low |
|
||||
| 6 — Session Signing | H-1 | Medium | Medium |
|
||||
| 7 — Investigate | M-3, M-5 | Investigation | TBD |
|
||||
| 8 — Monitor | L-1, I-1, I-2 | Trivial | Low |
|
||||
@@ -41,5 +41,16 @@
|
||||
"last 1 firefox 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +544,16 @@ body {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes confirmFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes confirmSlideUp {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Tooltip with enhanced styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
|
||||
@@ -8,10 +8,12 @@ import AuditLog from './components/AuditLog';
|
||||
import NvdSyncModal from './components/NvdSyncModal';
|
||||
import NavDrawer from './components/NavDrawer';
|
||||
import CalendarWidget from './components/CalendarWidget';
|
||||
import ConfirmModal from './components/ConfirmModal';
|
||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
import CompliancePage from './components/pages/CompliancePage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
import './App.css';
|
||||
|
||||
@@ -240,6 +242,9 @@ export default function App() {
|
||||
const [archiveList, setArchiveList] = useState([]);
|
||||
const [archiveListLoading, setArchiveListLoading] = useState(false);
|
||||
|
||||
// Confirmation modal state — replaces window.confirm()
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
const toggleCVEExpand = (cveId) => {
|
||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||
};
|
||||
@@ -531,10 +536,12 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (docId, cveId, vendor) => {
|
||||
if (!window.confirm('Are you sure you want to delete this document?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm({
|
||||
title: 'Delete Document',
|
||||
message: 'Are you sure you want to delete this document?',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/documents/${docId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -551,6 +558,8 @@ export default function App() {
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCVE = (cve) => {
|
||||
@@ -643,10 +652,12 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteCVEEntry = async (cve) => {
|
||||
if (!window.confirm(`Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm({
|
||||
title: 'Delete Vendor Entry',
|
||||
message: `Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const url = `${API_BASE}/cves/${cve.id}`;
|
||||
console.log('DELETE request to:', url);
|
||||
@@ -671,13 +682,17 @@ export default function App() {
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEntireCVE = async (cveId, vendorCount) => {
|
||||
if (!window.confirm(`Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm({
|
||||
title: 'Delete Entire CVE',
|
||||
message: `Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`,
|
||||
confirmText: 'Delete All',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`;
|
||||
console.log('DELETE request to:', url);
|
||||
@@ -702,6 +717,8 @@ export default function App() {
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTicket = async (e) => {
|
||||
@@ -769,7 +786,12 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteTicket = async (ticket) => {
|
||||
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
|
||||
setPendingConfirm({
|
||||
title: 'Delete Ticket',
|
||||
message: `Delete ticket ${ticket.ticket_key}?`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -781,6 +803,8 @@ export default function App() {
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openAddTicketForCVE = (cve_id, vendor) => {
|
||||
@@ -854,7 +878,12 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteArcherTicket = async (ticket) => {
|
||||
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
|
||||
setPendingConfirm({
|
||||
title: 'Delete Archer Ticket',
|
||||
message: `Delete Archer ticket ${ticket.exc_number}?`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -866,6 +895,8 @@ export default function App() {
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||
@@ -1012,11 +1043,8 @@ export default function App() {
|
||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'admin' && isAdmin() && (
|
||||
<div className="space-y-6">
|
||||
<UserManagement onClose={() => setCurrentPage('home')} />
|
||||
</div>
|
||||
)}
|
||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||
|
||||
{/* User Management Modal */}
|
||||
{showUserManagement && (
|
||||
@@ -2422,6 +2450,17 @@ export default function App() {
|
||||
|
||||
</div>}
|
||||
{/* End Three Column Layout */}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant={pendingConfirm?.variant || 'danger'}
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
74
frontend/src/components/AtlasBadge.js
Normal file
74
frontend/src/components/AtlasBadge.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AtlasBadge — small inline pill badge for the Host column on ReportingPage.
|
||||
// Shows Atlas action plan coverage status for a given host.
|
||||
//
|
||||
// Props:
|
||||
// hostId — numeric host identifier
|
||||
// atlasStatus — { host_id, has_action_plan, plan_count, synced_at } or undefined
|
||||
// onClick — callback when badge is clicked (opens slide-out panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const warningStyle = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
borderRadius: '9999px',
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
marginLeft: '6px',
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
border: '1px solid rgba(245,158,11,0.4)',
|
||||
color: '#F59E0B',
|
||||
};
|
||||
|
||||
const successStyle = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
borderRadius: '9999px',
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
marginLeft: '6px',
|
||||
background: 'rgba(16,185,129,0.12)',
|
||||
border: '1px solid rgba(16,185,129,0.4)',
|
||||
color: '#10B981',
|
||||
};
|
||||
|
||||
export default function AtlasBadge({ hostId, atlasStatus, onClick }) {
|
||||
// No status data — render nothing
|
||||
if (!atlasStatus) return null;
|
||||
|
||||
const hasPlan = atlasStatus.plan_count > 0;
|
||||
const style = hasPlan ? successStyle : warningStyle;
|
||||
const label = hasPlan ? String(atlasStatus.plan_count) : '0';
|
||||
|
||||
return (
|
||||
<span
|
||||
style={style}
|
||||
title={
|
||||
hasPlan
|
||||
? `${atlasStatus.plan_count} Atlas action plan${atlasStatus.plan_count !== 1 ? 's' : ''}`
|
||||
: 'No Atlas action plans — needs attention'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onClick) onClick(hostId);
|
||||
}}
|
||||
data-testid="atlas-badge"
|
||||
>
|
||||
<Shield style={{ width: 12, height: 12, flexShrink: 0 }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
870
frontend/src/components/AtlasSlideOutPanel.js
Normal file
870
frontend/src/components/AtlasSlideOutPanel.js
Normal file
@@ -0,0 +1,870 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan type badge colors
|
||||
// ---------------------------------------------------------------------------
|
||||
const PLAN_TYPE_COLORS = {
|
||||
remediation: '#0EA5E9',
|
||||
decommission: '#EF4444',
|
||||
false_positive: '#F59E0B',
|
||||
risk_acceptance: '#A855F7',
|
||||
scan_exclusion: '#64748B',
|
||||
};
|
||||
|
||||
const VALID_PLAN_TYPES = Object.keys(PLAN_TYPE_COLORS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared inline style constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const ACCENT = '#0EA5E9';
|
||||
|
||||
const panelStyle = {
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
|
||||
background: '#0A1220',
|
||||
borderLeft: '1px solid rgba(14,165,233,0.15)',
|
||||
boxShadow: '-8px 0 32px rgba(0,0,0,0.6)',
|
||||
zIndex: 41,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
};
|
||||
|
||||
const backdropStyle = {
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
zIndex: 40,
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
padding: '1.25rem 1.25rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const sectionTitleStyle = {
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
|
||||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: '#475569', marginBottom: '0.75rem',
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(14,165,233,0.06)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#E2E8F0',
|
||||
padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
display: 'block',
|
||||
fontSize: '0.68rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: '#94A3B8',
|
||||
marginBottom: '0.3rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
const primaryBtnStyle = {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'rgba(14,165,233,0.15)',
|
||||
border: '1px solid #0EA5E9',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#38BDF8',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom dropdown — dark-themed replacement for native <select>
|
||||
// ---------------------------------------------------------------------------
|
||||
function PlanTypeDropdown({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const color = PLAN_TYPE_COLORS[value] || '#94A3B8';
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
...inputStyle,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
borderColor: open ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
||||
{value.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: '4px',
|
||||
background: '#0F1A2E',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
zIndex: 50, overflow: 'hidden',
|
||||
}}>
|
||||
{VALID_PLAN_TYPES.map(t => {
|
||||
const c = PLAN_TYPE_COLORS[t] || '#94A3B8';
|
||||
const isSelected = t === value;
|
||||
return (
|
||||
<div
|
||||
key={t}
|
||||
onClick={() => { onChange(t); setOpen(false); }}
|
||||
style={{
|
||||
padding: '0.5rem 0.625rem',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
color: c,
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlanTypeBadge — colored pill for plan type
|
||||
// ---------------------------------------------------------------------------
|
||||
function PlanTypeBadge({ type }) {
|
||||
const color = PLAN_TYPE_COLORS[type] || '#94A3B8';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}50`,
|
||||
borderRadius: '0.25rem',
|
||||
color,
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.03em',
|
||||
}}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlanCard — displays a single action plan
|
||||
// ---------------------------------------------------------------------------
|
||||
function PlanCard({ plan, canWrite, onSaveEdit, editingId, onStartEdit, onCancelEdit }) {
|
||||
const isEditing = editingId === (plan.action_plan_id || plan.id);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
commit_date: plan.commit_date || '',
|
||||
qualys_id: plan.qualys_id || '',
|
||||
active_host_findings_id: plan.active_host_findings_id || '',
|
||||
jira_vnr: plan.jira_vnr || '',
|
||||
archer_exc: plan.archer_exc || '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editError, setEditError] = useState(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await onSaveEdit(plan.action_plan_id || plan.id, editForm);
|
||||
} catch (err) {
|
||||
setEditError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = !!plan._localPending;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||
background: isPending ? 'rgba(245,158,11,0.06)' : 'rgba(14,165,233,0.04)',
|
||||
border: isPending ? '1px solid rgba(245,158,11,0.25)' : '1px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
|
||||
{isPending && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '3px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
border: '1px solid rgba(245,158,11,0.35)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#F59E0B',
|
||||
fontSize: '0.6rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
{plan.status && !isPending && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '3px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
background: 'rgba(16,185,129,0.12)',
|
||||
border: '1px solid rgba(16,185,129,0.35)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#10B981',
|
||||
fontSize: '0.6rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{plan.status}
|
||||
</span>
|
||||
)}
|
||||
{canWrite && !isEditing && !isPending && (
|
||||
<button
|
||||
onClick={() => onStartEdit(plan.action_plan_id || plan.id)}
|
||||
title="Edit plan"
|
||||
style={{
|
||||
background: 'none', border: '1px solid rgba(14,165,233,0.15)',
|
||||
borderRadius: '0.25rem', padding: '0.2rem',
|
||||
cursor: 'pointer', color: '#475569',
|
||||
transition: 'all 0.15s', lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = ACCENT; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.15)'; }}
|
||||
>
|
||||
<Edit3 style={{ width: 11, height: 11 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>Commit</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
|
||||
</div>
|
||||
{plan.jira_vnr && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>VNR</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.jira_vnr}</span>
|
||||
</div>
|
||||
)}
|
||||
{plan.archer_exc && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>EXC</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.archer_exc}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={labelStyle}>Commit Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.commit_date}
|
||||
onChange={e => setEditForm({ ...editForm, commit_date: e.target.value })}
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={labelStyle}>Qualys ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.qualys_id}
|
||||
onChange={e => setEditForm({ ...editForm, qualys_id: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={labelStyle}>Findings ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.active_host_findings_id}
|
||||
onChange={e => setEditForm({ ...editForm, active_host_findings_id: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={labelStyle}>Jira VNR</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.jira_vnr}
|
||||
onChange={e => setEditForm({ ...editForm, jira_vnr: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={labelStyle}>Archer EXC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.archer_exc}
|
||||
onChange={e => setEditForm({ ...editForm, archer_exc: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
{editError && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{editError}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ ...primaryBtnStyle, fontSize: '0.68rem', padding: '0.35rem 0.75rem', opacity: saving ? 0.6 : 1 }}
|
||||
>
|
||||
{saving ? <Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} /> : <Check style={{ width: 12, height: 12 }} />}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
style={{
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontSize: '0.68rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InactiveSection — collapsible history of overridden/inactive plans
|
||||
// ---------------------------------------------------------------------------
|
||||
function InactiveSection({ plans }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div style={{ padding: '0.75rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
|
||||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: '#475569', width: '100%',
|
||||
}}
|
||||
>
|
||||
<ChevronDown style={{ width: 12, height: 12, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
History ({plans.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<div style={{ marginTop: '0.625rem' }}>
|
||||
{plans.map((plan, idx) => (
|
||||
<div key={plan.action_plan_id || idx} style={{
|
||||
marginBottom: '0.5rem', padding: '0.5rem 0.625rem',
|
||||
background: 'rgba(100,116,139,0.04)',
|
||||
border: '1px solid rgba(100,116,139,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
opacity: 0.7,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem' }}>
|
||||
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
|
||||
<span style={{
|
||||
fontSize: '0.6rem', color: '#64748B',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{plan.status || 'inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Commit</span>
|
||||
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
|
||||
</div>
|
||||
{plan.created_at && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Created</span>
|
||||
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.created_at.split('T')[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AtlasSlideOutPanel — main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualysId, onClose, canWrite, onPlanChange }) {
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
// Create form state — prepopulate qualys_id and findings ID from the clicked finding
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
plan_type: 'remediation',
|
||||
commit_date: '',
|
||||
qualys_id: qualysId || '',
|
||||
active_host_findings_id: findingId || '',
|
||||
jira_vnr: '',
|
||||
archer_exc: '',
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [successMsg, setSuccessMsg] = useState(null);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Parse Atlas response — handles { active: [...], inactive: [...] } format
|
||||
// -----------------------------------------------------------------------
|
||||
function parseAtlasPlans(data) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object') {
|
||||
const active = Array.isArray(data.active) ? data.active : [];
|
||||
const inactive = Array.isArray(data.inactive) ? data.inactive : [];
|
||||
return [...active, ...inactive];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch plans
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to load plans (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const remotePlans = parseAtlasPlans(data);
|
||||
// Merge: keep local pending plans that aren't yet confirmed by Atlas
|
||||
setPlans(prev => {
|
||||
const localPending = prev.filter(p => p._localPending);
|
||||
const remoteIds = new Set(remotePlans.map(p => p.action_plan_id));
|
||||
// Remove local pending plans that now appear in remote (confirmed)
|
||||
const stillPending = localPending.filter(p => !remoteIds.has(p.action_plan_id));
|
||||
return [...remotePlans, ...stillPending];
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
|
||||
// Clear success message after 3s
|
||||
useEffect(() => {
|
||||
if (!successMsg) return;
|
||||
const t = setTimeout(() => setSuccessMsg(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [successMsg]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Create plan
|
||||
// -----------------------------------------------------------------------
|
||||
const handleCreate = async () => {
|
||||
if (!createForm.commit_date) {
|
||||
setCreateError('Commit date is required');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const body = {
|
||||
plan_type: createForm.plan_type,
|
||||
commit_date: createForm.commit_date,
|
||||
};
|
||||
if (createForm.qualys_id.trim()) body.qualys_id = createForm.qualys_id.trim();
|
||||
if (createForm.active_host_findings_id) body.active_host_findings_id = Number(createForm.active_host_findings_id);
|
||||
if (createForm.jira_vnr.trim()) body.jira_vnr = createForm.jira_vnr.trim();
|
||||
if (createForm.archer_exc.trim()) body.archer_exc = createForm.archer_exc.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Create failed (${res.status})`);
|
||||
|
||||
// Add optimistic local plan immediately — shown as "pending" until sync confirms
|
||||
const localPlan = {
|
||||
action_plan_id: data.action_plan_id || ('local-' + Date.now()),
|
||||
plan_type: body.plan_type,
|
||||
commit_date: body.commit_date,
|
||||
qualys_id: body.qualys_id || null,
|
||||
active_host_findings_id: body.active_host_findings_id || null,
|
||||
jira_vnr: body.jira_vnr || null,
|
||||
archer_exc: body.archer_exc || null,
|
||||
status: 'pending',
|
||||
_localPending: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setPlans(prev => [localPlan, ...prev]);
|
||||
|
||||
// Reset form
|
||||
setCreateForm({ plan_type: 'remediation', commit_date: '', qualys_id: qualysId || '', active_host_findings_id: findingId || '', jira_vnr: '', archer_exc: '' });
|
||||
setShowCreate(false);
|
||||
setSuccessMsg('Action plan created');
|
||||
if (onPlanChange) onPlanChange();
|
||||
} catch (err) {
|
||||
setCreateError(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edit plan
|
||||
// -----------------------------------------------------------------------
|
||||
const handleSaveEdit = async (actionPlanId, updates) => {
|
||||
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action_plan_id: actionPlanId, updates }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Update failed (${res.status})`);
|
||||
|
||||
setEditingId(null);
|
||||
setSuccessMsg('Action plan updated');
|
||||
await fetchPlans();
|
||||
if (onPlanChange) onPlanChange();
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={onClose} style={backdropStyle} data-testid="atlas-panel-backdrop" />
|
||||
|
||||
{/* Panel */}
|
||||
<div style={panelStyle} data-testid="atlas-slide-out-panel">
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.3rem' }}>
|
||||
<Shield style={{ width: 16, height: 16, color: ACCENT, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#E2E8F0', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||
{hostName || 'Unknown Host'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
Host ID: {hostId}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||
data-testid="atlas-panel-close"
|
||||
>
|
||||
<X style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{successMsg && (
|
||||
<div style={{
|
||||
margin: '0.75rem 1.25rem 0', padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(16,185,129,0.1)',
|
||||
border: '1px solid rgba(16,185,129,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#10B981', fontSize: '0.75rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
}}>
|
||||
<Check style={{ width: 14, height: 14 }} />{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0' }}>
|
||||
<Loader style={{ width: 28, height: 28, color: ACCENT, animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem', alignItems: 'center' }}>
|
||||
<AlertCircle style={{ width: 16, height: 16, flexShrink: 0 }} />{error}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchPlans}
|
||||
style={{
|
||||
...primaryBtnStyle,
|
||||
fontSize: '0.68rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan list */}
|
||||
{!loading && !error && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Section: Active plans */}
|
||||
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<div style={sectionTitleStyle}>
|
||||
<Shield style={{ width: 14, height: 14, color: ACCENT }} />
|
||||
Active Plans ({plans.filter(p => p.status === 'active' || p._localPending).length})
|
||||
</div>
|
||||
|
||||
{plans.filter(p => p.status === 'active' || p._localPending).length === 0 && (
|
||||
<div style={{ color: '#475569', fontSize: '0.75rem', fontStyle: 'italic' }}>
|
||||
No active action plans for this host.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plans.filter(p => p.status === 'active' || p._localPending).map((plan, idx) => (
|
||||
<PlanCard
|
||||
key={plan.action_plan_id || plan.id || idx}
|
||||
plan={plan}
|
||||
canWrite={canWrite}
|
||||
editingId={editingId}
|
||||
onStartEdit={setEditingId}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section: Inactive plans (history) — collapsible */}
|
||||
{plans.filter(p => p.status !== 'active' && !p._localPending).length > 0 && (
|
||||
<InactiveSection plans={plans.filter(p => p.status !== 'active' && !p._localPending)} />
|
||||
)}
|
||||
|
||||
{/* Section: Create form */}
|
||||
{canWrite && (
|
||||
<div style={{ padding: '1rem 1.25rem' }}>
|
||||
{!showCreate ? (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
style={{
|
||||
...primaryBtnStyle,
|
||||
width: '100%',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
data-testid="atlas-create-plan-btn"
|
||||
>
|
||||
<Plus style={{ width: 14, height: 14 }} />
|
||||
New Action Plan
|
||||
</button>
|
||||
) : (
|
||||
<div data-testid="atlas-create-form">
|
||||
<div style={sectionTitleStyle}>
|
||||
<Plus style={{ width: 14, height: 14, color: ACCENT }} />
|
||||
Create Action Plan
|
||||
</div>
|
||||
|
||||
{/* Plan type */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Plan Type</label>
|
||||
<PlanTypeDropdown
|
||||
value={createForm.plan_type}
|
||||
onChange={val => setCreateForm({ ...createForm, plan_type: val })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commit date */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Commit Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={createForm.commit_date}
|
||||
onChange={e => setCreateForm({ ...createForm, commit_date: e.target.value })}
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Qualys ID */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Qualys ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.qualys_id}
|
||||
onChange={e => setCreateForm({ ...createForm, qualys_id: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Host Findings ID */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Findings ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createForm.active_host_findings_id}
|
||||
onChange={e => setCreateForm({ ...createForm, active_host_findings_id: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Jira VNR */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Jira VNR</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.jira_vnr}
|
||||
onChange={e => setCreateForm({ ...createForm, jira_vnr: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Archer EXC */}
|
||||
<div style={{ marginBottom: '0.625rem' }}>
|
||||
<label style={labelStyle}>Archer EXC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.archer_exc}
|
||||
onChange={e => setCreateForm({ ...createForm, archer_exc: e.target.value })}
|
||||
placeholder="Optional"
|
||||
style={inputStyle}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create error */}
|
||||
{createError && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.625rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
style={{ ...primaryBtnStyle, opacity: creating ? 0.6 : 1 }}
|
||||
onMouseEnter={e => { if (!creating) { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; }}
|
||||
>
|
||||
{creating
|
||||
? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} />
|
||||
: <Check style={{ width: 14, height: 14 }} />}
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setCreateError(null); }}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/ConfirmModal.js
Normal file
171
frontend/src/components/ConfirmModal.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* ConfirmModal — themed replacement for window.confirm().
|
||||
*
|
||||
* Props:
|
||||
* open {boolean} Whether the modal is visible
|
||||
* title {string} Heading text (e.g. "Delete Document")
|
||||
* message {string|ReactNode} Body text / description
|
||||
* confirmText {string} Label for the confirm button (default "Confirm")
|
||||
* cancelText {string} Label for the cancel button (default "Cancel")
|
||||
* variant {"danger"|"warning"|"default"} Controls accent color (default "danger")
|
||||
* onConfirm {function} Called when user confirms
|
||||
* onCancel {function} Called when user cancels or presses Escape
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title = 'Confirm',
|
||||
message = 'Are you sure?',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
const confirmRef = useRef(null);
|
||||
|
||||
// Focus the confirm button when the modal opens and handle Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Small delay so the DOM is painted before we focus
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50);
|
||||
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onCancel?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accentMap = {
|
||||
danger: { color: '#EF4444', bg: 'rgba(239,68,68,0.10)', bgHover: 'rgba(239,68,68,0.18)', border: 'rgba(239,68,68,0.3)' },
|
||||
warning: { color: '#F59E0B', bg: 'rgba(245,158,11,0.10)', bgHover: 'rgba(245,158,11,0.18)', border: 'rgba(245,158,11,0.3)' },
|
||||
default: { color: '#0EA5E9', bg: 'rgba(14,165,233,0.10)', bgHover: 'rgba(14,165,233,0.18)', border: 'rgba(14,165,233,0.3)' },
|
||||
};
|
||||
const accent = accentMap[variant] || accentMap.danger;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 70,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
animation: 'confirmFadeIn 0.15s ease-out',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel?.(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${accent.border}`,
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 30px ${accent.color}10`,
|
||||
width: '100%', maxWidth: '420px',
|
||||
padding: '1.75rem 2rem',
|
||||
animation: 'confirmSlideUp 0.15s ease-out',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '32px', height: '32px', borderRadius: '0.5rem',
|
||||
background: accent.bg,
|
||||
border: `1px solid ${accent.border}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<AlertTriangle style={{ width: '16px', height: '16px', color: accent.color }} />
|
||||
</div>
|
||||
<div
|
||||
id="confirm-modal-title"
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.95rem', fontWeight: '700',
|
||||
color: accent.color,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
fontSize: '0.82rem', color: '#CBD5E1',
|
||||
lineHeight: '1.6', marginBottom: '1.5rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1, padding: '0.625rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8', cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||
e.currentTarget.style.color = '#CBD5E1';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||
e.currentTarget.style.color = '#94A3B8';
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
flex: 1.5, padding: '0.625rem',
|
||||
background: accent.bg,
|
||||
border: `1px solid ${accent.color}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: accent.color, cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = accent.bgHover;
|
||||
e.currentTarget.style.boxShadow = `0 0 20px ${accent.color}25`;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = accent.bg;
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -12,7 +13,7 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
const [result, setResult] = useState(null);
|
||||
const [existingArticles, setExistingArticles] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
// Fetch existing articles on mount
|
||||
useEffect(() => {
|
||||
fetchExistingArticles();
|
||||
@@ -117,10 +118,12 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
const handleDelete = async (id, articleTitle) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm({
|
||||
title: 'Delete Article',
|
||||
message: `Are you sure you want to delete "${articleTitle}"?`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -138,6 +141,8 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
console.error('Error deleting article:', err);
|
||||
setError('Failed to delete article');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -379,6 +384,17 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant="danger"
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// ⚠️ CONVENTION: This component uses Tailwind utility classes (e.g. bg-white, rounded-lg, hover:bg-gray-50)
|
||||
// instead of inline styles or App.css global classes. This is the legacy modal kept for UserMenu quick-access;
|
||||
// the themed replacement lives in AdminPage.js.
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -35,6 +39,7 @@ export default function UserManagement({ onClose }) {
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
@@ -55,29 +60,10 @@ export default function UserManagement({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmGroupChange = (targetUser, newGroup) => {
|
||||
let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`;
|
||||
|
||||
// Extra warning when downgrading an Admin user
|
||||
if (targetUser.group === 'Admin' && newGroup !== 'Admin') {
|
||||
message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`;
|
||||
}
|
||||
|
||||
return window.confirm(message);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const doSubmit = async () => {
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
|
||||
// If editing and group changed, show confirmation dialog
|
||||
if (editingUser && formData.group !== editingUser.group) {
|
||||
if (!confirmGroupChange(editingUser, formData.group)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editingUser
|
||||
? `${API_BASE}/users/${editingUser.id}`
|
||||
@@ -117,6 +103,31 @@ export default function UserManagement({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If editing and group changed, show confirmation modal
|
||||
if (editingUser && formData.group !== editingUser.group) {
|
||||
let message = `Are you sure you want to change ${editingUser.username}'s group from ${editingUser.group} to ${formData.group}?`;
|
||||
if (editingUser.group === 'Admin' && formData.group !== 'Admin') {
|
||||
message += ` WARNING: You are removing Admin privileges from ${editingUser.username}. They will lose full system access.`;
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Change User Group',
|
||||
message,
|
||||
confirmText: 'Change Group',
|
||||
variant: editingUser.group === 'Admin' ? 'danger' : 'warning',
|
||||
onConfirm: () => {
|
||||
setPendingConfirm(null);
|
||||
doSubmit();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
doSubmit();
|
||||
};
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
@@ -131,10 +142,12 @@ export default function UserManagement({ onClose }) {
|
||||
};
|
||||
|
||||
const handleDelete = async (userId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingConfirm({
|
||||
title: 'Delete User',
|
||||
message: 'Are you sure you want to delete this user?',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -151,6 +164,8 @@ export default function UserManagement({ onClose }) {
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleActive = async (user) => {
|
||||
@@ -418,6 +433,17 @@ export default function UserManagement({ onClose }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant={pendingConfirm?.variant || 'danger'}
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1401
frontend/src/components/pages/AdminPage.js
Normal file
1401
frontend/src/components/pages/AdminPage.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2 } from 'lucide-react';
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
const [selectedMetrics, setSelectedMetrics] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [noteError, setNoteError] = useState(null);
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -90,6 +92,29 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (noteId, hasGroup) => {
|
||||
setPendingConfirm({
|
||||
title: 'Delete Note',
|
||||
message: 'Delete this note?',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const url = hasGroup
|
||||
? `${API_BASE}/compliance/notes/${noteId}?group=true`
|
||||
: `${API_BASE}/compliance/notes/${noteId}`;
|
||||
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete note');
|
||||
await fetchDetail();
|
||||
if (onNoteAdded) onNoteAdded();
|
||||
} catch (err) {
|
||||
setNoteError(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
|
||||
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
|
||||
|
||||
@@ -227,9 +252,25 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||||
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteNote(g.notes[0].id, !!g.notes[0].group_id)}
|
||||
title="Delete note"
|
||||
style={{
|
||||
background: 'none', border: '1px solid rgba(239,68,68,0.15)',
|
||||
borderRadius: '0.25rem', padding: '0.2rem',
|
||||
cursor: 'pointer', color: '#334155',
|
||||
transition: 'all 0.15s', lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.5)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.15)'; }}
|
||||
>
|
||||
<Trash2 style={{ width: '11px', height: '11px' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
|
||||
</div>
|
||||
@@ -339,6 +380,17 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant="danger"
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
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 TEAL = '#14B8A6';
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,18 +46,83 @@ function pctDisplay(pct) {
|
||||
return `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
// Deduplicate summary entries — one per metric_id for the selected team
|
||||
// (exclude aggregate "ALL: NTS-AEO" rows)
|
||||
function teamMetrics(entries, team) {
|
||||
return entries.filter(e => e.team === team);
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricHealthCard({ entry, active, onClick }) {
|
||||
function VariantPill({ entry, label }) {
|
||||
const color = statusColor(entry.status);
|
||||
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}`,
|
||||
}} />
|
||||
)}
|
||||
{label && <span style={{ color: '#94A3B8' }}>{label}</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 (
|
||||
<button
|
||||
@@ -66,33 +139,63 @@ function MetricHealthCard({ entry, active, onClick }) {
|
||||
transition: 'all 0.15s',
|
||||
minWidth: '160px',
|
||||
flex: '1 1 0',
|
||||
position: 'relative',
|
||||
}}
|
||||
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 */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||
{entry.metric_id}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem', paddingRight: '1.25rem' }}>
|
||||
{family.metricId}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{entry.category}
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{family.category}
|
||||
</div>
|
||||
|
||||
{/* Compliance % */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
|
||||
{pctDisplay(entry.compliance_pct)}
|
||||
{/* Variant pills */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
||||
{family.entries.map((entry, i) => {
|
||||
// Only show a label when there are multiple variants to differentiate
|
||||
let label = null;
|
||||
if (family.entries.length > 1) {
|
||||
label = entry.priority || `#${i + 1}`;
|
||||
}
|
||||
return <VariantPill key={entry.metric_id + '-' + i} entry={entry} label={label} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
target {pctDisplay(entry.target)}
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginBottom: '0.5rem' }}>
|
||||
target {pctDisplay(family.target)}
|
||||
</div>
|
||||
|
||||
{/* Status pill */}
|
||||
<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',
|
||||
color, padding: '0.2rem 0.5rem',
|
||||
background: `${color}12`, borderRadius: '999px',
|
||||
@@ -103,7 +206,7 @@ function MetricHealthCard({ entry, active, onClick }) {
|
||||
background: color, flexShrink: 0,
|
||||
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||
}} />
|
||||
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
|
||||
{isOk ? 'OK' : family.worstStatus.replace(' of Target', '')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -143,7 +246,7 @@ function SeenBadge({ count }) {
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CompliancePage({ onNavigate }) {
|
||||
const { canWrite } = useAuth();
|
||||
const { canWrite, isAdmin } = useAuth();
|
||||
|
||||
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
@@ -155,6 +258,13 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedHost, setSelectedHost] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [rollbackConfirm, setRollbackConfirm] = useState(false);
|
||||
const [rollbackLoading, setRollbackLoading] = useState(false);
|
||||
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) => {
|
||||
try {
|
||||
@@ -198,12 +308,34 @@ export default function CompliancePage({ onNavigate }) {
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
};
|
||||
|
||||
const handleRollback = async () => {
|
||||
if (!lastUpload) return;
|
||||
setRollbackLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/rollback/${lastUpload.id}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Rollback failed');
|
||||
setRollbackResult(data);
|
||||
setRollbackConfirm(false);
|
||||
refresh();
|
||||
// Auto-dismiss result after 4 seconds
|
||||
setTimeout(() => setRollbackResult(null), 4000);
|
||||
} catch (err) {
|
||||
setRollbackResult({ error: err.message });
|
||||
} finally {
|
||||
setRollbackLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// In-memory filters
|
||||
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()));
|
||||
|
||||
const metrics = teamMetrics(summary.entries, activeTeam);
|
||||
const families = groupByMetricFamily(summary.entries, activeTeam);
|
||||
const lastUpload = summary.upload;
|
||||
|
||||
return (
|
||||
@@ -221,9 +353,30 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{lastUpload ? (
|
||||
<>
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||
</span>
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => setRollbackConfirm(true)}
|
||||
title="Rollback last upload"
|
||||
style={{
|
||||
background: 'none', border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: '0.25rem', padding: '0.15rem 0.4rem',
|
||||
cursor: 'pointer', color: '#64748B',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
fontSize: '0.62rem', fontFamily: 'monospace',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.25)'; }}
|
||||
>
|
||||
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||||
Rollback
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||||
)}
|
||||
@@ -290,7 +443,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{metrics.length > 0 ? (
|
||||
{families.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Metric Health — click to filter
|
||||
@@ -302,15 +455,81 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
key={entry.metric_id}
|
||||
entry={entry}
|
||||
active={metricFilter === entry.metric_id}
|
||||
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
|
||||
family={family}
|
||||
active={isActive}
|
||||
onClick={() => setMetricFilter(isActive ? null : familyIds)}
|
||||
onInfoClick={(metricId) => setInfoMetric(metricId)}
|
||||
definitionLookup={METRIC_DEFINITIONS}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
) : lastUpload === null ? (
|
||||
<div style={{
|
||||
@@ -439,6 +658,128 @@ export default function CompliancePage({ onNavigate }) {
|
||||
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 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 ──────────────────────────── */}
|
||||
{rollbackConfirm && lastUpload && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 60,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
|
||||
width: '100%', maxWidth: '420px',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#EF4444', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '1rem' }}>
|
||||
Rollback Upload
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: '1.5', marginBottom: '0.5rem' }}>
|
||||
This will reverse the most recent upload:
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
padding: '0.625rem 0.75rem', marginBottom: '1.25rem',
|
||||
border: '1px solid rgba(239,68,68,0.15)',
|
||||
}}>
|
||||
<div><span style={{ color: '#64748B' }}>File:</span> {lastUpload.report_date || 'unknown date'}</div>
|
||||
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#475569' }}>
|
||||
New items will be deleted, resolved items will be reactivated, and the upload record will be removed.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
onClick={() => setRollbackConfirm(false)}
|
||||
style={{
|
||||
flex: 1, padding: '0.625rem', background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
|
||||
color: '#64748B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRollback}
|
||||
disabled={rollbackLoading}
|
||||
style={{
|
||||
flex: 2, padding: '0.625rem',
|
||||
background: rollbackLoading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid #EF4444',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#EF4444', cursor: rollbackLoading ? 'wait' : 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
opacity: rollbackLoading ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.18)'; }}
|
||||
onMouseLeave={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; }}>
|
||||
{rollbackLoading
|
||||
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Rolling back…</>
|
||||
: <><RotateCcw style={{ width: '14px', height: '14px' }} /> Confirm Rollback</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Rollback result toast ────────────────────────────────── */}
|
||||
{rollbackResult && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 70,
|
||||
background: rollbackResult.error
|
||||
? 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${rollbackResult.error ? 'rgba(239,68,68,0.4)' : 'rgba(16,185,129,0.4)'}`,
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
padding: '0.875rem 1.25rem',
|
||||
maxWidth: '360px',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
color: rollbackResult.error ? '#F87171' : '#10B981',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setRollbackResult(null)}
|
||||
>
|
||||
{rollbackResult.error ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||||
{rollbackResult.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<RotateCcw style={{ width: '14px', height: '14px' }} />
|
||||
{rollbackResult.message}
|
||||
</div>
|
||||
{rollbackResult.rolled_back && (
|
||||
<div style={{ fontSize: '0.68rem', color: '#64748B' }}>
|
||||
{rollbackResult.rolled_back.items_deleted} items deleted, {rollbackResult.rolled_back.items_reactivated} reactivated
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -497,3 +838,6 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Named exports for testing
|
||||
export { computeWorstStatus, groupByMetricFamily };
|
||||
|
||||
@@ -1,16 +1,123 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
|
||||
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet, ChevronDown, ChevronRight, ShieldAlert, AlertTriangle, Info, Wrench } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// phase: idle → uploading → preview → committing → done | error
|
||||
/* ── Drift Findings Group sub-component ─────────────────────────── */
|
||||
const SEVERITY_CONFIG = {
|
||||
breaking: { label: 'Breaking', color: '#EF4444', Icon: ShieldAlert },
|
||||
silent_miss: { label: 'Silent-miss', color: '#F59E0B', Icon: AlertTriangle },
|
||||
cosmetic: { label: 'Cosmetic', color: '#94A3B8', Icon: Info },
|
||||
};
|
||||
|
||||
function DriftFindingsGroup({ severity, findings }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { label, color, Icon } = SEVERITY_CONFIG[severity];
|
||||
const COLLAPSE_THRESHOLD = 5;
|
||||
const needsCollapse = findings.length > COLLAPSE_THRESHOLD;
|
||||
const visibleFindings = needsCollapse && !expanded
|
||||
? findings.slice(0, COLLAPSE_THRESHOLD)
|
||||
: findings;
|
||||
const hiddenCount = findings.length - COLLAPSE_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
{/* Group header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Icon style={{ width: '14px', height: '14px', color, flexShrink: 0 }} />
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.75rem', fontWeight: '600', color,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.65rem', fontWeight: '700', color,
|
||||
background: `${color}18`, border: `1px solid ${color}40`,
|
||||
borderRadius: '0.25rem', padding: '0.1rem 0.4rem',
|
||||
minWidth: '1.25rem', textAlign: 'center',
|
||||
}}>
|
||||
{findings.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Findings list */}
|
||||
{visibleFindings.map((f, i) => (
|
||||
<div key={i} style={{
|
||||
borderLeft: `4px solid ${color}`,
|
||||
background: 'rgba(15,23,42,0.6)',
|
||||
borderRadius: '0 0.375rem 0.375rem 0',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '0.375rem',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.8rem', color: '#E2E8F0', lineHeight: '1.4',
|
||||
}}>
|
||||
{f.message}
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.7rem', color: `${color}CC`, marginTop: '0.2rem',
|
||||
}}>
|
||||
{f.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show more / less toggle */}
|
||||
{needsCollapse && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: '0.25rem',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.7rem', color: '#64748B', padding: '0.25rem 0',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#94A3B8'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
|
||||
>
|
||||
{expanded
|
||||
? <><ChevronDown style={{ width: '12px', height: '12px' }} /> Show less</>
|
||||
: <><ChevronRight style={{ width: '12px', height: '12px' }} /> Show {hiddenCount} more</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// phase: idle → uploading → drift-review (if findings) → preview → committing → done | error
|
||||
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [phase, setPhase] = useState('idle');
|
||||
const [previewData, setPreviewData] = useState(null);
|
||||
const [driftReport, setDriftReport] = useState(null);
|
||||
const [reconcileChanges, setReconcileChanges] = useState(null);
|
||||
const [reconciling, setReconciling] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [lastFile, setLastFile] = useState(null);
|
||||
const [lastSchema, setLastSchema] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
/** Check whether a drift report has any findings */
|
||||
const hasDriftFindings = (drift) => {
|
||||
if (!drift) return false;
|
||||
return (
|
||||
(drift.breaking && drift.breaking.length > 0) ||
|
||||
(drift.silent_miss && drift.silent_miss.length > 0) ||
|
||||
(drift.cosmetic && drift.cosmetic.length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
@@ -20,6 +127,9 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
|
||||
setPhase('uploading');
|
||||
setError(null);
|
||||
setDriftReport(null);
|
||||
setReconcileChanges(null);
|
||||
setLastFile(file);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -37,7 +147,20 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
}
|
||||
|
||||
setPreviewData(data);
|
||||
|
||||
// Store schema for reconcile requests
|
||||
if (data.schema) {
|
||||
setLastSchema(data.schema);
|
||||
}
|
||||
|
||||
// Drift routing: if drift is non-null and has findings, enter drift-review
|
||||
// If drift is null (failed) or has no findings, skip to preview
|
||||
if (data.drift && hasDriftFindings(data.drift)) {
|
||||
setDriftReport(data.drift);
|
||||
setPhase('drift-review');
|
||||
} else {
|
||||
setPhase('preview');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
@@ -72,6 +195,70 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Admin-only: reconcile config to fix breaking/silent-miss drift, then re-upload */
|
||||
const handleReconcile = async () => {
|
||||
if (!driftReport || reconciling) return;
|
||||
setReconciling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Call reconcile endpoint
|
||||
const reconcileRes = await fetch(`${API_BASE}/compliance/reconcile-config`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ drift: driftReport, schema: lastSchema }),
|
||||
});
|
||||
const reconcileData = await reconcileRes.json();
|
||||
|
||||
if (!reconcileRes.ok) throw new Error(reconcileData.error || 'Reconcile failed');
|
||||
|
||||
setReconcileChanges(reconcileData.changes);
|
||||
|
||||
// Step 2: Re-upload the same file to get a fresh drift check
|
||||
if (!lastFile) {
|
||||
setReconciling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase('uploading');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', lastFile);
|
||||
|
||||
const previewRes = await fetch(`${API_BASE}/compliance/preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const previewData = await previewRes.json();
|
||||
|
||||
if (!previewRes.ok) {
|
||||
throw new Error(previewData.error || 'Re-upload failed after reconcile');
|
||||
}
|
||||
|
||||
setPreviewData(previewData);
|
||||
setReconciling(false);
|
||||
|
||||
// Update schema for any subsequent reconcile
|
||||
if (previewData.schema) {
|
||||
setLastSchema(previewData.schema);
|
||||
}
|
||||
|
||||
if (previewData.drift && hasDriftFindings(previewData.drift)) {
|
||||
setDriftReport(previewData.drift);
|
||||
setPhase('drift-review');
|
||||
} else {
|
||||
setDriftReport(null);
|
||||
setPhase('preview');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setReconciling(false);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const TEAL = '#14B8A6';
|
||||
|
||||
return (
|
||||
@@ -87,7 +274,10 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
border: `1px solid ${TEAL}40`,
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
|
||||
width: '100%', maxWidth: '480px',
|
||||
width: '100%', maxWidth: phase === 'drift-review' ? '560px' : '480px',
|
||||
maxHeight: 'calc(100vh - 2rem)',
|
||||
overflowY: 'auto',
|
||||
transition: 'max-width 0.3s ease',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
{/* Header */}
|
||||
@@ -148,6 +338,163 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DRIFT-REVIEW — schema drift findings */}
|
||||
{phase === 'drift-review' && driftReport && (
|
||||
<>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.8rem', color: '#64748B',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
Schema Drift Review
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
maxHeight: '320px', overflowY: 'auto',
|
||||
marginBottom: '1rem',
|
||||
paddingRight: '0.25rem',
|
||||
}}>
|
||||
{driftReport.breaking && driftReport.breaking.length > 0 && (
|
||||
<DriftFindingsGroup severity="breaking" findings={driftReport.breaking} />
|
||||
)}
|
||||
{driftReport.silent_miss && driftReport.silent_miss.length > 0 && (
|
||||
<DriftFindingsGroup severity="silent_miss" findings={driftReport.silent_miss} />
|
||||
)}
|
||||
{driftReport.cosmetic && driftReport.cosmetic.length > 0 && (
|
||||
<DriftFindingsGroup severity="cosmetic" findings={driftReport.cosmetic} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{driftReport.breaking && driftReport.breaking.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.7rem', color: '#EF4444',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
{isAdmin()
|
||||
? 'Upload blocked — use "Reconcile Config" to auto-fix the parser configuration, or update it manually.'
|
||||
: 'Upload blocked — an admin must reconcile the parser configuration before this report can be uploaded.'}
|
||||
</div>
|
||||
)}
|
||||
{(!driftReport.breaking || driftReport.breaking.length === 0) &&
|
||||
driftReport.silent_miss && driftReport.silent_miss.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.7rem', color: '#F59E0B',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
Review warnings before proceeding. Data may be miscategorised or dropped.
|
||||
{isAdmin() && ' Use "Reconcile Config" to auto-add unknown metrics and sheets.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconcile changes summary (shown after a successful reconcile) */}
|
||||
{reconcileChanges && reconcileChanges.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.7rem', color: '#10B981',
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
border: '1px solid rgba(16,185,129,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.6',
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '0.25rem' }}>
|
||||
Config reconciled — {reconcileChanges.length} change(s) applied:
|
||||
</div>
|
||||
{reconcileChanges.map((c, i) => (
|
||||
<div key={i} style={{ color: '#94A3B8' }}>
|
||||
{c.action === 'added' ? '+' : '−'} {c.detail}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ color: '#10B981', marginTop: '0.25rem' }}>Re-uploading file…</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => { setPhase('idle'); setPreviewData(null); setDriftReport(null); setReconcileChanges(null); setLastFile(null); setLastSchema(null); }}
|
||||
style={{
|
||||
flex: 1, minWidth: '80px', padding: '0.625rem', background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
|
||||
color: '#64748B', cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
Cancel
|
||||
</button>
|
||||
{/* Admin reconcile button — shown when there are breaking or silent-miss findings */}
|
||||
{isAdmin() && ((driftReport.breaking && driftReport.breaking.length > 0) ||
|
||||
(driftReport.silent_miss && driftReport.silent_miss.length > 0)) && (
|
||||
<button
|
||||
onClick={handleReconcile}
|
||||
disabled={reconciling}
|
||||
style={{
|
||||
flex: 2, minWidth: '140px', padding: '0.625rem',
|
||||
background: reconciling ? 'rgba(245,158,11,0.05)' : 'rgba(245,158,11,0.1)',
|
||||
border: '1px solid rgba(245,158,11,0.5)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F59E0B',
|
||||
cursor: reconciling ? 'wait' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
opacity: reconciling ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.18)'; }}
|
||||
onMouseLeave={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.1)'; }}>
|
||||
{reconciling
|
||||
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Reconciling…</>
|
||||
: <><Wrench style={{ width: '14px', height: '14px' }} /> Reconcile Config</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setPhase('preview'); }}
|
||||
disabled={driftReport.breaking && driftReport.breaking.length > 0}
|
||||
style={{
|
||||
flex: 2, padding: '0.625rem',
|
||||
background: (driftReport.breaking && driftReport.breaking.length > 0)
|
||||
? 'rgba(100,116,139,0.08)'
|
||||
: `${TEAL}18`,
|
||||
border: `1px solid ${(driftReport.breaking && driftReport.breaking.length > 0) ? 'rgba(100,116,139,0.3)' : TEAL}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: (driftReport.breaking && driftReport.breaking.length > 0) ? '#475569' : TEAL,
|
||||
cursor: (driftReport.breaking && driftReport.breaking.length > 0) ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
opacity: (driftReport.breaking && driftReport.breaking.length > 0) ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!(driftReport.breaking && driftReport.breaking.length > 0)) {
|
||||
e.currentTarget.style.background = `${TEAL}28`;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!(driftReport.breaking && driftReport.breaking.length > 0)) {
|
||||
e.currentTarget.style.background = `${TEAL}18`;
|
||||
}
|
||||
}}>
|
||||
Continue to Preview
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PREVIEW — diff summary + confirm */}
|
||||
{phase === 'preview' && previewData && (
|
||||
<>
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen, Search, Upload, RefreshCw, Loader,
|
||||
AlertCircle, FileText, File, Trash2, X,
|
||||
AlertCircle, FileText, File, Trash2, X, // ⚠️ CONVENTION: FileText and File are imported but unused — remove if not needed
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import KnowledgeBaseModal from '../KnowledgeBaseModal';
|
||||
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
|
||||
import ConfirmModal from '../ConfirmModal'; // ⚠️ CONVENTION: ConfirmModal is imported but never used — either integrate it into handleDelete or remove this import
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const GREEN = '#10B981';
|
||||
@@ -216,6 +217,7 @@ export default function KnowledgeBasePage() {
|
||||
const [activeCategory, setActiveCategory] = useState('All');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetch
|
||||
@@ -241,7 +243,12 @@ export default function KnowledgeBasePage() {
|
||||
// Delete
|
||||
// -------------------------------------------------------------------------
|
||||
const handleDelete = useCallback(async (article) => {
|
||||
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
|
||||
setPendingConfirm({
|
||||
title: 'Delete Article',
|
||||
message: `Delete "${article.title}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
@@ -252,6 +259,8 @@ export default function KnowledgeBasePage() {
|
||||
} catch (err) {
|
||||
alert(`Failed to delete: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [selected]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -479,6 +488,17 @@ export default function KnowledgeBasePage() {
|
||||
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant="danger"
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
import RedirectModal from '../RedirectModal';
|
||||
import AtlasBadge from '../AtlasBadge';
|
||||
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -514,7 +516,7 @@ function SortIcon({ colKey, sort }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// OverrideCell — inline editable hostname/dns with amber dot when overridden
|
||||
// ---------------------------------------------------------------------------
|
||||
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
|
||||
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite, suffix }) {
|
||||
const effective = initialOverride ?? originalValue ?? '';
|
||||
const [value, setValue] = useState(effective);
|
||||
const [isOverridden, setOverridden] = useState(!!initialOverride);
|
||||
@@ -620,6 +622,7 @@ function OverrideCell({ findingId, field, originalValue, initialOverride, canWri
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{suffix}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -955,7 +958,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) {
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -1017,6 +1020,13 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
originalValue={finding.hostName}
|
||||
initialOverride={finding.overrides?.hostName ?? null}
|
||||
canWrite={canWrite}
|
||||
suffix={
|
||||
<AtlasBadge
|
||||
hostId={finding.hostId}
|
||||
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
|
||||
onClick={onAtlasBadgeClick}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'ipAddress':
|
||||
@@ -2273,6 +2283,11 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const exp = new Date(expirationDate + 'T00:00:00');
|
||||
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
|
||||
else {
|
||||
const maxDate = new Date(today);
|
||||
maxDate.setDate(maxDate.getDate() + 120);
|
||||
if (exp > maxDate) errs.expirationDate = 'Expiration date cannot be more than 120 days from today';
|
||||
}
|
||||
}
|
||||
setErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
@@ -2604,6 +2619,8 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
type="date"
|
||||
value={expirationDate}
|
||||
onChange={e => setExpirationDate(e.target.value)}
|
||||
min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()}
|
||||
max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()}
|
||||
disabled={submitting}
|
||||
style={errors.expirationDate ? inputErrorStyle : inputStyle}
|
||||
/>
|
||||
@@ -2975,7 +2992,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Expiration Date</label>
|
||||
<input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} disabled={isApproved} style={inputStyle} />
|
||||
<input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} disabled={isApproved} min={(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()} max={(() => { const d = new Date(); d.setDate(d.getDate() + 120); return d.toISOString().split('T')[0]; })()} style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Scope Override</label>
|
||||
@@ -3613,6 +3630,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const tooltipCacheRef = useRef(new Map());
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
// Atlas action plan state
|
||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||
const [atlasSyncing, setAtlasSyncing] = useState(false);
|
||||
const [atlasError, setAtlasError] = useState(null);
|
||||
const [atlasPanelOpen, setAtlasPanelOpen] = useState(false);
|
||||
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
|
||||
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
|
||||
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -3717,6 +3743,20 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAtlasStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const map = new Map();
|
||||
data.forEach(row => map.set(row.host_id, row));
|
||||
setAtlasStatusMap(map);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Failed to fetch status:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -3757,6 +3797,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchFPWorkflowCounts();
|
||||
fetchQueue();
|
||||
fetchFpSubmissions();
|
||||
fetchAtlasStatus();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
@@ -4430,6 +4471,46 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</button>
|
||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
||||
<button
|
||||
onClick={async () => {
|
||||
setAtlasSyncing(true);
|
||||
setAtlasError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Atlas sync failed');
|
||||
}
|
||||
await fetchAtlasStatus();
|
||||
} catch (err) {
|
||||
setAtlasError(err.message);
|
||||
} finally {
|
||||
setAtlasSyncing(false);
|
||||
}
|
||||
}}
|
||||
disabled={atlasSyncing || !canWrite()}
|
||||
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
color: atlasSyncing ? '#475569' : '#0EA5E9',
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
|
||||
opacity: !canWrite() ? 0.5 : 1,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{atlasSyncing
|
||||
? <Loader style={{ width: 13, height: 13, animation: 'spin 1s linear infinite' }} />
|
||||
: <Database style={{ width: 13, height: 13 }} />}
|
||||
Atlas
|
||||
</button>
|
||||
<button
|
||||
onClick={syncFindings}
|
||||
disabled={syncing || loading}
|
||||
@@ -4458,6 +4539,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
|
||||
</div>
|
||||
)}
|
||||
{atlasError && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
||||
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>Atlas: {atlasError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
@@ -4689,7 +4776,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
/>
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
@@ -4771,6 +4858,21 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
anchorRect={tooltipAnchorRect}
|
||||
cache={tooltipCacheRef}
|
||||
/>
|
||||
{atlasPanelOpen && atlasSelectedHostId && (
|
||||
<AtlasSlideOutPanel
|
||||
hostId={atlasSelectedHostId}
|
||||
hostName={atlasSelectedHostName}
|
||||
findingId={atlasSelectedFindingId}
|
||||
onClose={() => {
|
||||
setAtlasPanelOpen(false);
|
||||
setAtlasSelectedHostId(null);
|
||||
setAtlasSelectedHostName(null);
|
||||
setAtlasSelectedFindingId(null);
|
||||
}}
|
||||
canWrite={canWrite()}
|
||||
onPlanChange={fetchAtlasStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
1078
run_audit_tests.sh
Executable file
1078
run_audit_tests.sh
Executable file
File diff suppressed because it is too large
Load Diff
53228
swagger.json
Normal file
53228
swagger.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user