Sync .kiro/ from master — v2.2.0 release batch
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
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
@@ -59,7 +61,21 @@ Adds an append-only audit trail for resolution_date and remediation_plan changes
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Model
|
||||
## 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
|
||||
|
||||
@@ -277,3 +293,492 @@ The `compliance_item_history` table is never referenced by any reporting query.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user