Add multi-metric note selection to compliance detail panel

This commit is contained in:
jramos
2026-04-16 14:28:44 -06:00
parent e1b0236874
commit f141fa58a1
7 changed files with 684 additions and 57 deletions

View File

@@ -0,0 +1 @@
{"specId": "8ec01dea-8d5c-40c1-8778-ec2992adb37f", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,290 @@
# Design Document: Multi-Metric Notes for Compliance Detail Panel
## Overview
This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the `ComplianceDetailPanel` uses a single-select `<select>` dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.
The change touches three layers:
1. **Database** — add a `group_id` column to `compliance_notes` so notes created together can be identified as a batch.
2. **API** — extend `POST /api/compliance/notes` to accept `metric_ids` (array) alongside the existing `metric_id` (string), inserting one row per metric inside a transaction.
3. **Frontend** — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by `group_id` in the display.
Backward compatibility is preserved: the existing `metric_id` field continues to work, and notes created before this feature (which lack a `group_id`) render exactly as they do today.
## Architecture
The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing `compliance.js` route file and `ComplianceDetailPanel.js` component.
```mermaid
sequenceDiagram
participant User
participant DetailPanel as ComplianceDetailPanel
participant API as POST /api/compliance/notes
participant DB as SQLite (compliance_notes)
User->>DetailPanel: Select multiple metrics via chip selector
User->>DetailPanel: Type note text, click Send
DetailPanel->>API: POST { hostname, metric_ids: [...], note }
API->>API: Validate inputs (note text, metric IDs)
API->>API: Generate group_id (UUID)
API->>DB: BEGIN TRANSACTION
loop For each metric_id in metric_ids
API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
end
API->>DB: COMMIT
API->>DetailPanel: 201 { notes: [...created rows] }
DetailPanel->>DetailPanel: Group notes by group_id, refresh display
```
## Components and Interfaces
### Backend
**Modified: `POST /api/compliance/notes`**
Request body accepts either format:
```javascript
// New multi-metric format
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }
// Legacy single-metric format (still supported)
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }
```
Precedence: if both `metric_id` and `metric_ids` are present, `metric_ids` wins.
Validation rules:
- `hostname` — required, string, 1300 chars, matches `/^[a-zA-Z0-9._-]+$/` (unchanged)
- `metric_ids` — array of strings, each non-empty and ≤50 chars, at least one entry
- `note` — required, non-empty after trimming, max 1000 chars (unchanged)
On success, the endpoint returns all created rows (with `username` joined from `users`) so the frontend can update without a separate fetch.
**New: Migration script `backend/migrations/add_compliance_notes_group_id.js`**
Adds the `group_id` column and backfills existing rows:
```sql
ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
-- Backfill: each existing row gets its own unique group_id
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;
```
The backfill ensures every row has a `group_id`, so the frontend grouping logic works uniformly without null checks.
### Frontend
**Modified: `ComplianceDetailPanel.js`**
The notes section is updated with three changes:
1. **Multi-select metric selector** — replaces the `<select>` dropdown with a chip-based toggle list. Each active metric is rendered as a clickable `MetricChip`. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.
2. **Submission logic**`handleAddNote` sends `metric_ids` (array of selected metric IDs) instead of `metric_id` (single string). The submit button is disabled when no metrics are selected or note text is empty.
3. **Note display grouping** — notes are grouped by `group_id` before rendering. Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges. Notes without a `group_id` (pre-migration legacy) render as individual entries, same as today.
**Component structure:**
```
ComplianceDetailPanel
├── Header (hostname, IP, device type, team)
├── Section: Failing Metrics
│ └── MetricRow (per active metric)
├── Section: Resolved Metrics
│ └── MetricRow (per resolved metric)
├── Section: History
│ └── MetricChip + seen count (per active metric)
└── Section: Notes
├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
└── Add Note Form
├── MetricChipSelector (multi-select chip toggles)
│ ├── MetricChip (per active metric, clickable)
│ └── Select All / Deselect All toggle
├── Textarea (note text)
└── Send button (disabled when no metrics selected or text empty)
```
**MetricChipSelector behavior:**
| State | Behavior |
|---|---|
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
| 2+ active metrics, panel just opened | First metric pre-selected. Select All toggle visible. |
| User clicks unselected chip | Chip added to selection |
| User clicks selected chip (2+ selected) | Chip removed from selection |
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
| Deselect All clicked | All metrics deselected except the first (to maintain minimum selection) |
**Design rationale — minimum selection of 1:** The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.
## Data Models
### compliance_notes table (modified)
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | Auto-increment row ID |
| `hostname` | TEXT NOT NULL | Device hostname |
| `metric_id` | TEXT NOT NULL | Compliance metric ID |
| `note` | TEXT NOT NULL | Note text (max 1000 chars) |
| `group_id` | TEXT | Batch identifier — rows from the same submission share this value |
| `created_by` | INTEGER FK | User ID of the note author |
| `created_at` | DATETIME | Timestamp of creation |
The `group_id` is a UUID v4 string generated server-side via `crypto.randomUUID()`. Single-metric submissions also receive a `group_id` so the frontend grouping logic is uniform.
**Index:** `idx_compliance_notes_group ON compliance_notes(group_id)` — supports the frontend's grouping query.
### API Response Shape
`POST /api/compliance/notes` response (201):
```json
{
"notes": [
{
"id": 42,
"hostname": "SERVER01",
"metric_id": "2.1.1",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
},
{
"id": 43,
"hostname": "SERVER01",
"metric_id": "2.3.2",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
}
]
}
```
`GET /api/compliance/items/:hostname` response — the existing `notes` array now includes `group_id`:
```json
{
"notes": [
{ "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
]
}
```
The frontend groups consecutive notes by `group_id` to render multi-metric notes as a single card.
## 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: Select All / Deselect All round-trip
*For any* set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).
**Validates: Requirements 2.1, 2.2**
### Property 2: Toggle label reflects selection state
*For any* set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.
**Validates: Requirements 2.3**
### Property 3: Multi-metric submission creates correct rows with shared group_id
*For any* valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in `compliance_notes` (where N = length of the metric IDs array), all sharing the same `note` text, `created_by` user, `created_at` timestamp, and `group_id` value.
**Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1**
### Property 4: Whitespace-only notes are rejected
*For any* string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.
**Validates: Requirements 3.3**
### Property 5: Atomic validation — invalid metric IDs reject the entire batch
*For any* array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.
**Validates: Requirements 5.2, 5.6**
### Property 6: Note grouping display
*For any* set of notes where multiple notes share the same `group_id`, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.
**Validates: Requirements 4.1, 4.2, 6.4**
### Property 7: Reverse chronological note ordering
*For any* set of notes with varying `created_at` timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest `created_at` first), regardless of how many metrics each group covers.
**Validates: Requirements 4.3**
## Error Handling
### Backend
| Scenario | Response | Behavior |
|---|---|---|
| Empty or whitespace-only note text | 400 `{ error: "Note cannot be empty" }` | No rows inserted |
| `metric_ids` is empty array | 400 `{ error: "At least one metric ID is required" }` | No rows inserted |
| Any metric ID in array is empty or >50 chars | 400 `{ error: "Invalid metric_id at index N" }` | No rows inserted (atomic rejection) |
| `metric_ids` is not an array (when provided) | 400 `{ error: "metric_ids must be an array" }` | Falls back to checking `metric_id` |
| Neither `metric_id` nor `metric_ids` provided | 400 `{ error: "metric_id or metric_ids is required" }` | No rows inserted |
| Database error during transaction | 500 `{ error: "Failed to save note" }` | Transaction rolled back, no partial inserts |
| Invalid hostname format | 400 `{ error: "Invalid hostname format" }` | No rows inserted (unchanged) |
Transaction safety: all inserts for a multi-metric note happen inside `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the transaction is rolled back and no rows are persisted.
### Frontend
| Scenario | Behavior |
|---|---|
| API returns 400 validation error | Display error message below the note input (existing `noteError` state) |
| API returns 500 server error | Display error message below the note input |
| Network failure | Display "Failed to save note" error |
| No metrics selected | Submit button is disabled, no API call made |
| Successful submission | Clear note text, refresh notes list, retain metric selection |
## Testing Strategy
### Unit Tests (example-based)
- **Backend:**
- Legacy `metric_id` field still creates a single note row (backward compatibility)
- Both `metric_id` and `metric_ids` provided — `metric_ids` takes precedence
- Single active metric pre-selects and is non-removable
- Response shape includes all created rows with `group_id` and `username`
- **Frontend:**
- MetricChipSelector renders correct number of chips for given active metrics
- Clicking a chip toggles its selection state
- Submit button disabled when note text is empty or no metrics selected
- Notes without `group_id` (legacy) render as individual entries
- Single active metric auto-selects and hides Select All toggle
### Property-Based Tests
Property-based tests use `fast-check` (JavaScript PBT library) with a minimum of 100 iterations per property.
Each property test is tagged with a comment referencing the design property:
- **Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id**
- **Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected**
- **Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch**
Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.
### Integration Tests
- End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
- Migration script: verify `group_id` column exists and legacy rows are backfilled
- Backward compatibility: existing `GET /items/:hostname` response includes `group_id` field on notes

View File

@@ -0,0 +1,85 @@
# Requirements Document
## Introduction
The compliance detail panel currently allows users to add notes to a single metric at a time via a dropdown selector. When a remediation action, vendor ticket, or status update applies to multiple metrics on the same device, users must repeat the same note for each metric individually. This feature adds multi-metric selection to the note creation flow so that a single note can be associated with multiple metrics in one action, while preserving the existing per-metric note history and display.
## Glossary
- **Detail_Panel**: The slide-out panel (`ComplianceDetailPanel.js`) that opens when a user clicks a device row on the Compliance page. It displays failing metrics, resolved metrics, upload history, and notes for a single hostname.
- **Note**: A timestamped, user-attributed text entry stored in the `compliance_notes` table, keyed on `(hostname, metric_id)`. Notes persist across uploads and form a historical record.
- **Metric_Selector**: The UI control in the Detail_Panel's notes section that allows the user to choose which metric(s) a note applies to. Currently a single-select dropdown; this feature replaces it with a multi-select control.
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
- **Notes_API**: The `POST /api/compliance/notes` endpoint that persists a note to the database.
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — these are the metrics currently failing.
## Requirements
### Requirement 1: Multi-Metric Selection UI
**User Story:** As a compliance analyst, I want to select multiple metrics when adding a note, so that I can document a single remediation action that covers several metrics without repeating myself.
#### Acceptance Criteria
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Metric_Selector SHALL display all Active_Metrics as individually selectable options.
2. WHEN the user interacts with the Metric_Selector, THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
3. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Metric_Selector SHALL pre-select that metric and remain visible as a single non-removable selection.
4. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select the first Active_Metric by default.
5. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
### Requirement 2: Select All / Deselect All
**User Story:** As a compliance analyst, I want a quick way to select or deselect all metrics, so that I can efficiently apply a note to every failing metric on a device.
#### Acceptance Criteria
1. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
2. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
3. WHEN the user manually selects all Active_Metrics one by one, THE toggle label SHALL update to "Deselect All" to reflect the current state.
### Requirement 3: Multi-Metric Note Submission
**User Story:** As a compliance analyst, I want the system to save my note against all selected metrics in one action, so that the historical record accurately reflects which metrics the note covers.
#### Acceptance Criteria
1. WHEN the user submits a note with multiple metrics selected, THE Notes_API SHALL create one `compliance_notes` row per selected metric, all sharing the same note text, `created_by`, and `created_at` timestamp.
2. WHEN the user submits a note with a single metric selected, THE Notes_API SHALL create exactly one `compliance_notes` row, preserving backward compatibility with the existing behavior.
3. IF the note text is empty or contains only whitespace, THEN THE Notes_API SHALL reject the submission and return a validation error.
4. IF no metrics are selected, THEN THE Detail_Panel SHALL disable the submit button and prevent submission.
5. WHEN a multi-metric note is successfully saved, THE Detail_Panel SHALL clear the note text field, refresh the notes list, and retain the current metric selection.
### Requirement 4: Multi-Metric Note Display
**User Story:** As a compliance analyst, I want to see which metrics a note was applied to, so that I can understand the scope of past remediation actions.
#### Acceptance Criteria
1. WHEN a note was submitted for multiple metrics simultaneously, THE Detail_Panel SHALL display all associated Metric_Chips together on that note entry, visually grouped.
2. WHEN a note was submitted for a single metric, THE Detail_Panel SHALL continue to display a single Metric_Chip on that note entry, matching the current behavior.
3. THE Detail_Panel SHALL display notes in reverse chronological order, with the newest note first, regardless of how many metrics each note covers.
### Requirement 5: Backend Multi-Metric Notes Endpoint
**User Story:** As a developer, I want the notes API to accept an array of metric IDs, so that the frontend can submit a note for multiple metrics in a single request.
#### Acceptance Criteria
1. THE Notes_API SHALL accept a `metric_ids` field (array of strings) in the request body as an alternative to the existing `metric_id` field (single string).
2. WHEN `metric_ids` is provided, THE Notes_API SHALL validate that each entry is a non-empty string of 50 characters or fewer.
3. WHEN `metric_ids` is provided, THE Notes_API SHALL insert one `compliance_notes` row per metric ID, all within the same database transaction, sharing the same `created_at` timestamp.
4. WHEN the legacy `metric_id` field is provided instead of `metric_ids`, THE Notes_API SHALL continue to function as before, inserting a single row.
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Notes_API SHALL use `metric_ids` and ignore `metric_id`.
6. IF any metric ID in the `metric_ids` array fails validation, THEN THE Notes_API SHALL reject the entire request and return a 400 error without inserting any rows.
7. THE Notes_API SHALL return all created note rows in the response, so the frontend can update the display without a separate fetch.
### Requirement 6: Note Grouping Identifier
**User Story:** As a developer, I want notes that were created together to share a group identifier, so that the frontend can visually group multi-metric notes in the display.
#### Acceptance Criteria
1. WHEN multiple notes are created from a single submission, THE Notes_API SHALL assign the same `group_id` value to all rows in that batch.
2. WHEN a single note is created, THE Notes_API SHALL assign a unique `group_id` to that row.
3. THE `group_id` SHALL be stored as a text column in the `compliance_notes` table.
4. THE Detail_Panel SHALL use the `group_id` to visually group notes that were submitted together, displaying them as a single note entry with multiple Metric_Chips rather than as separate entries.

View File

@@ -0,0 +1,105 @@
# Implementation Plan: Multi-Metric Notes for Compliance Detail Panel
## Overview
Extend the compliance notes system so a single note can be associated with multiple metrics in one action. Changes span three layers: a new migration script adding `group_id` to `compliance_notes`, updates to the `POST /notes` endpoint in `backend/routes/compliance.js` to accept `metric_ids` (array) and insert rows transactionally, and frontend changes in `ComplianceDetailPanel.js` to replace the single-select dropdown with a multi-select chip selector and group notes by `group_id` in the display.
## Tasks
- [x] 1. Create database migration for `group_id` column
- [x] 1.1 Create `backend/migrations/add_compliance_notes_group_id.js`
- Add `group_id TEXT` column to `compliance_notes` table via `ALTER TABLE`
- Create index `idx_compliance_notes_group` on `compliance_notes(group_id)`
- Backfill existing rows: `UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`
- Follow the existing migration pattern (sqlite3, serialize, console logging)
- _Requirements: 6.1, 6.2, 6.3_
- [x] 2. Update `POST /notes` endpoint to support multi-metric submissions
- [x] 2.1 Modify the `POST /notes` handler in `backend/routes/compliance.js` to accept `metric_ids` array
- Accept `metric_ids` (array of strings) as an alternative to `metric_id` (single string)
- When both are provided, `metric_ids` takes precedence
- When neither is provided, return 400 with `"metric_id or metric_ids is required"`
- When `metric_ids` is provided but is not an array, return 400 with `"metric_ids must be an array"`
- Normalize single `metric_id` into a one-element array internally so the rest of the logic is uniform
- _Requirements: 5.1, 5.4, 5.5_
- [x] 2.2 Add validation for `metric_ids` array entries
- Validate that `metric_ids` has at least one entry; return 400 with `"At least one metric ID is required"` if empty
- Validate each entry is a non-empty string of 50 characters or fewer; return 400 with `"Invalid metric_id at index N"` on failure
- Reject the entire request if any entry fails validation (atomic rejection, no partial inserts)
- _Requirements: 5.2, 5.6_
- [x] 2.3 Implement transactional multi-row insert with `group_id`
- Generate a `group_id` using `crypto.randomUUID()` for each submission (single or multi)
- Wrap all inserts in `BEGIN TRANSACTION` / `COMMIT` with `ROLLBACK` on error
- Insert one `compliance_notes` row per metric ID, all sharing the same `note`, `group_id`, `created_by`, and `created_at`
- _Requirements: 3.1, 3.2, 5.3, 6.1, 6.2_
- [x] 2.4 Update the response to return all created note rows
- After commit, query all created rows (joined with `users` for `username`) and return as `{ notes: [...] }`
- Each row includes `id`, `hostname`, `metric_id`, `note`, `group_id`, `created_at`, `created_by`
- Return HTTP 201 status
- _Requirements: 5.7_
- [x] 3. Update `GET /items/:hostname` to include `group_id` in notes response
- Add `cn.group_id` to the SELECT in the notes query within the `GET /items/:hostname` handler
- The existing query already fetches notes for the hostname; just add the column
- No other changes to this endpoint
- _Requirements: 6.3, 6.4_
- [x] 4. Checkpoint — Verify backend changes
- Ensure all backend changes are syntactically correct, ask the user if questions arise.
- [x] 5. Replace single-select dropdown with multi-select MetricChipSelector in `ComplianceDetailPanel.js`
- [x] 5.1 Replace `noteMetric` (string) state with `selectedMetrics` (array) state
- Initialize `selectedMetrics` with the first active metric's ID when detail loads (matching current default behavior)
- When there is exactly one active metric, pre-select it as a non-removable selection
- _Requirements: 1.3, 1.4_
- [x] 5.2 Build the multi-select chip-based metric selector UI
- Replace the existing `<select>` dropdown with a row of clickable `MetricChip` components
- Each active metric renders as a chip; selected chips get a highlighted border/background
- Clicking an unselected chip adds it to `selectedMetrics`
- Clicking a selected chip removes it, unless it is the only selected chip (minimum 1 selection)
- Only show the chip selector when there are 2+ active metrics (single metric is auto-selected)
- Style chips using existing `MetricChip` component patterns and category colors
- _Requirements: 1.1, 1.2, 1.5_
- [x] 5.3 Add Select All / Deselect All toggle
- Show a text toggle above or beside the chip row when there are 2+ active metrics
- "Select All" selects all active metrics; label changes to "Deselect All"
- "Deselect All" deselects all except the first metric (minimum selection invariant)
- Toggle label is a pure function of whether all metrics are selected
- Hide the toggle when there is only one active metric
- _Requirements: 2.1, 2.2, 2.3_
- [x] 6. Update note submission logic to send `metric_ids` array
- Modify `handleAddNote` to send `{ hostname, metric_ids: selectedMetrics, note }` instead of `{ hostname, metric_id: noteMetric, note }`
- Disable the submit button when `selectedMetrics` is empty or note text is empty
- On success, clear note text, refresh the detail panel, and retain the current metric selection
- Handle the new response shape (`{ notes: [...] }`) from the updated API
- _Requirements: 3.1, 3.4, 3.5_
- [x] 7. Update note display to group by `group_id`
- [x] 7.1 Add note grouping logic
- Group the `detail.notes` array by `group_id` before rendering
- Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges
- Notes without a `group_id` (pre-migration legacy, should not occur after backfill) render as individual entries
- Maintain reverse chronological order (newest `created_at` first) across groups
- _Requirements: 4.1, 4.2, 4.3, 6.4_
- [x] 7.2 Update the note card rendering
- For grouped notes, display all associated `MetricChip` components in the card header
- For single-metric notes, display one `MetricChip` (matching current behavior)
- Preserve existing note card styling (background, border, padding, typography)
- _Requirements: 4.1, 4.2_
- [x] 8. Final checkpoint — Verify full feature
- Ensure frontend compiles without errors, ask the user if questions arise.
## Notes
- No automated tests — feature is validated manually per user preference
- No new components or route modules required; all changes are scoped to existing files plus one migration
- The `group_id` backfill ensures legacy notes render correctly without null checks
- Each task references specific requirements for traceability

View File

@@ -0,0 +1,29 @@
// Migration: Add group_id column to compliance_notes table
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_compliance_notes_group_id migration...');
db.serialize(() => {
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
if (err) console.error('Error adding group_id column:', err);
else console.log('✓ group_id column added to compliance_notes');
});
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
if (err) console.error('Error creating group_id index:', err);
else console.log('✓ idx_compliance_notes_group created');
});
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
if (err) console.error('Error backfilling group_id:', err);
else console.log('✓ Existing rows backfilled with legacy group_id');
});
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -8,12 +8,13 @@
// 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 a (hostname, metric_id) pair
// POST /notes — add a note to one or more (hostname, metric_id) pairs
// GET /notes/:hostname/:metricId — notes for a specific device+metric
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
@@ -488,7 +489,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// Notes (all metrics for this hostname, sorted newest first)
const notes = await dbAll(db,
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
`SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
@@ -517,42 +518,82 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// POST /notes
// Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note }
// Add a note to one or more (hostname, metric_id) pairs.
// Body: { hostname, metric_ids: [...], note } — or legacy { hostname, metric_id, note }
// -----------------------------------------------------------------------
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, note } = req.body;
const { hostname, metric_id, metric_ids, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname format' });
}
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
// --- Resolve metric IDs: metric_ids takes precedence over metric_id ---
let resolvedIds;
if (metric_ids !== undefined) {
if (!Array.isArray(metric_ids)) {
return res.status(400).json({ error: 'metric_ids must be an array' });
}
resolvedIds = metric_ids;
} else if (metric_id !== undefined && metric_id !== null && metric_id !== '') {
if (typeof metric_id !== 'string' || metric_id.length > 50) {
return res.status(400).json({ error: 'Invalid metric_id' });
}
resolvedIds = [metric_id];
} else {
return res.status(400).json({ error: 'metric_id or metric_ids is required' });
}
// --- Validate resolved metric IDs ---
if (resolvedIds.length === 0) {
return res.status(400).json({ error: 'At least one metric ID is required' });
}
for (let i = 0; i < resolvedIds.length; i++) {
const mid = resolvedIds[i];
if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) {
return res.status(400).json({ error: `Invalid metric_id at index ${i}` });
}
}
const noteText = String(note || '').trim().slice(0, 1000);
if (!noteText) {
return res.status(400).json({ error: 'Note cannot be empty' });
}
try {
const { lastID } = await dbRun(db,
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
[hostname, metric_id, noteText, req.user?.id || null]
);
const groupId = crypto.randomUUID();
const userId = req.user?.id || null;
const created = await dbGet(db,
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
try {
await dbRun(db, 'BEGIN TRANSACTION');
const insertedIds = [];
for (const mid of resolvedIds) {
const { lastID } = await dbRun(db,
`INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
[hostname, mid, noteText, groupId, userId]
);
insertedIds.push(lastID);
}
await dbRun(db, 'COMMIT');
// Fetch all created rows with username
const placeholders = insertedIds.map(() => '?').join(', ');
const notes = await dbAll(db,
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
WHERE cn.id = ?`,
[lastID]
WHERE cn.id IN (${placeholders})
ORDER BY cn.id ASC`,
insertedIds
);
res.status(201).json(created);
res.status(201).json({ notes });
} catch (err) {
await dbRun(db, 'ROLLBACK').catch(() => {});
console.error('[Compliance] POST /notes error:', err.message);
res.status(500).json({ error: 'Failed to save note' });
}

View File

@@ -42,7 +42,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [noteText, setNoteText] = useState('');
const [noteMetric, setNoteMetric] = useState('');
const [selectedMetrics, setSelectedMetrics] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [noteError, setNoteError] = useState(null);
@@ -55,9 +55,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default note metric to first active failing metric
// Default selected metrics to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setNoteMetric(firstActive.metric_id);
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
} catch (err) {
setError(err.message);
} finally {
@@ -68,7 +68,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
useEffect(() => { fetchDetail(); }, [fetchDetail]);
const handleAddNote = async () => {
if (!noteText.trim() || !noteMetric) return;
if (!noteText.trim() || selectedMetrics.length === 0) return;
setSubmitting(true);
setNoteError(null);
try {
@@ -76,7 +76,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
body: JSON.stringify({ hostname, metric_ids: selectedMetrics, note: noteText.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save note');
@@ -194,39 +194,115 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
{detail.notes.length === 0 && (
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
)}
{detail.notes.map(n => (
<div key={n.id} style={{
{(() => {
// Build a lookup map for metric categories (active + resolved)
const metricMap = {};
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
// Group notes by group_id, preserving reverse chronological order
const grouped = [];
const seen = new Set();
// detail.notes is already sorted newest-first from the API
for (const n of detail.notes) {
const gid = n.group_id;
if (!gid) {
// Legacy note without group_id — render individually
grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at });
} else if (!seen.has(gid)) {
seen.add(gid);
const group = detail.notes.filter(x => x.group_id === gid);
grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at });
}
}
return grouped.map(g => (
<div key={g.key} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.3rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{g.notes.map(n => (
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
))}
</div>
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
</div>
))}
));
})()}
{/* Add note */}
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{activeMetrics.length > 1 && (
<select
value={noteMetric}
onChange={e => setNoteMetric(e.target.value)}
{activeMetrics.length > 1 && (() => {
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
return (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
Metrics
</span>
<button
onClick={() => {
if (allSelected) {
setSelectedMetrics([activeMetrics[0].metric_id]);
} else {
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
}
}}
style={{
width: '100%', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.25rem', color: '#CBD5E1',
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
}}>
{activeMetrics.map(m => (
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} {m.category}</option>
))}
</select>
)}
background: 'none', border: 'none', cursor: 'pointer',
fontSize: '0.68rem', fontFamily: 'monospace',
color: TEAL, padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{allSelected ? 'Deselect All' : 'Select All'}
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{activeMetrics.map(m => {
const isSelected = selectedMetrics.includes(m.metric_id);
const color = categoryColor(m.category);
return (
<button
key={m.metric_id}
onClick={() => {
if (isSelected) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
}
} else {
setSelectedMetrics([...selectedMetrics, m.metric_id]);
}
}}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.25rem 0.5rem',
background: isSelected ? `${color}25` : `${color}08`,
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
borderRadius: '0.25rem',
color: isSelected ? color : `${color}90`,
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
transition: 'all 0.15s',
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
}}
>
{m.metric_id}
</button>
);
})}
</div>
</div>
);
})()}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
value={noteText}
@@ -244,13 +320,13 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
/>
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
<button onClick={handleAddNote} disabled={!noteText.trim() || selectedMetrics.length === 0 || submitting}
style={{
padding: '0.5rem 0.625rem', flexShrink: 0,
background: noteText.trim() ? `${TEAL}20` : 'transparent',
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
cursor: (noteText.trim() && selectedMetrics.length > 0) ? 'pointer' : 'default', transition: 'all 0.15s',
}}>
{submitting
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />