/ frontend / src / components / rollback / RollbackPanel.tsx
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  }