Add remediation plan and resolution date history tracking
New table compliance_item_history stores an append-only audit trail of changes to resolution_date and remediation_plan. The current values remain on compliance_items for fast VCL reporting queries (no double-counting). Backend: - Migration: creates compliance_item_history with indexes - PATCH /items/:hostname/metadata: records old→new in history before updating, accepts optional change_reason field (max 500 chars) - GET /items/:hostname: returns history array (last 10 entries, newest first) - POST /vcl/bulk-commit: records history for each changed field per hostname Frontend: - ComplianceDetailPanel: added change reason input below Save button - Added Change History section showing field changes with timestamps, usernames, old→new values, and reasons - Re-fetches detail after save to show updated history immediately Tests updated to match new transaction-based PATCH flow.
This commit is contained in:
@@ -51,6 +51,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
// Metadata fields
|
||||
const [resolutionDate, setResolutionDate] = useState('');
|
||||
const [remediationPlan, setRemediationPlan] = useState('');
|
||||
const [changeReason, setChangeReason] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaError, setMetaError] = useState(null);
|
||||
|
||||
@@ -58,14 +59,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
setMetaSaving(true);
|
||||
setMetaError(null);
|
||||
try {
|
||||
const body = { ...fields };
|
||||
if (changeReason.trim()) body.change_reason = changeReason.trim();
|
||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
||||
setChangeReason('');
|
||||
// Re-fetch to get updated history
|
||||
await fetchDetail();
|
||||
} catch (err) {
|
||||
setMetaError(err.message);
|
||||
} finally {
|
||||
@@ -315,8 +321,54 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</button>
|
||||
</div>
|
||||
{metaError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem', fontFamily: 'monospace' }}>{metaError}</div>}
|
||||
{/* Change reason input */}
|
||||
<input
|
||||
type="text"
|
||||
value={changeReason}
|
||||
onChange={e => { if (e.target.value.length <= 500) setChangeReason(e.target.value); }}
|
||||
placeholder="Reason for change (optional)"
|
||||
style={{
|
||||
width: '100%', marginTop: '0.5rem',
|
||||
background: 'rgba(15,23,42,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#94A3B8',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontSize: '0.7rem',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = 'rgba(20,184,166,0.4)'}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.08)'}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Change History */}
|
||||
{detail.history && detail.history.length > 0 && (
|
||||
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{detail.history.map(h => (
|
||||
<div key={h.id} style={{ marginBottom: '0.6rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#94A3B8' }}>
|
||||
{h.field_name === 'resolution_date' ? '📅' : '📋'} {h.field_name.replace('_', ' ')}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#475569' }}>
|
||||
{h.changed_at ? new Date(h.changed_at).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.15rem' }}>
|
||||
<span style={{ color: '#EF4444' }}>{h.old_value || '—'}</span>
|
||||
<span style={{ color: '#475569' }}> → </span>
|
||||
<span style={{ color: '#10B981' }}>{h.field_name === 'remediation_plan' ? (h.new_value && h.new_value.length > 60 ? h.new_value.slice(0, 60) + '…' : (h.new_value || '—')) : (h.new_value || '—')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#475569', marginTop: '0.1rem' }}>
|
||||
{h.changed_by}{h.change_reason ? ` · ${h.change_reason}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||
{detail.notes.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user