import { useEffect, useRef, useState, useCallback } from 'react'; import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, CircleHelp, HeartPulse, X, Minimize2, Terminal as TerminalIcon, } from 'lucide-react'; import { Modal } from '@/components/ui/modal'; import { DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow'; import TerminalComponent from '@/components/Terminal'; import { useDeployFeedback, VERB_LABELS, type HealthGateUiState } from '@/context/DeployFeedbackContext'; import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style'; const AUTO_CLOSE_SECONDS = 4; // Warn that an in-flight operation has gone quiet after this much silence. The // backend idle-output timeout terminates a truly hung step later (default // ~10min); this earlier heads-up keeps the modal from looking falsely busy. const STALL_WARN_MS = 75_000; interface DeployFeedbackModalProps { isMinimized: boolean; onMinimize: () => void; } function formatElapsed(seconds: number): string { if (seconds >= 60) { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}m ${s}s`; } return `${seconds}s`; } export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) { const { panelState, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback(); const [style] = useDeployFeedbackStyle(); const [showRaw, setShowRaw] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [countdown, setCountdown] = useState(AUTO_CLOSE_SECONDS); const [userScrolledUp, setUserScrolledUp] = useState(false); const startTimeRef = useRef(0); const elapsedIntervalRef = useRef | null>(null); const countdownIntervalRef = useRef | null>(null); const autoCloseHoveredRef = useRef(false); const scrollRef = useRef(null); const { isOpen, stackName, action, status, errorMessage, progressUnavailable } = panelState; useEffect(() => { if (isOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect setShowRaw(false); // eslint-disable-next-line react-hooks/set-state-in-effect setElapsedSeconds(0); // eslint-disable-next-line react-hooks/set-state-in-effect setCountdown(AUTO_CLOSE_SECONDS); // eslint-disable-next-line react-hooks/set-state-in-effect setUserScrolledUp(false); startTimeRef.current = 0; } }, [isOpen]); useEffect(() => { if (isOpen && status !== 'preparing') { if (startTimeRef.current === 0) startTimeRef.current = Date.now(); if (elapsedIntervalRef.current !== null) return; elapsedIntervalRef.current = setInterval(() => { setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000)); }, 1000); } else { if (elapsedIntervalRef.current !== null) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } } return () => { if (elapsedIntervalRef.current !== null) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } }; }, [isOpen, status]); const clearCountdownInterval = useCallback(() => { if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } }, []); const startCountdown = useCallback(() => { clearCountdownInterval(); setCountdown(AUTO_CLOSE_SECONDS); let remaining = AUTO_CLOSE_SECONDS; countdownIntervalRef.current = setInterval(() => { remaining -= 1; setCountdown(remaining); if (remaining <= 0) { clearCountdownInterval(); if (!autoCloseHoveredRef.current) { onPanelClose(); } } }, 1000); }, [clearCountdownInterval, onPanelClose]); // Auto-close only when there is nothing left to watch: success with no gate, // or success whose gate passed. An observing gate suspends the countdown; a // failed/unknown gate keeps the modal open until the user closes it. const gateHoldsOpen = healthGate !== null && healthGate.status !== 'passed'; // Inline style never auto-closes the modal: the banner owns the lifecycle and // a manually opened log should not be yanked mid-read. const canAutoClose = status === 'succeeded' && isOpen && !gateHoldsOpen && style === 'modal'; useEffect(() => { if (canAutoClose) { // eslint-disable-next-line react-hooks/set-state-in-effect startCountdown(); } else { clearCountdownInterval(); } return () => { clearCountdownInterval(); }; }, [canAutoClose, startCountdown, clearCountdownInterval]); useEffect(() => { if (!userScrolledUp && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [logRows.length, userScrolledUp]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; setUserScrolledUp(distanceFromBottom > 32); }, []); const handleMouseEnter = useCallback(() => { autoCloseHoveredRef.current = true; clearCountdownInterval(); }, [clearCountdownInterval]); const handleMouseLeave = useCallback(() => { autoCloseHoveredRef.current = false; if (canAutoClose) { startCountdown(); } }, [canAutoClose, startCountdown]); // In Inline style, closing only hides the modal while the banner is still the // surface (running or cleanly done). When the op or its gate failed the banner // steps aside for the recovery surface, so there is nothing to return to: // closing ends the session (and clears the fallback pill), as Modal style // always does. const inlineFailed = status === 'failed' || healthGate?.status === 'failed'; const closeSurface = useCallback(() => { if (style === 'inline' && !inlineFailed) onMinimize(); else onPanelClose(); }, [style, inlineFailed, onMinimize, onPanelClose]); const handleOpenChange = useCallback( (open: boolean) => { if (!open) closeSurface(); }, [closeSurface] ); const isDialogOpen = isOpen && !isMinimized; const verbLabel = VERB_LABELS[action]; // Re-evaluated each second by the elapsed-time interval's re-render. Warns // while the operation is still streaming but has produced no output for a // while, including the case where no first line ever arrived. Suppressed once // the progress stream is unavailable: no more output can arrive, so the modal // already shows "Live progress unavailable" and a stall warning would be noise. const lastLine = logRows.length > 0 ? logRows[logRows.length - 1].message : null; const secondsSinceOutput = lastOutputAt > 0 ? Math.floor((Date.now() - lastOutputAt) / 1000) : 0; const stalled = status === 'streaming' && !progressUnavailable && lastOutputAt > 0 && Date.now() - lastOutputAt > STALL_WARN_MS; return (
{verbLabel.present} {stackName} {/* Header */}
{verbLabel.present} {stackName} {status !== 'preparing' && elapsedSeconds > 0 && ( {formatElapsed(elapsedSeconds)} )}
{/* Post-update health gate status */} {status === 'succeeded' && healthGate && ( )} {/* Stalled-output warning: in-flight but quiet */} {stalled && (

{lastLine ? `No new output for ${formatElapsed(secondsSinceOutput)}. The operation may be stalled.` : 'No output received yet. The operation may be stalled.'}

{lastLine && (

{lastLine}

)}
)} {/* Body */}
{logRows.length === 0 ? ( ) : (
{logRows.map((row) => ( ))}
)}
{/* Modal style owns the live terminal here, always mounted so onMessage feeds structured rows even before the user toggles raw view. In Inline style the portal owns the single socket, so this modal (opened on demand) renders a text view of the captured lines instead, avoiding a second socket for the same session. */} {style === 'modal' ? (
) : showRaw ? (
              {logRows.map((r) => r.raw).join('\n')}
            
) : null} {/* Footer */}
); } // Re-renders every second via the elapsed-time interval, so the observing // elapsed count stays live without its own timer. function HealthGateBanner({ gate }: { gate: HealthGateUiState }) { const elapsed = gate.startedAt ? Math.max(0, Math.floor((Date.now() - gate.startedAt) / 1000)) : 0; const windowLabel = gate.windowSeconds ? ` of ${gate.windowSeconds}s` : ''; if (gate.status === 'observing') { return (

Health gate: observing containers ({elapsed}s{windowLabel}). Closing this panel does not stop the observation.

); } if (gate.status === 'passed') { return (

Health gate passed: containers stayed healthy through the observation window.

); } if (gate.status === 'failed') { return (

Health gate failed{gate.reason ? `: ${gate.reason}` : ''}. Rollback options are available on the stack.

); } return (

Health gate result is unknown{gate.reason ? `: ${gate.reason}` : ''}. Check the stack's containers directly.

); } interface StatusIndicatorProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; progressUnavailable: boolean; rowCount: number; errorMessage?: string; countdown: number; /** False when auto-close is not eligible: inline style, or while a gate is observing or terminal-unhealthy. */ showCountdown: boolean; /** Active health gate status, or null when no gate was started. */ gateStatus: 'observing' | 'passed' | 'failed' | 'unknown' | null; } function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown, showCountdown, gateStatus }: StatusIndicatorProps) { // While the deploy is still in flight (preparing/streaming) but the progress // socket is gone, the deploy keeps running server-side with no live output. if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { return (
Live progress unavailable
); } if (status === 'preparing') { return (
Connecting...
); } if (status === 'streaming') { return (
{rowCount} {rowCount === 1 ? 'line' : 'lines'}
); } if (status === 'succeeded') { // The compose operation finished, but while a health gate is active the // verdict is not "succeeded" yet: withhold the green state until the gate // passes, and report a failed/unknown gate as the headline result. if (gateStatus === 'observing') { return (
Verifying health
); } if (gateStatus === 'failed') { return (
Health gate failed
); } if (gateStatus === 'unknown') { return (
Health unknown
); } return (
Succeeded {showCountdown && closes in {countdown}s}
); } // failed return (
{errorMessage ?? 'Failed'}
); } interface EmptyBodyProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; progressUnavailable: boolean; } function EmptyBody({ status, progressUnavailable }: EmptyBodyProps) { if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { return (
Live progress is unavailable for this deploy. It continues running in the background.
); } if (status === 'preparing') { return (
Connecting to log stream...
); } return (
Waiting for output...
); }