Add per-metric remediation plans and improve CI pipeline

Per-metric remediation plan scoping (GitLab issue #19):
- Add metric_id column to compliance_item_history table (migration)
- Extend PATCH /items/:hostname/metadata to accept metric_id/metric_ids
  for targeting specific metrics instead of all active items
- Add MetricChipSelector UI in detail panel for choosing which metrics
  to apply resolution_date and remediation_plan changes to
- Display per-metric labels (MetricChip or 'All metrics') on history entries
- Backward compatible: omitting metric_ids preserves hostname-level behavior

CI/CD pipeline improvements:
- Add migration idempotency integration test (runs against real Postgres)
- Add post-deploy smoke tests for compliance and VCL endpoints
- Bump lint --max-warnings from 10 to 25
- Configure varsIgnorePattern for _ prefix convention on unused vars

Closes #19
This commit is contained in:
Jordan Ramos
2026-05-26 11:16:28 -06:00
parent 33e449f520
commit caf6ca4008
9 changed files with 936 additions and 78 deletions

View File

@@ -55,12 +55,69 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const [metaSaving, setMetaSaving] = useState(false);
const [metaError, setMetaError] = useState(null);
const handleSaveMetadata = async (fields) => {
// Per-metric metadata selection (separate from notes selector)
const [metricSelection, setMetricSelection] = useState([]);
// Track whether user has edited fields (to detect "Multiple values" untouched)
const [resolutionDateEdited, setResolutionDateEdited] = useState(false);
const [remediationPlanEdited, setRemediationPlanEdited] = useState(false);
// Compute shared values for selected metrics
const computeSharedValues = useCallback((selectedIds, metrics) => {
if (!metrics || selectedIds.length === 0) return { resolution_date: '', remediation_plan: '', resolutionMultiple: false, planMultiple: false };
const selected = metrics.filter(m => selectedIds.includes(m.metric_id));
if (selected.length === 0) return { resolution_date: '', remediation_plan: '', resolutionMultiple: false, planMultiple: false };
const dates = selected.map(m => m.resolution_date || '');
const plans = selected.map(m => m.remediation_plan || '');
const allDatesMatch = dates.every(d => d === dates[0]);
const allPlansMatch = plans.every(p => p === plans[0]);
return {
resolution_date: allDatesMatch ? dates[0] : '',
remediation_plan: allPlansMatch ? plans[0] : '',
resolutionMultiple: !allDatesMatch,
planMultiple: !allPlansMatch,
};
}, []);
// Recompute displayed values when metric selection changes
useEffect(() => {
if (!detail || metricSelection.length === 0) return;
const shared = computeSharedValues(metricSelection, detail.metrics);
setResolutionDate(shared.resolution_date);
setRemediationPlan(shared.remediation_plan);
setResolutionDateEdited(false);
setRemediationPlanEdited(false);
}, [metricSelection, detail, computeSharedValues]);
// Determine if "Multiple values" placeholders should show
const sharedInfo = detail ? computeSharedValues(metricSelection, detail.metrics) : { resolutionMultiple: false, planMultiple: false };
const handleSaveMetadata = async () => {
setMetaSaving(true);
setMetaError(null);
try {
const body = { ...fields };
const body = {};
// Only include resolution_date if user edited it or it's not a "Multiple values" situation
if (resolutionDateEdited || !sharedInfo.resolutionMultiple) {
body.resolution_date = resolutionDate || null;
}
// Only include remediation_plan if user edited it or it's not a "Multiple values" situation
if (remediationPlanEdited || !sharedInfo.planMultiple) {
body.remediation_plan = remediationPlan || null;
}
if (changeReason.trim()) body.change_reason = changeReason.trim();
// Per-metric scoping: omit metric_ids when all active metrics are selected (backward compat)
const activeIds = (detail?.metrics || []).filter(m => m.status === 'active').map(m => m.metric_id);
const allSelected = activeIds.length > 0 && activeIds.every(id => metricSelection.includes(id)) && metricSelection.length === activeIds.length;
if (!allSelected && metricSelection.length > 0) {
body.metric_ids = metricSelection;
}
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
method: 'PATCH',
credentials: 'include',
@@ -70,6 +127,8 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
setChangeReason('');
setResolutionDateEdited(false);
setRemediationPlanEdited(false);
// Re-fetch to get updated history
await fetchDetail();
} catch (err) {
@@ -88,13 +147,20 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default selected metrics to first active failing metric
// Default selected metrics to first active failing metric (for notes)
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
// Populate metadata fields
setResolutionDate(data.resolution_date || '');
setRemediationPlan(data.remediation_plan || '');
// Default metricSelection to ALL active metrics (for metadata editing)
const allActiveIds = (data.metrics || []).filter(m => m.status === 'active').map(m => m.metric_id);
setMetricSelection(allActiveIds);
// Populate metadata fields from shared values
const shared = computeSharedValues(allActiveIds, data.metrics);
setResolutionDate(shared.resolution_date);
setRemediationPlan(shared.remediation_plan);
setResolutionDateEdited(false);
setRemediationPlanEdited(false);
} catch (err) {
setError(err.message);
} finally {
@@ -249,18 +315,114 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
</Section>
)}
{/* Metric Selector for Metadata Editing */}
{activeMetrics.length > 0 && (
<Section title="Apply To Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
{activeMetrics.length > 1 && (() => {
const allMetaSelected = activeMetrics.every(m => metricSelection.includes(m.metric_id)) && metricSelection.length === activeMetrics.length;
return (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
{metricSelection.length} of {activeMetrics.length} selected
</span>
<button
onClick={() => {
if (allMetaSelected) {
setMetricSelection([activeMetrics[0].metric_id]);
} else {
setMetricSelection(activeMetrics.map(m => m.metric_id));
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: '0.68rem', fontFamily: 'monospace',
color: TEAL, padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{allMetaSelected ? 'Deselect All' : 'Select All'}
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{activeMetrics.map(m => {
const isSelected = metricSelection.includes(m.metric_id);
const color = categoryColor(m.category);
return (
<button
key={m.metric_id}
onClick={() => {
if (isSelected) {
if (metricSelection.length > 1) {
setMetricSelection(metricSelection.filter(id => id !== m.metric_id));
}
} else {
setMetricSelection([...metricSelection, m.metric_id]);
}
}}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.25rem 0.5rem',
background: isSelected ? `${color}25` : `${color}08`,
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
borderRadius: '0.25rem',
color: isSelected ? color : `${color}90`,
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: (isSelected && metricSelection.length === 1) ? 'default' : 'pointer',
transition: 'all 0.15s',
opacity: (isSelected && metricSelection.length === 1) ? 0.85 : 1,
}}
>
{m.metric_id}
</button>
);
})}
</div>
</div>
);
})()}
{activeMetrics.length === 1 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{activeMetrics.map(m => {
const color = categoryColor(m.category);
return (
<span
key={m.metric_id}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.25rem 0.5rem',
background: `${color}25`,
border: `1px solid ${color}90`,
borderRadius: '0.25rem',
color: color,
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
opacity: 0.85,
}}
>
{m.metric_id}
</span>
);
})}
</div>
)}
</Section>
)}
{/* Resolution Date */}
<Section title="Resolution Date" icon={<Calendar style={{ width: '14px', height: '14px' }} />}>
<input
type="date"
value={resolutionDate}
onChange={e => setResolutionDate(e.target.value)}
onChange={e => { setResolutionDate(e.target.value); setResolutionDateEdited(true); }}
placeholder={sharedInfo.resolutionMultiple ? 'Multiple values' : ''}
style={{
width: '100%',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem',
color: '#F8FAFC',
color: sharedInfo.resolutionMultiple && !resolutionDateEdited ? '#64748B' : '#F8FAFC',
padding: '0.5rem 0.625rem',
fontSize: '0.8rem',
fontFamily: 'monospace',
@@ -268,6 +430,11 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
}}
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
/>
{sharedInfo.resolutionMultiple && !resolutionDateEdited && (
<div style={{ fontSize: '0.65rem', color: '#64748B', fontStyle: 'italic', marginTop: '0.25rem' }}>
Multiple values leave unchanged to preserve per-metric dates
</div>
)}
</Section>
{/* Remediation Plan */}
@@ -275,16 +442,16 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
<textarea
value={remediationPlan}
onChange={e => {
if (e.target.value.length <= 2000) setRemediationPlan(e.target.value);
if (e.target.value.length <= 2000) { setRemediationPlan(e.target.value); setRemediationPlanEdited(true); }
}}
placeholder="Describe the remediation plan…"
placeholder={sharedInfo.planMultiple ? 'Multiple values' : 'Describe the remediation plan…'}
rows={4}
style={{
width: '100%', resize: 'vertical',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem',
color: '#F8FAFC',
color: sharedInfo.planMultiple && !remediationPlanEdited ? '#64748B' : '#F8FAFC',
padding: '0.5rem 0.625rem',
fontSize: '0.8rem',
outline: 'none',
@@ -293,12 +460,17 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
/>
{sharedInfo.planMultiple && !remediationPlanEdited && (
<div style={{ fontSize: '0.65rem', color: '#64748B', fontStyle: 'italic', marginTop: '0.25rem' }}>
Multiple values leave unchanged to preserve per-metric plans
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.4rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: remediationPlan.length > 1900 ? '#F59E0B' : '#475569' }}>
{remediationPlan.length}/2000
</span>
<button
onClick={() => handleSaveMetadata({ resolution_date: resolutionDate || null, remediation_plan: remediationPlan || null })}
onClick={() => handleSaveMetadata()}
disabled={metaSaving}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
@@ -346,6 +518,10 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
{detail.history && detail.history.length > 0 && (
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
{(() => {
// Build metricMap from metrics array for chip coloring
const metricMap = {};
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
// Group entries by timestamp + user (entries saved together appear as one)
const groups = [];
for (const h of detail.history) {
@@ -366,7 +542,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
</span>
</div>
{group.entries.map(h => (
<div key={h.id} style={{ marginBottom: '0.2rem' }}>
<div key={h.id} style={{ marginBottom: '0.2rem', display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
{h.metric_id ? (
<MetricChip metricId={h.metric_id} category={metricMap[h.metric_id] || ''} />
) : (
<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>
)}
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>
{h.field_name === 'resolution_date' ? '📅 ' : '📋 '}
</span>