291 lines
14 KiB
Markdown
291 lines
14 KiB
Markdown
|
|
# 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, 1–300 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
|