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:
@@ -28,7 +28,10 @@
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "vars": "all", "args": "after-used", "ignoreRestSiblings": true, "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user