Add admin page overhaul and compliance schema drift check specs, compliance upload improvements, drift checker helper
This commit is contained in:
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
|
||||
|
||||
- [ ] 1. Create AdminPage component with page header and tab navigation
|
||||
- [ ] 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_
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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_
|
||||
|
||||
- [ ] 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**
|
||||
|
||||
- [ ] 3. Checkpoint — Verify user management panel
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 4. Implement AuditLogPanel
|
||||
- [ ] 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_
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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**
|
||||
|
||||
- [ ] 6. Checkpoint — Verify all panels and integration
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 7. Access control and final wiring
|
||||
- [ ] 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**
|
||||
|
||||
- [ ] 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
|
||||
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.
|
||||
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
|
||||
BIN
backend/cve_database.db.backupNVD
Normal file
BIN
backend/cve_database.db.backupNVD
Normal file
Binary file not shown.
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 };
|
||||
@@ -2,25 +2,35 @@
|
||||
// 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 /commit — commit a previewed upload to DB
|
||||
// GET /uploads — list all uploads
|
||||
// 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
|
||||
// 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 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']);
|
||||
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']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
@@ -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;
|
||||
@@ -629,6 +895,10 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
// 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 +951,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 +981,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 +1005,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 {
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
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,45 +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',
|
||||
'1.1.1': 'Logging & Monitoring',
|
||||
'1.1.3': 'Logging & Monitoring',
|
||||
'1.4.1': 'Logging & Monitoring',
|
||||
'5.2.7': 'Access & MFA',
|
||||
'5.2.8': 'Access & MFA',
|
||||
'7.1.4': 'Logging & Monitoring',
|
||||
'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', 'Vulns', 'Aging Dashboard'}
|
||||
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):
|
||||
|
||||
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
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
@@ -143,7 +143,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 +155,9 @@ 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 fetchSummary = useCallback(async (team) => {
|
||||
try {
|
||||
@@ -198,6 +201,28 @@ 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))
|
||||
@@ -221,9 +246,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>
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
@@ -439,6 +485,118 @@ export default function CompliancePage({ onNavigate }) {
|
||||
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,122 @@
|
||||
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 [phase, setPhase] = useState('idle');
|
||||
const [previewData, setPreviewData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
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;
|
||||
@@ -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);
|
||||
setPhase('preview');
|
||||
|
||||
// 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 && (
|
||||
<>
|
||||
|
||||
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