New specs: archer-template-library, ccp-metrics-view-restructure, compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date, compliance-remediation-display-fix, flexible-jira-ticket-creation, forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix, multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown New steering: archer-template-gen.md Updated: migration-registration-check hook, remediation-plan-history spec, gitlab-workflow, tech, versioning steering files
785 lines
34 KiB
Markdown
785 lines
34 KiB
Markdown
# Design Document: Remediation Plan History
|
||
|
||
## Overview
|
||
|
||
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new `compliance_item_history` table for historical entries. The pattern mirrors how `compliance_notes` works — separate rows with timestamps and attribution.
|
||
|
||
**Per-metric extension (Requirements 8–15):** The existing PATCH endpoint updates all active rows for a hostname uniformly. Since `compliance_items` already stores `resolution_date` and `remediation_plan` per row (each row is a hostname+metric_id pair), the extension allows targeting specific metrics via optional `metric_id`/`metric_ids` parameters. This mirrors the multi-metric notes pattern established in the `compliance-multi-metric-notes` spec — same chip selector UI, same Select All toggle, same `metric_ids` array API convention.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ComplianceDetailPanel │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
||
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
|
||
│ │ Date Input │ │ Plan Input │ │ │ │
|
||
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
||
│ │ │ │ │
|
||
│ └──────────────────┴──────────────────────┘ │
|
||
│ │ │
|
||
│ PATCH /metadata │
|
||
│ { resolution_date, remediation_plan, │
|
||
│ change_reason } │
|
||
│ │ │
|
||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||
│ │ Change History Section │ │
|
||
│ │ [date] [field] [old→new] [by user] [reason] │ │
|
||
│ └─────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Backend (compliance.js) │
|
||
│ │
|
||
│ PATCH /items/:hostname/metadata │
|
||
│ 1. Validate inputs │
|
||
│ 2. SELECT current values from compliance_items │
|
||
│ 3. Compare old vs new — skip if identical │
|
||
│ 4. INSERT into compliance_item_history (per changed field) │
|
||
│ 5. UPDATE compliance_items with new values │
|
||
│ │
|
||
│ GET /items/:hostname │
|
||
│ (existing) + SELECT from compliance_item_history │
|
||
│ LIMIT 10 ORDER BY changed_at DESC │
|
||
│ │
|
||
│ POST /vcl/bulk-commit │
|
||
│ For each hostname: same compare-then-insert pattern │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ PostgreSQL │
|
||
│ │
|
||
│ compliance_items (unchanged) │
|
||
│ resolution_date DATE │
|
||
│ remediation_plan TEXT │
|
||
│ │
|
||
│ compliance_item_history (new) │
|
||
│ id, hostname, field_name, old_value, new_value, │
|
||
│ change_reason, changed_by, changed_at │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Components and Interfaces
|
||
|
||
### Backend Components
|
||
|
||
- **PATCH /api/compliance/items/:hostname/metadata** — updates resolution_date and/or remediation_plan on compliance_items rows, records field-level change history. Extended with optional `metric_id`/`metric_ids` for per-metric scoping.
|
||
- **GET /api/compliance/items/:hostname** — returns device detail including history entries (with metric_id field).
|
||
- **POST /api/compliance/vcl/bulk-commit** — bulk update path that also records history per hostname.
|
||
|
||
### Frontend Components
|
||
|
||
- **ComplianceDetailPanel.js** — slide-out panel displaying device compliance detail, metadata editing, change history, and notes.
|
||
- **MetricChipSelector (metadata)** — chip-based multi-select for choosing which metrics a resolution_date/remediation_plan update applies to. Positioned above the metadata inputs.
|
||
- **HistoryMetricLabel** — renders a MetricChip for per-metric history entries or "All metrics" label for hostname-level entries.
|
||
|
||
## Data Models
|
||
|
||
### New Table: compliance_item_history
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||
id SERIAL PRIMARY KEY,
|
||
hostname TEXT NOT NULL,
|
||
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||
old_value TEXT,
|
||
new_value TEXT,
|
||
change_reason TEXT,
|
||
changed_by TEXT NOT NULL,
|
||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||
ON compliance_item_history(hostname, field_name);
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||
ON compliance_item_history(changed_at);
|
||
```
|
||
|
||
### Existing Table: compliance_items (no changes)
|
||
|
||
The `resolution_date` and `remediation_plan` columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
|
||
|
||
## API Changes
|
||
|
||
### PATCH /api/compliance/items/:hostname/metadata
|
||
|
||
**Request body changes:**
|
||
|
||
```json
|
||
{
|
||
"resolution_date": "2026-03-15",
|
||
"remediation_plan": "Upgrade firmware to v4.2",
|
||
"change_reason": "Vendor pushed back delivery date"
|
||
}
|
||
```
|
||
|
||
New optional field: `change_reason` (string, max 500 characters, nullable).
|
||
|
||
**Behavior changes:**
|
||
|
||
1. Before updating compliance_items, query the current values:
|
||
```sql
|
||
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||
FROM compliance_items
|
||
WHERE hostname = $1 AND status = 'active'
|
||
ORDER BY hostname, id DESC
|
||
LIMIT 1
|
||
```
|
||
|
||
2. For each field being updated, compare old vs new. If different, insert a history row:
|
||
```sql
|
||
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||
VALUES ($1, $2, $3, $4, $5, $6)
|
||
```
|
||
|
||
3. Then proceed with the existing UPDATE as before.
|
||
|
||
**Response:** unchanged (`{ updated: number }`).
|
||
|
||
### GET /api/compliance/items/:hostname
|
||
|
||
**Response changes:**
|
||
|
||
Add a `history` array to the response:
|
||
|
||
```json
|
||
{
|
||
"hostname": "server01.example.com",
|
||
"ip_address": "10.0.1.5",
|
||
"device_type": "Server",
|
||
"team": "STEAM",
|
||
"resolution_date": "2026-03-15",
|
||
"remediation_plan": "Upgrade firmware to v4.2",
|
||
"metrics": [...],
|
||
"notes": [...],
|
||
"history": [
|
||
{
|
||
"id": 42,
|
||
"field_name": "resolution_date",
|
||
"old_value": "2026-02-01",
|
||
"new_value": "2026-03-15",
|
||
"change_reason": "Vendor pushed back delivery date",
|
||
"changed_by": "jsmith",
|
||
"changed_at": "2026-01-20T14:30:00.000Z"
|
||
},
|
||
{
|
||
"id": 41,
|
||
"field_name": "remediation_plan",
|
||
"old_value": null,
|
||
"new_value": "Upgrade firmware to v4.2",
|
||
"change_reason": null,
|
||
"changed_by": "jsmith",
|
||
"changed_at": "2026-01-15T09:00:00.000Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Query:
|
||
```sql
|
||
SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||
FROM compliance_item_history
|
||
WHERE hostname = $1
|
||
ORDER BY changed_at DESC
|
||
LIMIT 10
|
||
```
|
||
|
||
### POST /api/compliance/vcl/bulk-commit
|
||
|
||
**Behavior changes:**
|
||
|
||
Within the transaction, before updating each hostname:
|
||
1. Query current values for that hostname
|
||
2. Compare each field (resolution_date, remediation_plan) against the incoming value
|
||
3. Insert history rows for changed fields with `changed_by` set to `req.user.username`
|
||
|
||
No request/response shape changes. The `change_reason` is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
|
||
|
||
## Frontend Changes
|
||
|
||
### ComplianceDetailPanel.js
|
||
|
||
**New state:**
|
||
- `changeReason` (string) — text input value for the reason field
|
||
- `history` (array) — populated from the API response
|
||
|
||
**New UI elements:**
|
||
|
||
1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
|
||
|
||
2. **Change History section** — a new `<Section>` component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
|
||
|
||
**History entry display format:**
|
||
```
|
||
[field_name icon] old_value → new_value
|
||
username · 2026-01-20 [reason if present]
|
||
```
|
||
|
||
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
|
||
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
|
||
- Change reason: displayed in muted text below the change line when present
|
||
|
||
**Save flow update:**
|
||
- The `handleSaveMetadata` function passes `change_reason` alongside the field values
|
||
- After successful save, clear the `changeReason` input and re-fetch detail (which now includes updated history)
|
||
|
||
## Error Handling
|
||
|
||
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
|
||
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
|
||
- `change_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
|
||
|
||
## Migration
|
||
|
||
File: `backend/migrations/add_compliance_item_history.js`
|
||
|
||
```javascript
|
||
const pool = require('../db');
|
||
|
||
async function run() {
|
||
console.log('Starting compliance_item_history migration...');
|
||
try {
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||
id SERIAL PRIMARY KEY,
|
||
hostname TEXT NOT NULL,
|
||
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||
old_value TEXT,
|
||
new_value TEXT,
|
||
change_reason TEXT,
|
||
changed_by TEXT NOT NULL,
|
||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
`);
|
||
console.log('✓ compliance_item_history table created (or already exists)');
|
||
|
||
await pool.query(`
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||
ON compliance_item_history(hostname, field_name)
|
||
`);
|
||
console.log('✓ hostname/field_name index created');
|
||
|
||
await pool.query(`
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||
ON compliance_item_history(changed_at)
|
||
`);
|
||
console.log('✓ changed_at index created');
|
||
|
||
console.log('Migration complete.');
|
||
} catch (err) {
|
||
console.error('Migration failed:', err.message);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
module.exports = { run };
|
||
```
|
||
|
||
## Reporting Isolation
|
||
|
||
No changes to VCL reporting queries. The following queries continue to read directly from `compliance_items.resolution_date`:
|
||
|
||
- Donut chart: `SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname`
|
||
- Burndown forecast: `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL`
|
||
- Per-vertical burndown in vclMultiVertical.js
|
||
|
||
The `compliance_item_history` table is never referenced by any reporting query.
|
||
|
||
## Performance Considerations
|
||
|
||
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
|
||
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
|
||
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
|
||
- No additional queries are added to the VCL reporting paths.
|
||
|
||
---
|
||
|
||
## Per-Metric Extension (Requirements 8–15)
|
||
|
||
### Overview
|
||
|
||
The `compliance_items` table already stores `resolution_date` and `remediation_plan` per row, where each row represents a unique (hostname, metric_id) pair. The current PATCH endpoint updates ALL active rows for a hostname uniformly. This extension adds optional metric scoping so analysts can set different resolution dates and remediation plans for individual metrics on the same device.
|
||
|
||
The UI pattern replicates the multi-metric notes selector from the `compliance-multi-metric-notes` spec: chip-based multi-select with Select All toggle, positioned above the resolution_date and remediation_plan inputs.
|
||
|
||
### Architecture — Per-Metric Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Panel as ComplianceDetailPanel
|
||
participant API as PATCH /items/:hostname/metadata
|
||
participant DB as PostgreSQL
|
||
|
||
User->>Panel: Open detail panel for hostname
|
||
Panel->>Panel: Pre-select all active metrics (Select All default)
|
||
User->>Panel: Deselect some metrics, select specific ones
|
||
Panel->>Panel: Compute shared values for selected metrics
|
||
Panel->>Panel: Display shared value or "Multiple values" placeholder
|
||
|
||
User->>Panel: Edit resolution_date / remediation_plan
|
||
User->>Panel: Click Save
|
||
|
||
alt All metrics selected (Select All)
|
||
Panel->>API: PATCH { resolution_date, remediation_plan, change_reason }
|
||
Note over Panel,API: No metric_ids → hostname-level update (backward compat)
|
||
else Specific metrics selected
|
||
Panel->>API: PATCH { resolution_date, remediation_plan, change_reason, metric_ids: [...] }
|
||
end
|
||
|
||
API->>API: Validate metric_ids against active items
|
||
API->>DB: SELECT current values per targeted metric
|
||
loop For each targeted metric with changed values
|
||
API->>DB: INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, ...)
|
||
end
|
||
API->>DB: UPDATE compliance_items SET ... WHERE hostname = $1 AND metric_id = ANY($2)
|
||
API-->>Panel: { updated: N }
|
||
Panel->>Panel: Re-fetch detail, refresh history
|
||
```
|
||
|
||
### Schema Extension
|
||
|
||
#### Add metric_id column to compliance_item_history
|
||
|
||
```sql
|
||
ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||
ON compliance_item_history(hostname, metric_id);
|
||
```
|
||
|
||
The column is nullable. Existing rows retain `NULL` metric_id, indicating they were hostname-level changes made before this extension. New per-metric changes populate the column; hostname-level changes (no metric_ids in request) continue to insert with `NULL`.
|
||
|
||
#### Migration File: `backend/migrations/add_compliance_history_metric_id.js`
|
||
|
||
```javascript
|
||
const pool = require('../db');
|
||
|
||
async function run() {
|
||
console.log('Starting compliance_item_history metric_id migration...');
|
||
try {
|
||
// Add nullable metric_id column (idempotent check)
|
||
const { rows } = await pool.query(`
|
||
SELECT column_name FROM information_schema.columns
|
||
WHERE table_name = 'compliance_item_history' AND column_name = 'metric_id'
|
||
`);
|
||
if (rows.length === 0) {
|
||
await pool.query(`ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT`);
|
||
console.log('✓ metric_id column added to compliance_item_history');
|
||
} else {
|
||
console.log('✓ metric_id column already exists');
|
||
}
|
||
|
||
await pool.query(`
|
||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||
ON compliance_item_history(hostname, metric_id)
|
||
`);
|
||
console.log('✓ (hostname, metric_id) index created');
|
||
|
||
console.log('Migration complete.');
|
||
} catch (err) {
|
||
console.error('Migration failed:', err.message);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
module.exports = { run };
|
||
|
||
// Self-execute when run directly
|
||
if (require.main === module) {
|
||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||
}
|
||
```
|
||
|
||
No backfill is performed — pre-existing history rows with `NULL` metric_id correctly represent hostname-level changes.
|
||
|
||
### API Changes — Per-Metric Scoping
|
||
|
||
#### PATCH /api/compliance/items/:hostname/metadata (extended)
|
||
|
||
**New optional request body fields:**
|
||
|
||
```json
|
||
{
|
||
"resolution_date": "2026-03-15",
|
||
"remediation_plan": "Upgrade firmware to v4.2",
|
||
"change_reason": "Vendor pushed back delivery date",
|
||
"metric_id": "2.1.1",
|
||
"metric_ids": ["2.1.1", "2.3.2"]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
|---|---|---|
|
||
| `metric_id` | string (optional) | Scope update to a single metric |
|
||
| `metric_ids` | string[] (optional) | Scope update to multiple specific metrics |
|
||
|
||
**Precedence:** If both `metric_id` and `metric_ids` are provided, `metric_ids` wins and `metric_id` is ignored.
|
||
|
||
**Validation:**
|
||
- Each metric_id must be a non-empty string of 100 characters or fewer
|
||
- Each metric_id must correspond to an active compliance_item for the hostname — if any provided metric_id has no matching active row, return 400 with `{ error: "Invalid metric_id: <value> — no active compliance item found" }`
|
||
|
||
**Behavior by scoping mode:**
|
||
|
||
| Mode | Condition | Query target | History metric_id |
|
||
|---|---|---|---|
|
||
| Hostname-level | Neither `metric_id` nor `metric_ids` provided | All active rows for hostname | `NULL` |
|
||
| Single metric | `metric_id` provided (no `metric_ids`) | Single row matching hostname + metric_id | The metric_id value |
|
||
| Multi-metric | `metric_ids` provided | Rows matching hostname + any of the metric_ids | Per-row metric_id |
|
||
|
||
**Per-metric history insertion logic:**
|
||
|
||
When metric scoping is active, the endpoint queries current values per targeted metric individually (since they may differ), then inserts one history row per metric per changed field:
|
||
|
||
```sql
|
||
-- Get current values for each targeted metric
|
||
SELECT metric_id, resolution_date, remediation_plan
|
||
FROM compliance_items
|
||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'
|
||
```
|
||
|
||
```sql
|
||
-- Insert history per metric per changed field
|
||
INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
```
|
||
|
||
```sql
|
||
-- Update only targeted rows
|
||
UPDATE compliance_items
|
||
SET resolution_date = $1, remediation_plan = $2
|
||
WHERE hostname = $3 AND metric_id = ANY($4) AND status = 'active'
|
||
```
|
||
|
||
**Response:** unchanged (`{ updated: number }`).
|
||
|
||
#### GET /api/compliance/items/:hostname (extended history response)
|
||
|
||
The history query now includes `metric_id`:
|
||
|
||
```sql
|
||
SELECT id, hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||
FROM compliance_item_history
|
||
WHERE hostname = $1
|
||
ORDER BY changed_at DESC
|
||
LIMIT 10
|
||
```
|
||
|
||
Response shape — history entries now include `metric_id`:
|
||
|
||
```json
|
||
{
|
||
"history": [
|
||
{
|
||
"id": 55,
|
||
"metric_id": "2.1.1",
|
||
"field_name": "resolution_date",
|
||
"old_value": "2026-02-01",
|
||
"new_value": "2026-04-15",
|
||
"change_reason": "Vendor delay on patch",
|
||
"changed_by": "jsmith",
|
||
"changed_at": "2026-02-10T11:00:00.000Z"
|
||
},
|
||
{
|
||
"id": 54,
|
||
"metric_id": null,
|
||
"field_name": "remediation_plan",
|
||
"old_value": "Upgrade firmware",
|
||
"new_value": "Replace hardware",
|
||
"change_reason": null,
|
||
"changed_by": "admin",
|
||
"changed_at": "2026-02-05T09:00:00.000Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Frontend Changes — Per-Metric Metadata Selector
|
||
|
||
#### New State
|
||
|
||
```javascript
|
||
const [metricSelection, setMetricSelection] = useState([]); // metric_ids for metadata editing
|
||
```
|
||
|
||
This is separate from the existing `selectedMetrics` state (used for notes). The metadata metric selector has its own selection state.
|
||
|
||
#### UI Layout
|
||
|
||
The Metric_Selector is placed above the resolution_date and remediation_plan inputs, matching the notes section pattern:
|
||
|
||
```
|
||
ComplianceDetailPanel
|
||
├── Header (hostname, IP, device type, team)
|
||
├── Section: Failing Metrics
|
||
├── Section: Resolved Metrics
|
||
├── Section: Remediation Plan & Resolution Date
|
||
│ ├── MetricChipSelector (for metadata) ← NEW
|
||
│ │ ├── Select All / Deselect All toggle
|
||
│ │ └── MetricChip (per active metric, clickable)
|
||
│ ├── Resolution Date input (populated from selection)
|
||
│ ├── Remediation Plan textarea (populated from selection)
|
||
│ ├── Change Reason input
|
||
│ └── Save button
|
||
├── Section: Change History
|
||
│ └── HistoryEntry (with optional MetricChip) ← EXTENDED
|
||
├── Section: Notes
|
||
│ ├── NoteCard (grouped by group_id)
|
||
│ └── Add Note Form (with its own MetricChipSelector)
|
||
```
|
||
|
||
#### MetricChipSelector Behavior (Metadata)
|
||
|
||
| State | Behavior |
|
||
|---|---|
|
||
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
|
||
| 2+ active metrics, panel just opened | **All metrics pre-selected** (Select All default). 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 (minimum selection invariant) |
|
||
|
||
**Key difference from notes selector:** The metadata selector defaults to "Select All" on panel open (preserving existing hostname-level behavior), while the notes selector defaults to the first metric only.
|
||
|
||
#### Per-Metric Field Display Logic
|
||
|
||
When the metric selection changes, the panel computes what to display in the resolution_date and remediation_plan inputs:
|
||
|
||
```javascript
|
||
function computeSharedValues(metrics, selectedIds) {
|
||
const selected = metrics.filter(m => selectedIds.includes(m.metric_id));
|
||
if (selected.length === 0) return { resolution_date: '', remediation_plan: '' };
|
||
if (selected.length === 1) {
|
||
return {
|
||
resolution_date: selected[0].resolution_date || '',
|
||
remediation_plan: selected[0].remediation_plan || '',
|
||
};
|
||
}
|
||
|
||
// Multiple selected — check if values are uniform
|
||
const dates = new Set(selected.map(m => m.resolution_date || ''));
|
||
const plans = new Set(selected.map(m => m.remediation_plan || ''));
|
||
|
||
return {
|
||
resolution_date: dates.size === 1 ? [...dates][0] : null, // null = "Multiple values"
|
||
remediation_plan: plans.size === 1 ? [...plans][0] : null, // null = "Multiple values"
|
||
};
|
||
}
|
||
```
|
||
|
||
**Display rules:**
|
||
|
||
| Computed value | Input state |
|
||
|---|---|
|
||
| Non-null string (including empty) | Input populated with that value |
|
||
| `null` (multiple different values) | Input empty, placeholder text: "Multiple values" |
|
||
|
||
**Save behavior with "Multiple values":**
|
||
|
||
- If the user leaves an input untouched while it shows "Multiple values" placeholder, that field is NOT included in the PATCH request body — preserving existing per-metric values.
|
||
- If the user types a new value into a "Multiple values" field, that value is sent and applied to all selected metrics.
|
||
|
||
#### Save Flow — Metric Scoping
|
||
|
||
```javascript
|
||
const handleSaveMetadata = async (fields) => {
|
||
const body = { ...fields };
|
||
if (changeReason.trim()) body.change_reason = changeReason.trim();
|
||
|
||
// Determine if we need metric scoping
|
||
const allSelected = activeMetrics.length > 0 &&
|
||
activeMetrics.every(m => metricSelection.includes(m.metric_id));
|
||
|
||
if (!allSelected) {
|
||
// Specific metrics selected — include metric_ids
|
||
body.metric_ids = metricSelection;
|
||
}
|
||
// If all selected, omit metric_ids → hostname-level update (backward compat)
|
||
|
||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||
method: 'PATCH',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
// ... error handling, re-fetch ...
|
||
};
|
||
```
|
||
|
||
### History Display — Per-Metric Extension
|
||
|
||
History entries now optionally show which metric the change applied to:
|
||
|
||
**Display format (extended):**
|
||
|
||
```
|
||
[MetricChip "2.1.1"] [field_name icon] old_value → new_value
|
||
username · 2026-01-20 [reason if present]
|
||
```
|
||
|
||
Or for hostname-level changes (metric_id is null):
|
||
|
||
```
|
||
[All metrics] [field_name icon] old_value → new_value
|
||
username · 2026-01-20 [reason if present]
|
||
```
|
||
|
||
**Rendering logic:**
|
||
|
||
```javascript
|
||
function HistoryMetricLabel({ metricId, metricMap }) {
|
||
if (metricId) {
|
||
return <MetricChip metricId={metricId} category={metricMap[metricId] || ''} />;
|
||
}
|
||
return (
|
||
<span style={{
|
||
fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic',
|
||
padding: '0.15rem 0.4rem',
|
||
background: 'rgba(100,116,139,0.1)',
|
||
borderRadius: '0.2rem',
|
||
}}>
|
||
All metrics
|
||
</span>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Per-Metric Reporting
|
||
|
||
The burndown and donut queries already read from individual `compliance_items` rows (each row has its own `resolution_date`). The per-metric extension does not change reporting queries — it only changes how the PATCH endpoint targets rows.
|
||
|
||
**Existing burndown query (unchanged):**
|
||
|
||
```sql
|
||
SELECT resolution_date, COUNT(*) as count
|
||
FROM compliance_items
|
||
WHERE status = 'active' AND resolution_date IS NOT NULL
|
||
GROUP BY resolution_date
|
||
ORDER BY resolution_date
|
||
```
|
||
|
||
Each row is already a (hostname, metric_id) pair, so per-metric resolution dates are naturally reflected in the burndown without query changes.
|
||
|
||
**Aggregated hostname view (unchanged):**
|
||
|
||
```sql
|
||
SELECT hostname, MAX(resolution_date) as resolution_date
|
||
FROM compliance_items
|
||
WHERE status = 'active'
|
||
GROUP BY hostname
|
||
```
|
||
|
||
When deduplicating by hostname for summary views, the latest (MAX) resolution_date among all active metrics is used.
|
||
|
||
### Error Handling — Per-Metric Extension
|
||
|
||
| Scenario | Response | Behavior |
|
||
|---|---|---|
|
||
| `metric_ids` contains a metric_id with no active row | 400 `{ error: "Invalid metric_id: <value> — no active compliance item found" }` | No rows updated, no history inserted |
|
||
| `metric_ids` is empty array | 400 `{ error: "metric_ids must contain at least one entry" }` | No rows updated |
|
||
| `metric_id` or entry in `metric_ids` exceeds 100 chars | 400 `{ error: "metric_id exceeds 100 characters" }` | No rows updated |
|
||
| `metric_id` or entry in `metric_ids` is empty string | 400 `{ error: "metric_id cannot be empty" }` | No rows updated |
|
||
| Per-metric update targets metrics with different current values | N/A (success) | One history entry per metric per changed field |
|
||
| All targeted metrics already have the new value | 200 `{ updated: 0 }` | No history entries created (no-change skip) |
|
||
|
||
### Backward Compatibility
|
||
|
||
The per-metric extension is fully backward compatible:
|
||
|
||
1. **API:** Requests without `metric_id`/`metric_ids` follow the existing hostname-level path — update all active rows, insert history with `NULL` metric_id.
|
||
2. **Bulk commit:** Continues to apply values to all active metrics for each hostname. No `metric_ids` support in bulk (same as before).
|
||
3. **UI default:** Panel opens with all metrics selected. Saving without changing selection omits `metric_ids` from the request, triggering hostname-level behavior.
|
||
4. **History display:** Existing history entries (NULL metric_id) display as "All metrics" — no visual regression.
|
||
5. **Reporting:** No query changes. Per-row resolution_date was already the source of truth for burndown calculations.
|
||
|
||
## Correctness Properties
|
||
|
||
### Property 1: History entries are created only when values change
|
||
|
||
*For any* PATCH request to `/items/:hostname/metadata` with a resolution_date or remediation_plan value, a history entry is inserted if and only if the new value differs from the current value on the targeted compliance_items row(s). Identical values produce zero history rows.
|
||
|
||
**Validates: Requirements 1.4, 6.3**
|
||
|
||
### Property 2: Per-metric history records the correct old_value per row
|
||
|
||
*For any* per-metric update targeting N metrics with potentially different current values, the system inserts one history entry per metric per changed field, and each entry's `old_value` matches the actual previous value of that specific compliance_items row — not a shared/aggregated value.
|
||
|
||
**Validates: Requirements 11.4, 11.5**
|
||
|
||
### Property 3: Hostname-level updates produce NULL metric_id in history
|
||
|
||
*For any* PATCH request without `metric_id` or `metric_ids`, all resulting history entries have `metric_id = NULL`, indicating the change applied to all active metrics for the hostname.
|
||
|
||
**Validates: Requirements 11.3, 15.1**
|
||
|
||
### Property 4: Backward compatibility — omitting metric_ids updates all active rows
|
||
|
||
*For any* PATCH request without `metric_id` or `metric_ids`, the number of compliance_items rows updated equals the count of active rows for that hostname. The behavior is identical to the pre-extension endpoint.
|
||
|
||
**Validates: Requirements 8.6, 15.1, 15.2**
|
||
|
||
### Property 5: Invalid metric_ids reject the entire request atomically
|
||
|
||
*For any* PATCH request where at least one entry in `metric_ids` does not correspond to an active compliance_item for the hostname, the endpoint returns 400 and no rows are updated, no history entries are created.
|
||
|
||
**Validates: Requirements 8.7**
|
||
|
||
### Property 6: Select All default preserves hostname-level behavior
|
||
|
||
*For any* panel open action on a hostname with N active metrics, the metadata metric selector defaults to all N metrics selected. Saving without changing the selection omits `metric_ids` from the request body, triggering the hostname-level update path.
|
||
|
||
**Validates: Requirements 9.7, 15.3, 15.4**
|
||
|
||
### Property 7: Shared value display is a pure function of selection
|
||
|
||
*For any* set of selected metrics, if all share the same resolution_date (or remediation_plan), the input displays that value. If any differ, the input shows "Multiple values" placeholder. This is deterministic and independent of selection order.
|
||
|
||
**Validates: Requirements 10.2, 10.3, 10.4, 10.5**
|
||
|
||
## Testing Strategy
|
||
|
||
### Unit Tests
|
||
|
||
**Backend:**
|
||
- PATCH without metric_ids updates all active rows (backward compat)
|
||
- PATCH with `metric_id` updates only the single matching row
|
||
- PATCH with `metric_ids` updates only matching rows
|
||
- Invalid metric_id returns 400 with descriptive error
|
||
- History entries include correct metric_id (or NULL for hostname-level)
|
||
- Per-metric update with different current values creates per-row history entries
|
||
- Both `metric_id` and `metric_ids` provided — `metric_ids` wins
|
||
|
||
**Frontend:**
|
||
- MetricChipSelector defaults to all metrics selected on panel open
|
||
- Shared value computation returns correct value when all metrics agree
|
||
- Shared value computation returns null when metrics disagree
|
||
- Save with all selected omits metric_ids from request body
|
||
- Save with subset includes metric_ids in request body
|
||
- History entries with metric_id render MetricChip
|
||
- History entries with null metric_id render "All metrics" label
|
||
|
||
### Property-Based Tests
|
||
|
||
Property-based tests use `fast-check` with a minimum of 100 iterations per property.
|
||
|
||
- **Property 2: Per-metric history records correct old_value** — generate random sets of metrics with varying current values, apply a uniform update, verify each history entry's old_value matches the pre-update value for that specific metric.
|
||
- **Property 4: Backward compatibility** — generate random hostnames with N active metrics, send PATCH without metric_ids, verify all N rows are updated.
|
||
- **Property 7: Shared value display** — generate random arrays of metric objects with varying resolution_date/remediation_plan values, verify computeSharedValues returns the correct shared value or null.
|
||
|
||
### Integration Tests
|
||
|
||
- End-to-end: open panel → select 2 of 4 metrics → change resolution_date → save → verify only 2 rows updated, history entries have correct metric_ids
|
||
- Migration: verify metric_id column exists after running migration, existing rows retain NULL
|
||
- Backward compat: existing bulk-commit flow continues to work without metric_ids
|
||
|
||
### Performance Considerations — Per-Metric Extension
|
||
|
||
- Per-metric updates query current values with `WHERE metric_id = ANY($2)` — the new `(hostname, metric_id)` index on `compliance_item_history` ensures efficient lookups.
|
||
- Worst case: a device with 15 active metrics, all selected, both fields changed = 30 history inserts per save. Still lightweight within a single transaction.
|
||
- The `(hostname, metric_id)` index on the history table supports future filtering of history by metric if needed.
|
||
- No additional queries added to reporting paths.
|