Add admin page overhaul and compliance schema drift check specs, compliance upload improvements, drift checker helper

This commit is contained in:
root
2026-04-20 20:12:12 +00:00
parent 6082721452
commit 043c85cc69
20 changed files with 56814 additions and 59 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel';
@@ -143,7 +143,7 @@ function SeenBadge({ count }) {
// Main Page
// ---------------------------------------------------------------------------
export default function CompliancePage({ onNavigate }) {
const { canWrite } = useAuth();
const { canWrite, isAdmin } = useAuth();
const [activeTeam, setActiveTeam] = useState('STEAM');
const [activeTab, setActiveTab] = useState('active');
@@ -155,6 +155,9 @@ export default function CompliancePage({ onNavigate }) {
const [error, setError] = useState(null);
const [selectedHost, setSelectedHost] = useState(null);
const [showUpload, setShowUpload] = useState(false);
const [rollbackConfirm, setRollbackConfirm] = useState(false);
const [rollbackLoading, setRollbackLoading] = useState(false);
const [rollbackResult, setRollbackResult] = useState(null);
const fetchSummary = useCallback(async (team) => {
try {
@@ -198,6 +201,28 @@ export default function CompliancePage({ onNavigate }) {
fetchDevices(activeTeam, activeTab);
};
const handleRollback = async () => {
if (!lastUpload) return;
setRollbackLoading(true);
try {
const res = await fetch(`${API_BASE}/compliance/rollback/${lastUpload.id}`, {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Rollback failed');
setRollbackResult(data);
setRollbackConfirm(false);
refresh();
// Auto-dismiss result after 4 seconds
setTimeout(() => setRollbackResult(null), 4000);
} catch (err) {
setRollbackResult({ error: err.message });
} finally {
setRollbackLoading(false);
}
};
// In-memory filters
const filteredDevices = devices
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
@@ -221,9 +246,30 @@ export default function CompliancePage({ onNavigate }) {
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{lastUpload ? (
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
</span>
<>
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
</span>
{isAdmin() && (
<button
onClick={() => setRollbackConfirm(true)}
title="Rollback last upload"
style={{
background: 'none', border: '1px solid rgba(239,68,68,0.25)',
borderRadius: '0.25rem', padding: '0.15rem 0.4rem',
cursor: 'pointer', color: '#64748B',
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
fontSize: '0.62rem', fontFamily: 'monospace',
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.6)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.25)'; }}
>
<RotateCcw style={{ width: '10px', height: '10px' }} />
Rollback
</button>
)}
</>
) : (
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
)}
@@ -439,6 +485,118 @@ export default function CompliancePage({ onNavigate }) {
onUploadComplete={() => { setShowUpload(false); refresh(); }}
/>
)}
{/* ── Rollback confirmation modal ──────────────────────────── */}
{rollbackConfirm && lastUpload && (
<div style={{
position: 'fixed', inset: 0, zIndex: 60,
background: 'rgba(10, 14, 39, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '1rem',
}}>
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.75rem',
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
width: '100%', maxWidth: '420px',
padding: '2rem',
}}>
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#EF4444', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '1rem' }}>
Rollback Upload
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: '1.5', marginBottom: '0.5rem' }}>
This will reverse the most recent upload:
</div>
<div style={{
fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
padding: '0.625rem 0.75rem', marginBottom: '1.25rem',
border: '1px solid rgba(239,68,68,0.15)',
}}>
<div><span style={{ color: '#64748B' }}>File:</span> {lastUpload.report_date || 'unknown date'}</div>
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#475569' }}>
New items will be deleted, resolved items will be reactivated, and the upload record will be removed.
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
onClick={() => setRollbackConfirm(false)}
style={{
flex: 1, padding: '0.625rem', background: 'transparent',
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
color: '#64748B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.8rem',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
Cancel
</button>
<button
onClick={handleRollback}
disabled={rollbackLoading}
style={{
flex: 2, padding: '0.625rem',
background: rollbackLoading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.1)',
border: '1px solid #EF4444',
borderRadius: '0.375rem',
color: '#EF4444', cursor: rollbackLoading ? 'wait' : 'pointer',
fontFamily: 'monospace', fontSize: '0.8rem',
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
opacity: rollbackLoading ? 0.6 : 1,
}}
onMouseEnter={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.18)'; }}
onMouseLeave={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; }}>
{rollbackLoading
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Rolling back</>
: <><RotateCcw style={{ width: '14px', height: '14px' }} /> Confirm Rollback</>
}
</button>
</div>
</div>
</div>
)}
{/* ── Rollback result toast ────────────────────────────────── */}
{rollbackResult && (
<div style={{
position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 70,
background: rollbackResult.error
? 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)'
: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
border: `1px solid ${rollbackResult.error ? 'rgba(239,68,68,0.4)' : 'rgba(16,185,129,0.4)'}`,
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
padding: '0.875rem 1.25rem',
maxWidth: '360px',
fontFamily: 'monospace', fontSize: '0.75rem',
color: rollbackResult.error ? '#F87171' : '#10B981',
cursor: 'pointer',
}}
onClick={() => setRollbackResult(null)}
>
{rollbackResult.error ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
{rollbackResult.error}
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<RotateCcw style={{ width: '14px', height: '14px' }} />
{rollbackResult.message}
</div>
{rollbackResult.rolled_back && (
<div style={{ fontSize: '0.68rem', color: '#64748B' }}>
{rollbackResult.rolled_back.items_deleted} items deleted, {rollbackResult.rolled_back.items_reactivated} reactivated
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -1,15 +1,122 @@
import React, { useState, useRef } from 'react';
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet, ChevronDown, ChevronRight, ShieldAlert, AlertTriangle, Info, Wrench } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// phase: idle → uploading → preview → committing → done | error
/* ── Drift Findings Group sub-component ─────────────────────────── */
const SEVERITY_CONFIG = {
breaking: { label: 'Breaking', color: '#EF4444', Icon: ShieldAlert },
silent_miss: { label: 'Silent-miss', color: '#F59E0B', Icon: AlertTriangle },
cosmetic: { label: 'Cosmetic', color: '#94A3B8', Icon: Info },
};
function DriftFindingsGroup({ severity, findings }) {
const [expanded, setExpanded] = useState(false);
const { label, color, Icon } = SEVERITY_CONFIG[severity];
const COLLAPSE_THRESHOLD = 5;
const needsCollapse = findings.length > COLLAPSE_THRESHOLD;
const visibleFindings = needsCollapse && !expanded
? findings.slice(0, COLLAPSE_THRESHOLD)
: findings;
const hiddenCount = findings.length - COLLAPSE_THRESHOLD;
return (
<div style={{ marginBottom: '1rem' }}>
{/* Group header */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
marginBottom: '0.5rem',
}}>
<Icon style={{ width: '14px', height: '14px', color, flexShrink: 0 }} />
<span style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.75rem', fontWeight: '600', color,
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{label}
</span>
<span style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.65rem', fontWeight: '700', color,
background: `${color}18`, border: `1px solid ${color}40`,
borderRadius: '0.25rem', padding: '0.1rem 0.4rem',
minWidth: '1.25rem', textAlign: 'center',
}}>
{findings.length}
</span>
</div>
{/* Findings list */}
{visibleFindings.map((f, i) => (
<div key={i} style={{
borderLeft: `4px solid ${color}`,
background: 'rgba(15,23,42,0.6)',
borderRadius: '0 0.375rem 0.375rem 0',
padding: '0.5rem 0.75rem',
marginBottom: '0.375rem',
}}>
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.8rem', color: '#E2E8F0', lineHeight: '1.4',
}}>
{f.message}
</div>
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem', color: `${color}CC`, marginTop: '0.2rem',
}}>
{f.value}
</div>
</div>
))}
{/* Show more / less toggle */}
{needsCollapse && (
<button
onClick={() => setExpanded(!expanded)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: '0.25rem',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem', color: '#64748B', padding: '0.25rem 0',
}}
onMouseEnter={e => e.currentTarget.style.color = '#94A3B8'}
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
>
{expanded
? <><ChevronDown style={{ width: '12px', height: '12px' }} /> Show less</>
: <><ChevronRight style={{ width: '12px', height: '12px' }} /> Show {hiddenCount} more</>
}
</button>
)}
</div>
);
}
// phase: idle → uploading → drift-review (if findings) → preview → committing → done | error
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
const [phase, setPhase] = useState('idle');
const [previewData, setPreviewData] = useState(null);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
const { isAdmin } = useAuth();
const [phase, setPhase] = useState('idle');
const [previewData, setPreviewData] = useState(null);
const [driftReport, setDriftReport] = useState(null);
const [reconcileChanges, setReconcileChanges] = useState(null);
const [reconciling, setReconciling] = useState(false);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const [lastFile, setLastFile] = useState(null);
const [lastSchema, setLastSchema] = useState(null);
const fileInputRef = useRef(null);
/** Check whether a drift report has any findings */
const hasDriftFindings = (drift) => {
if (!drift) return false;
return (
(drift.breaking && drift.breaking.length > 0) ||
(drift.silent_miss && drift.silent_miss.length > 0) ||
(drift.cosmetic && drift.cosmetic.length > 0)
);
};
const handleFile = async (file) => {
if (!file) return;
@@ -20,6 +127,9 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
setPhase('uploading');
setError(null);
setDriftReport(null);
setReconcileChanges(null);
setLastFile(file);
try {
const formData = new FormData();
@@ -37,7 +147,20 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
}
setPreviewData(data);
setPhase('preview');
// Store schema for reconcile requests
if (data.schema) {
setLastSchema(data.schema);
}
// Drift routing: if drift is non-null and has findings, enter drift-review
// If drift is null (failed) or has no findings, skip to preview
if (data.drift && hasDriftFindings(data.drift)) {
setDriftReport(data.drift);
setPhase('drift-review');
} else {
setPhase('preview');
}
} catch (err) {
setError(err.message);
setPhase('error');
@@ -72,6 +195,70 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
}
};
/** Admin-only: reconcile config to fix breaking/silent-miss drift, then re-upload */
const handleReconcile = async () => {
if (!driftReport || reconciling) return;
setReconciling(true);
setError(null);
try {
// Step 1: Call reconcile endpoint
const reconcileRes = await fetch(`${API_BASE}/compliance/reconcile-config`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drift: driftReport, schema: lastSchema }),
});
const reconcileData = await reconcileRes.json();
if (!reconcileRes.ok) throw new Error(reconcileData.error || 'Reconcile failed');
setReconcileChanges(reconcileData.changes);
// Step 2: Re-upload the same file to get a fresh drift check
if (!lastFile) {
setReconciling(false);
return;
}
setPhase('uploading');
const formData = new FormData();
formData.append('file', lastFile);
const previewRes = await fetch(`${API_BASE}/compliance/preview`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const previewData = await previewRes.json();
if (!previewRes.ok) {
throw new Error(previewData.error || 'Re-upload failed after reconcile');
}
setPreviewData(previewData);
setReconciling(false);
// Update schema for any subsequent reconcile
if (previewData.schema) {
setLastSchema(previewData.schema);
}
if (previewData.drift && hasDriftFindings(previewData.drift)) {
setDriftReport(previewData.drift);
setPhase('drift-review');
} else {
setDriftReport(null);
setPhase('preview');
}
} catch (err) {
setError(err.message);
setReconciling(false);
setPhase('error');
}
};
const TEAL = '#14B8A6';
return (
@@ -87,7 +274,10 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
border: `1px solid ${TEAL}40`,
borderRadius: '0.75rem',
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
width: '100%', maxWidth: '480px',
width: '100%', maxWidth: phase === 'drift-review' ? '560px' : '480px',
maxHeight: 'calc(100vh - 2rem)',
overflowY: 'auto',
transition: 'max-width 0.3s ease',
padding: '2rem',
}}>
{/* Header */}
@@ -148,6 +338,163 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
</div>
)}
{/* DRIFT-REVIEW — schema drift findings */}
{phase === 'drift-review' && driftReport && (
<>
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.8rem', color: '#64748B',
textTransform: 'uppercase', letterSpacing: '0.05em',
marginBottom: '1rem',
}}>
Schema Drift Review
</div>
<div style={{
maxHeight: '320px', overflowY: 'auto',
marginBottom: '1rem',
paddingRight: '0.25rem',
}}>
{driftReport.breaking && driftReport.breaking.length > 0 && (
<DriftFindingsGroup severity="breaking" findings={driftReport.breaking} />
)}
{driftReport.silent_miss && driftReport.silent_miss.length > 0 && (
<DriftFindingsGroup severity="silent_miss" findings={driftReport.silent_miss} />
)}
{driftReport.cosmetic && driftReport.cosmetic.length > 0 && (
<DriftFindingsGroup severity="cosmetic" findings={driftReport.cosmetic} />
)}
</div>
{/* Status message */}
{driftReport.breaking && driftReport.breaking.length > 0 && (
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem', color: '#EF4444',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.25)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
lineHeight: '1.4',
}}>
{isAdmin()
? 'Upload blocked — use "Reconcile Config" to auto-fix the parser configuration, or update it manually.'
: 'Upload blocked — an admin must reconcile the parser configuration before this report can be uploaded.'}
</div>
)}
{(!driftReport.breaking || driftReport.breaking.length === 0) &&
driftReport.silent_miss && driftReport.silent_miss.length > 0 && (
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem', color: '#F59E0B',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.25)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
lineHeight: '1.4',
}}>
Review warnings before proceeding. Data may be miscategorised or dropped.
{isAdmin() && ' Use "Reconcile Config" to auto-add unknown metrics and sheets.'}
</div>
)}
{/* Reconcile changes summary (shown after a successful reconcile) */}
{reconcileChanges && reconcileChanges.length > 0 && (
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem', color: '#10B981',
background: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.25)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
lineHeight: '1.6',
}}>
<div style={{ fontWeight: '600', marginBottom: '0.25rem' }}>
Config reconciled {reconcileChanges.length} change(s) applied:
</div>
{reconcileChanges.map((c, i) => (
<div key={i} style={{ color: '#94A3B8' }}>
{c.action === 'added' ? '+' : ''} {c.detail}
</div>
))}
<div style={{ color: '#10B981', marginTop: '0.25rem' }}>Re-uploading file</div>
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button onClick={() => { setPhase('idle'); setPreviewData(null); setDriftReport(null); setReconcileChanges(null); setLastFile(null); setLastSchema(null); }}
style={{
flex: 1, minWidth: '80px', padding: '0.625rem', background: 'transparent',
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
color: '#64748B', cursor: 'pointer',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
Cancel
</button>
{/* Admin reconcile button — shown when there are breaking or silent-miss findings */}
{isAdmin() && ((driftReport.breaking && driftReport.breaking.length > 0) ||
(driftReport.silent_miss && driftReport.silent_miss.length > 0)) && (
<button
onClick={handleReconcile}
disabled={reconciling}
style={{
flex: 2, minWidth: '140px', padding: '0.625rem',
background: reconciling ? 'rgba(245,158,11,0.05)' : 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.5)',
borderRadius: '0.375rem',
color: '#F59E0B',
cursor: reconciling ? 'wait' : 'pointer',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
opacity: reconciling ? 0.6 : 1,
}}
onMouseEnter={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.18)'; }}
onMouseLeave={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.1)'; }}>
{reconciling
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Reconciling</>
: <><Wrench style={{ width: '14px', height: '14px' }} /> Reconcile Config</>
}
</button>
)}
<button
onClick={() => { setPhase('preview'); }}
disabled={driftReport.breaking && driftReport.breaking.length > 0}
style={{
flex: 2, padding: '0.625rem',
background: (driftReport.breaking && driftReport.breaking.length > 0)
? 'rgba(100,116,139,0.08)'
: `${TEAL}18`,
border: `1px solid ${(driftReport.breaking && driftReport.breaking.length > 0) ? 'rgba(100,116,139,0.3)' : TEAL}`,
borderRadius: '0.375rem',
color: (driftReport.breaking && driftReport.breaking.length > 0) ? '#475569' : TEAL,
cursor: (driftReport.breaking && driftReport.breaking.length > 0) ? 'not-allowed' : 'pointer',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
opacity: (driftReport.breaking && driftReport.breaking.length > 0) ? 0.5 : 1,
}}
onMouseEnter={e => {
if (!(driftReport.breaking && driftReport.breaking.length > 0)) {
e.currentTarget.style.background = `${TEAL}28`;
}
}}
onMouseLeave={e => {
if (!(driftReport.breaking && driftReport.breaking.length > 0)) {
e.currentTarget.style.background = `${TEAL}18`;
}
}}>
Continue to Preview
</button>
</div>
</>
)}
{/* PREVIEW — diff summary + confirm */}
{phase === 'preview' && previewData && (
<>