import { useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { ConfirmModal } from '@/components/ui/modal'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Unlock } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events'; import { TableSkeleton } from './TableSkeleton'; export interface RollbackGeneration { id: string; shortId: string; stackName: string; status: 'active' | 'restored_current' | 'superseded' | 'recovery_required'; isCurrent: boolean; phase: string; createdAt: number; artifactExpiresAt: number | null; /** Best-effort UI hint only; the server revalidates eligibility on release. */ releasable: boolean; } interface RollbackGenerationsTabProps { generations: RollbackGeneration[]; isLoading: boolean; isAdmin: boolean; nodeId?: number; /** Refetches the Resources page's data after a successful release. */ onReleased: () => void | Promise; } function formatExpiry(gen: RollbackGeneration): string { if (gen.isCurrent) return 'Protected while current'; if (gen.status === 'recovery_required') return 'Recovery required'; if (gen.artifactExpiresAt === null) return 'Pending'; const days = (gen.artifactExpiresAt - Date.now()) / (24 * 60 * 60 * 1000); if (days <= 0) return 'Expiring now'; if (days < 1) return `Expires in ${Math.max(1, Math.round(days * 24))}h`; return `Expires in ${Math.round(days)}d`; } function StateBadge({ gen }: { gen: RollbackGeneration }) { switch (gen.status) { case 'recovery_required': return Recovery required; case 'superseded': return Superseded; case 'active': case 'restored_current': return gen.isCurrent ? Current : Superseded; default: { const unhandled: never = gen.status; return {String(unhandled)}; } } } /** * Full-stack rollback generations (the sencho-rb//:hold images * StackUpdateRecoveryService creates). Kept in its own tab rather than the * generic Images list: this is durable recovery state with its own lifecycle * (stack, generation, retention, release), not ordinary Docker image inventory. */ export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId, onReleased }: RollbackGenerationsTabProps) { const [confirmRelease, setConfirmRelease] = useState(null); const [isReleasing, setIsReleasing] = useState(false); const handleRelease = async () => { if (!confirmRelease) return; setIsReleasing(true); const loadingId = toast.loading(`Releasing rollback protection for ${confirmRelease.shortId}...`); try { const res = await apiFetch(`/system/rollback/generations/${confirmRelease.id}/release`, { method: 'POST' }); const data = await res.json().catch(() => null); if (!res.ok) { throw new Error(data?.error || 'Failed to release rollback protection'); } toast.success(data?.message || 'Rollback protection released'); await onReleased(); } catch (error) { const err = error as Record; toast.error(String(err?.message || 'Failed to release rollback protection')); } finally { toast.dismiss(loadingId); setIsReleasing(false); setConfirmRelease(null); } }; return ( <>

Rollback-protected images from full-stack updates. Each generation is kept so a failed update can be automatically rolled back, and clears on its own once it is superseded and its retention window passes (configurable under Settings → Infrastructure → Stacks → Deploy Guardrails).

Stack Generation State Retention Actions {isLoading ? : ( {generations.length === 0 ? ( No rollback-protected generations on this node. ) : generations.map((gen, i) => ( {gen.shortId} {formatExpiry(gen)} {isAdmin && ( {gen.releasable ? 'Release rollback protection' : 'Not releasable right now (mid-recovery or observing a health gate)'} )} ))} )}
!open && setConfirmRelease(null)} variant="destructive" kicker="ROLLBACK · RELEASE · IRREVERSIBLE" title={`Release rollback protection for ${confirmRelease?.stackName ?? ''}`} confirmLabel={isReleasing ? 'Releasing...' : 'Release'} confirming={isReleasing} onConfirm={handleRelease} >

{confirmRelease?.isCurrent ? ( <> This is {confirmRelease?.stackName}'s current rollback point. Releasing it now means Sencho will not be able to automatically roll this stack back until its next successful full-stack update. ) : ( <> Permanently removes the held rollback image for generation{' '} {confirmRelease?.shortId}{' '} ahead of its normal retention window. )}

); }