Add admin page overhaul and compliance schema drift check specs, compliance upload improvements, drift checker helper
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user