From 6082721452f91a37e90afe58aea636ec93aa7b39 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 20 Apr 2026 10:23:47 -0600 Subject: [PATCH] Sync all local changes for remote dev server migration --- .../hooks/compliance-schema-watcher.kiro.hook | 13 ++ .../.config.kiro | 1 + .../requirements.md | 128 ++++++++++++++++++ README.md | 23 +++- backend/scripts/dump_xlsx_schema.py | 84 ++++++++++++ backend/scripts/parse_compliance_xlsx.py | 8 +- docs/kb-compliance-guide.md | 8 +- 7 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 .kiro/hooks/compliance-schema-watcher.kiro.hook create mode 100644 .kiro/specs/compliance-schema-drift-check/.config.kiro create mode 100644 .kiro/specs/compliance-schema-drift-check/requirements.md create mode 100644 backend/scripts/dump_xlsx_schema.py diff --git a/.kiro/hooks/compliance-schema-watcher.kiro.hook b/.kiro/hooks/compliance-schema-watcher.kiro.hook new file mode 100644 index 0000000..fba8bf0 --- /dev/null +++ b/.kiro/hooks/compliance-schema-watcher.kiro.hook @@ -0,0 +1,13 @@ +{ + "enabled": true, + "name": "Compliance Schema Watcher", + "description": "Manually triggered before uploading a compliance xlsx to the dashboard. Diffs the xlsx structure against the parser's hand-maintained dicts (METRIC_CATEGORIES, CORE_COLS, SKIP_SHEETS) and flags anything that would cause silent data loss or misclassification. Prompts for the xlsx path and report mode.", + "version": "1", + "when": { + "type": "userTriggered" + }, + "then": { + "type": "askAgent", + "prompt": "You are the Compliance Schema Watcher agent. Follow the instructions in `.kiro/agents/compliance-schema-watcher.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Path to the xlsx file:** Absolute path, or a filename to look for in `.compliance-staging/` then `~/Downloads/`. Example: `.compliance-staging/NTS_AEO_2026_04_15.xlsx`\n2. **Mode:** \"report only\" (surface drift findings in chat, no file edits) or \"report + propose edits\" (surface drift and draft specific dict changes for `backend/scripts/parse_compliance_xlsx.py`)\n\nOnce you have both inputs, follow the full schema drift workflow described in `.kiro/agents/compliance-schema-watcher.md`: resolve the file path, read the parser dicts, run the helper script to extract xlsx structure, diff against the parser's expectations, and output a categorised report with a pre-upload verdict." + } +} \ No newline at end of file diff --git a/.kiro/specs/compliance-schema-drift-check/.config.kiro b/.kiro/specs/compliance-schema-drift-check/.config.kiro new file mode 100644 index 0000000..1c579a8 --- /dev/null +++ b/.kiro/specs/compliance-schema-drift-check/.config.kiro @@ -0,0 +1 @@ +{"specId": "e83a2e8f-4508-4669-9697-41219c8a7c71", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/compliance-schema-drift-check/requirements.md b/.kiro/specs/compliance-schema-drift-check/requirements.md new file mode 100644 index 0000000..3540487 --- /dev/null +++ b/.kiro/specs/compliance-schema-drift-check/requirements.md @@ -0,0 +1,128 @@ +# Requirements Document + +## Introduction + +The compliance upload flow in the STEAM Security Dashboard parses weekly NTS_AEO xlsx reports using a Python parser (`parse_compliance_xlsx.py`) that relies on three hand-maintained configuration dicts: `METRIC_CATEGORIES` (metric ID to category mapping), `CORE_COLS` (column names that become main item fields), and `SKIP_SHEETS` (sheet names excluded from parsing). When the xlsx report structure changes — new metrics appear, sheets are renamed, columns are added or removed — the parser silently miscategorises data, drops fields, or fails outright. Currently, detecting this drift requires a separate manual agent workflow. + +This feature builds schema drift detection directly into the upload flow. During the preview step, the backend extracts the xlsx structure and compares it against the parser configuration. The frontend displays categorised drift findings (breaking, silent-miss, cosmetic) in the upload modal before the user sees the diff preview. Breaking findings block the upload; silent-miss findings warn but allow proceeding; cosmetic findings are informational. The parser configuration dicts are extracted into a shared JSON config file that both the Python parser and the Node.js backend can read, establishing a single source of truth. + +## Glossary + +- **Drift_Checker**: The backend module that compares an xlsx file's structural schema against the Parser_Config and produces a categorised Drift_Report. +- **Parser_Config**: A shared JSON configuration file (`backend/scripts/compliance_config.json`) containing `metric_categories`, `core_cols`, and `skip_sheets`. This file is the single source of truth read by both the Python parser and the Node.js backend. +- **Drift_Report**: A structured object returned by the Drift_Checker containing arrays of findings grouped by severity: `breaking`, `silent_miss`, and `cosmetic`. +- **Drift_Finding**: A single entry in the Drift_Report, containing a severity level, a human-readable message, and the specific value that triggered the finding (e.g., a column name, sheet name, or metric ID). +- **Breaking_Finding**: A Drift_Finding indicating the xlsx structure will cause parse errors or data loss. Examples: a core column missing from a detail sheet, a previously existing sheet removed or renamed. +- **Silent_Miss_Finding**: A Drift_Finding indicating data exists in the xlsx but will be dropped or miscategorised by the parser. Examples: a new metric value in the Summary sheet not present in `metric_categories`, a new sheet not in `skip_sheets` and not in `metric_categories`. +- **Cosmetic_Finding**: A Drift_Finding indicating a minor discrepancy worth noting but not blocking. Examples: new columns in known sheets (automatically captured in `extra_json`), stale entries in `metric_categories` that no longer appear in the xlsx. +- **Upload_Modal**: The `ComplianceUploadModal.js` component that manages the file upload flow through phases: idle, uploading, drift-review, preview, committing, done, and error. +- **Preview_Endpoint**: The `POST /api/compliance/preview` endpoint that parses the uploaded xlsx, runs the drift check, computes the diff, and returns both the Drift_Report and diff counts. +- **Schema_Extractor**: The logic (adapted from `dump_xlsx_schema.py`) that reads an xlsx file using openpyxl and extracts sheet names, column headers per sheet, and metric values from the Summary sheet. +- **Detail_Sheet**: Any sheet in the xlsx that is not in the `skip_sheets` set and is parsed for non-compliant item rows. + +## Requirements + +### Requirement 1: Shared Parser Configuration File + +**User Story:** As a developer, I want the parser configuration dicts extracted into a shared JSON file, so that both the Python parser and the Node.js backend read from a single source of truth. + +#### Acceptance Criteria + +1. THE Parser_Config SHALL be stored at `backend/scripts/compliance_config.json` as a JSON file containing three keys: `metric_categories` (object mapping metric ID strings to category name strings), `core_cols` (array of column name strings), and `skip_sheets` (array of sheet name strings). +2. THE Parser_Config SHALL contain the same values currently defined inline in `METRIC_CATEGORIES`, `CORE_COLS`, and `SKIP_SHEETS` in `parse_compliance_xlsx.py`. +3. WHEN the Python parser starts, THE Python parser SHALL read `metric_categories`, `core_cols`, and `skip_sheets` from the Parser_Config file instead of using inline dict definitions. +4. IF the Parser_Config file is missing or contains invalid JSON, THEN THE Python parser SHALL exit with a non-zero exit code and print a descriptive error message to stderr. +5. WHEN the Node.js backend handles a preview request, THE Drift_Checker SHALL read the Parser_Config file to obtain the current metric categories, core columns, and skip sheets. +6. IF the Parser_Config file is missing or contains invalid JSON when the Node.js backend reads it, THEN THE Preview_Endpoint SHALL return a 500 error with a message indicating the configuration file could not be loaded. + +### Requirement 2: Schema Extraction from Uploaded xlsx + +**User Story:** As a developer, I want the backend to extract the structural schema from an uploaded xlsx file, so that the drift checker can compare it against the parser configuration. + +#### Acceptance Criteria + +1. WHEN an xlsx file is uploaded to the Preview_Endpoint, THE Schema_Extractor SHALL extract the list of sheet names, the column headers from the first row of each sheet, and the unique metric values from the Summary sheet's Metric column (header at row 4, data from row 5 onward). +2. THE Schema_Extractor SHALL use openpyxl in read-only mode to extract the xlsx structure, reusing the approach from `dump_xlsx_schema.py`. +3. THE Schema_Extractor SHALL run as a Python subprocess invoked by the Node.js backend, returning the extracted schema as JSON on stdout. +4. IF the xlsx file cannot be opened or contains no sheets, THEN THE Schema_Extractor SHALL return a JSON error object on stdout and exit with a non-zero exit code. + +### Requirement 3: Drift Detection — Breaking Findings + +**User Story:** As a compliance analyst, I want the system to detect structural changes that will cause parse failures or data loss, so that I do not upload a report that produces corrupt data. + +#### Acceptance Criteria + +1. WHEN a Detail_Sheet is missing one or more columns listed in `core_cols` of the Parser_Config, THE Drift_Checker SHALL produce a Breaking_Finding for each missing column, identifying the sheet name and column name. +2. WHEN a sheet name that previously existed as a Detail_Sheet (present in `metric_categories` but not in `skip_sheets`) is absent from the uploaded xlsx, THE Drift_Checker SHALL produce a Breaking_Finding identifying the missing sheet name. +3. THE Drift_Checker SHALL classify all Breaking_Findings with severity `"breaking"`. + +### Requirement 4: Drift Detection — Silent-Miss Findings + +**User Story:** As a compliance analyst, I want the system to detect when new data in the xlsx will be silently miscategorised or dropped, so that I can update the parser configuration before proceeding. + +#### Acceptance Criteria + +1. WHEN the Summary sheet contains metric values not present as keys in `metric_categories` of the Parser_Config, THE Drift_Checker SHALL produce a Silent_Miss_Finding for each unknown metric value. +2. WHEN the xlsx contains sheets that are not in `skip_sheets` and whose names do not appear as keys in `metric_categories`, THE Drift_Checker SHALL produce a Silent_Miss_Finding for each unknown sheet, indicating it will be parsed with an 'Other' category. +3. THE Drift_Checker SHALL classify all Silent_Miss_Findings with severity `"silent_miss"`. + +### Requirement 5: Drift Detection — Cosmetic Findings + +**User Story:** As a compliance analyst, I want to see informational notes about minor schema differences, so that I have full visibility into how the xlsx structure has evolved. + +#### Acceptance Criteria + +1. WHEN a Detail_Sheet contains columns not present in `core_cols` of the Parser_Config, THE Drift_Checker SHALL produce a Cosmetic_Finding for each new column, noting that the column data will be captured in `extra_json`. +2. WHEN `metric_categories` in the Parser_Config contains metric IDs that do not appear in the Summary sheet's metric values, THE Drift_Checker SHALL produce a Cosmetic_Finding for each stale metric ID. +3. THE Drift_Checker SHALL classify all Cosmetic_Findings with severity `"cosmetic"`. + +### Requirement 6: Preview Endpoint Drift Integration + +**User Story:** As a developer, I want the preview endpoint to include the drift report in its response, so that the frontend can display drift findings before showing the diff preview. + +#### Acceptance Criteria + +1. WHEN the Preview_Endpoint processes an uploaded xlsx file, THE Preview_Endpoint SHALL run the Schema_Extractor and Drift_Checker before running the existing parser and diff computation. +2. THE Preview_Endpoint SHALL include a `drift` field in the JSON response containing the Drift_Report with `breaking`, `silent_miss`, and `cosmetic` arrays. +3. WHEN the drift check produces Breaking_Findings, THE Preview_Endpoint SHALL still return a 200 response with the Drift_Report, allowing the frontend to display the findings and block the commit. +4. IF the Schema_Extractor or Drift_Checker fails unexpectedly, THEN THE Preview_Endpoint SHALL proceed with the normal parse and diff flow, returning a `drift` field set to `null` and a `drift_error` field with a descriptive message, so that the upload flow is not blocked by drift check failures. + +### Requirement 7: Upload Modal Drift Review Phase + +**User Story:** As a compliance analyst, I want to see drift findings in the upload modal after file upload and before the diff preview, so that I can assess schema compatibility before deciding to proceed. + +#### Acceptance Criteria + +1. WHEN the Preview_Endpoint returns a Drift_Report with one or more findings, THE Upload_Modal SHALL display a drift review phase between the uploading spinner and the diff preview. +2. THE Upload_Modal SHALL display Breaking_Findings with red text and a red left-border accent, using the dashboard danger color (`#EF4444`). +3. THE Upload_Modal SHALL display Silent_Miss_Findings with amber text and an amber left-border accent, using the dashboard warning color (`#F59E0B`). +4. THE Upload_Modal SHALL display Cosmetic_Findings with muted text and a subtle left-border accent, using the dashboard muted text color (`#94A3B8`). +5. WHEN the Drift_Report contains one or more Breaking_Findings, THE Upload_Modal SHALL disable the "Continue to Preview" button and display a message indicating the upload is blocked until the parser configuration is updated. +6. WHEN the Drift_Report contains Silent_Miss_Findings but no Breaking_Findings, THE Upload_Modal SHALL enable the "Continue to Preview" button and display a warning message advising the user to review the findings. +7. WHEN the Drift_Report contains only Cosmetic_Findings, THE Upload_Modal SHALL enable the "Continue to Preview" button without a warning message. +8. WHEN the Drift_Report contains no findings, THE Upload_Modal SHALL skip the drift review phase and proceed directly to the diff preview. + +### Requirement 8: Drift Review UI Layout and Interaction + +**User Story:** As a compliance analyst, I want the drift findings to be clearly organised and scannable, so that I can quickly understand what changed in the xlsx structure. + +#### Acceptance Criteria + +1. THE Upload_Modal SHALL group drift findings by severity, displaying Breaking_Findings first, then Silent_Miss_Findings, then Cosmetic_Findings. +2. THE Upload_Modal SHALL display a count badge next to each severity group header showing the number of findings in that group. +3. WHEN a severity group contains more than five findings, THE Upload_Modal SHALL collapse the group to show the first five findings with an expandable "Show N more" toggle. +4. EACH Drift_Finding displayed in the Upload_Modal SHALL include the finding message and the specific value (column name, sheet name, or metric ID) that triggered the finding. +5. THE Upload_Modal SHALL display a "Cancel" button that returns the modal to the idle phase, and a "Continue to Preview" button (when enabled) that advances to the diff preview phase. +6. THE Upload_Modal drift review phase SHALL follow the dashboard's dark theme and monospace typography conventions defined in `DESIGN_SYSTEM.md`. + +### Requirement 9: Existing Upload Flow Preservation + +**User Story:** As a compliance analyst, I want the existing upload flow to remain intact, so that the drift check is an additive enhancement and does not disrupt the current preview-then-commit workflow. + +#### Acceptance Criteria + +1. WHEN the user clicks "Continue to Preview" from the drift review phase, THE Upload_Modal SHALL display the same diff preview (recurring, new, resolved counts) and "Confirm Upload" button as the current implementation. +2. THE Preview_Endpoint SHALL continue to return `diff`, `tempFile`, `filename`, `report_date`, and `total_items` fields in the response alongside the new `drift` field. +3. THE commit flow (`POST /api/compliance/commit`) SHALL remain unchanged and SHALL NOT perform any drift checking. +4. WHEN the `drift` field in the preview response is `null` (drift check failed or was skipped), THE Upload_Modal SHALL proceed directly to the diff preview phase as if no drift was detected. + diff --git a/README.md b/README.md index 254c0da..847a922 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,9 @@ node migrations/add_ivanti_counts_history_table.js node migrations/add_fp_submissions_table.js node migrations/add_user_groups.js node migrations/add_created_by_columns.js +node migrations/add_fp_submission_editing.js +node migrations/add_granite_workflow_type.js +node migrations/add_compliance_notes_group_id.js ``` ### 8. Build the frontend @@ -386,7 +389,7 @@ A personal staging list for batch-processing FP, Archer, and CARD workflows with - Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity) - Delete individual items with the trash icon, or select multiple and use **Delete (N)** - **Clear Completed** removes all marked-complete items at once -- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Successful submission marks the queue items as complete and records the submission locally. +- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Attachments can be local file uploads or documents selected from the CVE document library — library documents are read from disk and sent to Ivanti identically to local uploads. Successful submission marks the queue items as complete and records the submission locally. **Redirecting completed items:** - Completed items show a redirect button (↱) next to the delete icon @@ -585,7 +588,13 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a | Method | Path | Group | Description | |---|---|---|---| -| POST | `/api/ivanti/fp-workflow` | Admin, Standard_User | Submit an FP workflow batch to Ivanti API (multipart/form-data with attachments) | +| GET | `/api/ivanti/fp-workflow/documents/search` | Any | Search the CVE document library by name, CVE ID, or vendor; returns up to 50 matches | +| POST | `/api/ivanti/fp-workflow` | Admin, Standard_User | Submit an FP workflow batch to Ivanti API (multipart/form-data with local attachments and/or `libraryDocIds`) | +| GET | `/api/ivanti/fp-workflow/submissions` | Any | List FP submissions for the current user | +| PUT | `/api/ivanti/fp-workflow/submissions/:id` | Admin, Standard_User | Update an FP submission (edit form fields) | +| POST | `/api/ivanti/fp-workflow/submissions/:id/findings` | Admin, Standard_User | Add or remove findings on an existing submission | +| POST | `/api/ivanti/fp-workflow/submissions/:id/attachments` | Admin, Standard_User | Upload additional attachments (local files and/or `libraryDocIds`) to an existing submission | +| PATCH | `/api/ivanti/fp-workflow/submissions/:id/status` | Admin, Standard_User | Update submission lifecycle status | ### Ivanti — Todo Queue @@ -616,7 +625,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a | GET | `/api/compliance/items` | Any | Device list; `?team=STEAM&status=active` | | GET | `/api/compliance/items/:hostname` | Any | Full detail for a device (metrics + notes) | | GET | `/api/compliance/notes/:hostname/:metricId` | Any | Notes for a specific hostname/metric | -| POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric | +| POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric; accepts `metric_ids` array for multi-metric notes | ### Knowledge Base @@ -777,7 +786,7 @@ cve-dashboard/ **`compliance_items`** — One row per device/metric violation. Tracks hostname, IP, device type, team, metric ID, category, `extra_json` (all non-core xlsx columns), status (active/resolved), first seen upload, and times seen. Identity key: `(hostname, metric_id)`. -**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. Foreign-key linked to compliance items. +**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. `group_id` column links notes created in the same multi-metric submission. Foreign-key linked to compliance items. ### View @@ -892,6 +901,9 @@ node migrations/add_ivanti_counts_history_table.js node migrations/add_fp_submissions_table.js node migrations/add_user_groups.js node migrations/add_created_by_columns.js +node migrations/add_fp_submission_editing.js +node migrations/add_granite_workflow_type.js +node migrations/add_compliance_notes_group_id.js cd .. # 7. Rebuild the frontend @@ -932,6 +944,9 @@ node migrations/add_ivanti_counts_history_table.js node migrations/add_fp_submissions_table.js node migrations/add_user_groups.js node migrations/add_created_by_columns.js +node migrations/add_fp_submission_editing.js +node migrations/add_granite_workflow_type.js +node migrations/add_compliance_notes_group_id.js ``` For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`: diff --git a/backend/scripts/dump_xlsx_schema.py b/backend/scripts/dump_xlsx_schema.py new file mode 100644 index 0000000..240a028 --- /dev/null +++ b/backend/scripts/dump_xlsx_schema.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Dump the structural schema of a compliance xlsx file as JSON. +Usage: python3 dump_xlsx_schema.py + +Output: +{ + "sheets": [ + { + "name": "SheetName", + "columns": ["Col A", "Col B", ...], + "row_count": 150, + "metric_values": ["2.3.4i", "5.2.4", ...] // only if a Metric column exists + }, + ... + ] +} + +Dependencies: openpyxl (already in requirements.txt) +""" +import sys +import json +from openpyxl import load_workbook + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({'error': 'No file path provided'})) + sys.exit(1) + + filepath = sys.argv[1] + + try: + wb = load_workbook(filepath, read_only=True, data_only=True) + except Exception as e: + print(json.dumps({'error': f'Cannot open file: {str(e)}'})) + sys.exit(1) + + sheets = [] + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(max_row=1, values_only=True)) + columns = [str(c).strip() for c in rows[0] if c is not None] if rows else [] + + # Count data rows (excluding header) + row_count = 0 + for _ in ws.iter_rows(min_row=2, values_only=True): + row_count += 1 + + # Extract metric values if a Metric column exists in the Summary sheet + metric_values = [] + if sheet_name == 'Summary': + # Summary has header at row 4 (0-indexed row 3), read from row 5 onward + header_rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True)) + if header_rows: + summary_cols = [str(c).strip() if c else '' for c in header_rows[0]] + metric_idx = None + for i, col in enumerate(summary_cols): + if col == 'Metric': + metric_idx = i + break + if metric_idx is not None: + for row in ws.iter_rows(min_row=5, values_only=True): + if row[metric_idx] is not None: + val = str(row[metric_idx]).strip() + if val and val != 'Metric': + metric_values.append(val) + + entry = { + 'name': sheet_name, + 'columns': columns, + 'row_count': row_count, + } + if metric_values: + entry['metric_values'] = sorted(set(metric_values)) + + sheets.append(entry) + + wb.close() + print(json.dumps({'sheets': sheets}, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/backend/scripts/parse_compliance_xlsx.py b/backend/scripts/parse_compliance_xlsx.py index eb7f7dd..a1c5949 100644 --- a/backend/scripts/parse_compliance_xlsx.py +++ b/backend/scripts/parse_compliance_xlsx.py @@ -32,6 +32,12 @@ METRIC_CATEGORIES = { '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', @@ -44,7 +50,7 @@ CORE_COLS = { 'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM', } -SKIP_SHEETS = {'Summary', 'CMDB_9box'} +SKIP_SHEETS = {'Summary', 'CMDB_9box', 'Vulns', 'Aging Dashboard'} def safe_str(val): diff --git a/docs/kb-compliance-guide.md b/docs/kb-compliance-guide.md index e4da76c..a95cbc0 100644 --- a/docs/kb-compliance-guide.md +++ b/docs/kb-compliance-guide.md @@ -77,13 +77,13 @@ Click a device to open the detail panel showing: ### Adding Notes -You can add notes to any device/metric combination: +You can add notes to one or more metrics on a device at once: 1. Open the device detail panel -2. Find the metric you want to annotate -3. Type your note and save +2. Select the metrics the note applies to using the chip selector — click individual metric chips to toggle them, or use **Select All** / **Deselect All** for bulk selection +3. Type your note and click send 4. Notes are timestamped and attributed to the logged-in user -Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant. +When a note is submitted for multiple metrics, it appears as a single grouped entry in the notes history with all associated metric chips displayed together. Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant. ## Data Flow