RollbackPanel.tsx
1 import { useState } from 'react' 2 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 3 import type { Chain } from '../../types/vote' 4 import type { RollbackTarget, RollbackImpact } from '../../types/rollback' 5 import * as rollbackService from '../../services/rollback' 6 import { useAuthStore } from '../../store/auth' 7 8 interface RollbackPanelProps { 9 repoId: string 10 chain: Chain 11 } 12 13 export default function RollbackPanel({ repoId, chain }: RollbackPanelProps) { 14 const { isConnected } = useAuthStore() 15 const queryClient = useQueryClient() 16 const [selectedTarget, setSelectedTarget] = useState<RollbackTarget | null>(null) 17 const [reason, setReason] = useState('') 18 const [showConfirm, setShowConfirm] = useState(false) 19 20 // Fetch rollback targets 21 const { data: targets = [], isLoading: isLoadingTargets } = useQuery({ 22 queryKey: ['rollbackTargets', repoId, chain], 23 queryFn: () => rollbackService.getRollbackTargets(repoId, chain), 24 }) 25 26 // Fetch current checkpoint 27 const { data: currentCheckpoint } = useQuery({ 28 queryKey: ['currentCheckpoint', repoId, chain], 29 queryFn: () => rollbackService.getCurrentCheckpoint(repoId, chain), 30 }) 31 32 // Analyze impact when target is selected 33 const { data: impact, isLoading: isAnalyzing } = useQuery({ 34 queryKey: ['rollbackImpact', selectedTarget?.checkpoint.commitHash, currentCheckpoint?.commitHash], 35 queryFn: () => 36 rollbackService.analyzeRollbackImpact( 37 selectedTarget!.checkpoint, 38 currentCheckpoint!, 39 chain 40 ), 41 enabled: !!selectedTarget && !!currentCheckpoint, 42 }) 43 44 // Request rollback mutation 45 const requestMutation = useMutation({ 46 mutationFn: () => 47 rollbackService.requestRollback(selectedTarget!.checkpoint, reason, chain), 48 onSuccess: () => { 49 queryClient.invalidateQueries({ queryKey: ['pendingRollbacks'] }) 50 setSelectedTarget(null) 51 setReason('') 52 setShowConfirm(false) 53 }, 54 }) 55 56 if (!isConnected) { 57 return ( 58 <div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center"> 59 <p className="text-gray-600">Connect your wallet to manage rollbacks.</p> 60 </div> 61 ) 62 } 63 64 return ( 65 <div className="bg-white border rounded-lg"> 66 <div className="p-4 border-b"> 67 <h3 className="font-semibold text-lg">Rollback Management</h3> 68 <p className="text-sm text-gray-600 mt-1"> 69 Select a checkpoint to rollback to. Rollbacks require governance approval. 70 </p> 71 </div> 72 73 {/* Current State */} 74 {currentCheckpoint && ( 75 <div className="p-4 bg-green-50 border-b"> 76 <div className="flex items-center gap-2"> 77 <div className="h-3 w-3 bg-green-500 rounded-full" /> 78 <span className="font-medium text-green-800">Current Checkpoint</span> 79 </div> 80 <p className="text-sm text-green-700 font-mono mt-1"> 81 {currentCheckpoint.commitHash.slice(0, 16)}... 82 </p> 83 <p className="text-xs text-green-600 mt-1"> 84 Finalized: {new Date(currentCheckpoint.finalizedAt * 1000).toLocaleString()} 85 </p> 86 </div> 87 )} 88 89 {/* Rollback Targets */} 90 <div className="p-4"> 91 <h4 className="font-medium mb-3">Available Rollback Points</h4> 92 93 {isLoadingTargets ? ( 94 <div className="flex justify-center py-8"> 95 <div className="animate-spin h-6 w-6 border-2 border-gray-400 border-t-transparent rounded-full" /> 96 </div> 97 ) : targets.length === 0 ? ( 98 <p className="text-gray-500 text-sm py-4">No rollback points available.</p> 99 ) : ( 100 <div className="space-y-2 max-h-64 overflow-y-auto"> 101 {targets.map((target) => ( 102 <RollbackTargetCard 103 key={target.checkpoint.commitHash} 104 target={target} 105 isSelected={selectedTarget?.checkpoint.commitHash === target.checkpoint.commitHash} 106 isCurrent={currentCheckpoint?.commitHash === target.checkpoint.commitHash} 107 onSelect={() => setSelectedTarget(target)} 108 /> 109 ))} 110 </div> 111 )} 112 </div> 113 114 {/* Impact Analysis */} 115 {selectedTarget && ( 116 <div className="p-4 border-t"> 117 <h4 className="font-medium mb-3">Impact Analysis</h4> 118 119 {isAnalyzing ? ( 120 <div className="flex items-center gap-2 text-gray-600"> 121 <div className="animate-spin h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full" /> 122 <span>Analyzing impact...</span> 123 </div> 124 ) : impact ? ( 125 <ImpactSummary impact={impact} /> 126 ) : null} 127 </div> 128 )} 129 130 {/* Rollback Request Form */} 131 {selectedTarget && impact && !showConfirm && ( 132 <div className="p-4 border-t"> 133 <h4 className="font-medium mb-3">Request Rollback</h4> 134 <textarea 135 value={reason} 136 onChange={(e) => setReason(e.target.value)} 137 placeholder="Reason for rollback (required)..." 138 className="w-full px-3 py-2 border rounded-lg text-sm resize-none h-20" 139 /> 140 <button 141 onClick={() => setShowConfirm(true)} 142 disabled={!reason.trim()} 143 className="mt-3 w-full py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" 144 > 145 Review Rollback Request 146 </button> 147 </div> 148 )} 149 150 {/* Confirmation Dialog */} 151 {showConfirm && impact && ( 152 <div className="p-4 border-t bg-red-50"> 153 <h4 className="font-semibold text-red-800 mb-3">Confirm Rollback Request</h4> 154 155 <div className="bg-white rounded-lg p-4 mb-4"> 156 <dl className="space-y-2 text-sm"> 157 <div className="flex justify-between"> 158 <dt className="text-gray-600">Target Checkpoint:</dt> 159 <dd className="font-mono">{selectedTarget?.checkpoint.commitHash.slice(0, 12)}...</dd> 160 </div> 161 <div className="flex justify-between"> 162 <dt className="text-gray-600">Risk Level:</dt> 163 <dd> 164 <RiskBadge level={impact.riskLevel} /> 165 </dd> 166 </div> 167 <div className="flex justify-between"> 168 <dt className="text-gray-600">Affected PRs:</dt> 169 <dd>{impact.affectedPrs.length}</dd> 170 </div> 171 <div className="flex justify-between"> 172 <dt className="text-gray-600">Broken Dependencies:</dt> 173 <dd>{impact.brokenDependencies.length}</dd> 174 </div> 175 </dl> 176 </div> 177 178 {impact.warnings.length > 0 && ( 179 <div className="bg-yellow-100 rounded-lg p-3 mb-4"> 180 <p className="font-medium text-yellow-800 text-sm mb-2">Warnings:</p> 181 <ul className="text-sm text-yellow-700 space-y-1"> 182 {impact.warnings.map((warning, i) => ( 183 <li key={i}>• {warning}</li> 184 ))} 185 </ul> 186 </div> 187 )} 188 189 <p className="text-sm text-red-700 mb-4"> 190 This will create a governance proposal. A 67% approval from governors 191 is required to execute the rollback. 192 </p> 193 194 <div className="flex gap-3"> 195 <button 196 onClick={() => setShowConfirm(false)} 197 className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50" 198 > 199 Cancel 200 </button> 201 <button 202 onClick={() => requestMutation.mutate()} 203 disabled={requestMutation.isPending} 204 className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50" 205 > 206 {requestMutation.isPending ? 'Submitting...' : 'Submit Request'} 207 </button> 208 </div> 209 </div> 210 )} 211 </div> 212 ) 213 } 214 215 function RollbackTargetCard({ 216 target, 217 isSelected, 218 isCurrent, 219 onSelect, 220 }: { 221 target: RollbackTarget 222 isSelected: boolean 223 isCurrent: boolean 224 onSelect: () => void 225 }) { 226 return ( 227 <button 228 onClick={onSelect} 229 disabled={isCurrent} 230 className={`w-full text-left p-3 rounded-lg border transition-colors ${ 231 isCurrent 232 ? 'bg-gray-100 border-gray-300 cursor-not-allowed opacity-50' 233 : isSelected 234 ? 'bg-blue-50 border-blue-300' 235 : 'bg-white border-gray-200 hover:border-gray-300' 236 }`} 237 > 238 <div className="flex items-center justify-between"> 239 <div className="font-mono text-sm"> 240 {target.checkpoint.commitHash.slice(0, 12)}... 241 </div> 242 {isCurrent && ( 243 <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded"> 244 Current 245 </span> 246 )} 247 </div> 248 <div className="text-xs text-gray-500 mt-1"> 249 {target.commitMessage} 250 </div> 251 <div className="text-xs text-gray-400 mt-1"> 252 {new Date(target.timestamp * 1000).toLocaleString()} 253 </div> 254 </button> 255 ) 256 } 257 258 function ImpactSummary({ impact }: { impact: RollbackImpact }) { 259 return ( 260 <div className="space-y-3"> 261 <div className="flex items-center gap-4"> 262 <RiskBadge level={impact.riskLevel} /> 263 <span className="text-sm text-gray-600"> 264 Est. downtime: {formatDuration(impact.estimatedDowntime)} 265 </span> 266 </div> 267 268 <div className="grid grid-cols-2 gap-4 text-sm"> 269 <div className="bg-gray-50 rounded-lg p-3"> 270 <div className="text-gray-600">Affected PRs</div> 271 <div className="text-xl font-bold">{impact.affectedPrs.length}</div> 272 </div> 273 <div className="bg-gray-50 rounded-lg p-3"> 274 <div className="text-gray-600">Broken Dependencies</div> 275 <div className="text-xl font-bold">{impact.brokenDependencies.length}</div> 276 </div> 277 </div> 278 279 {impact.warnings.length > 0 && ( 280 <div className="bg-yellow-50 rounded-lg p-3"> 281 <p className="font-medium text-yellow-800 text-sm">Warnings:</p> 282 <ul className="text-sm text-yellow-700 mt-1 space-y-1"> 283 {impact.warnings.map((warning, i) => ( 284 <li key={i}>• {warning}</li> 285 ))} 286 </ul> 287 </div> 288 )} 289 </div> 290 ) 291 } 292 293 function RiskBadge({ level }: { level: RollbackImpact['riskLevel'] }) { 294 const styles = { 295 low: 'bg-green-100 text-green-700', 296 medium: 'bg-yellow-100 text-yellow-700', 297 high: 'bg-orange-100 text-orange-700', 298 critical: 'bg-red-100 text-red-700', 299 } 300 301 const labels = { 302 low: 'Low Risk', 303 medium: 'Medium Risk', 304 high: 'High Risk', 305 critical: 'Critical Risk', 306 } 307 308 return ( 309 <span className={`px-2 py-1 rounded text-xs font-medium ${styles[level]}`}> 310 {labels[level]} 311 </span> 312 ) 313 } 314 315 function formatDuration(seconds: number): string { 316 if (seconds < 60) return `${seconds}s` 317 if (seconds < 3600) return `${Math.round(seconds / 60)}m` 318 return `${Math.round(seconds / 3600)}h` 319 }