import { createSignal, Show, onMount, onCleanup, createEffect } from 'solid-js'; import { UpdatesAPI, type UpdateStatus } from '@/api/updates'; import { logger } from '@/utils/logger'; interface UpdateProgressModalProps { isOpen: boolean; onClose: () => void; onViewHistory: () => void; connected?: () => boolean; reconnecting?: () => boolean; } export function UpdateProgressModal(props: UpdateProgressModalProps) { const [status, setStatus] = createSignal(null); const [isComplete, setIsComplete] = createSignal(false); const [hasError, setHasError] = createSignal(false); const [isRestarting, setIsRestarting] = createSignal(false); const [wsDisconnected, setWsDisconnected] = createSignal(false); let pollInterval: number | undefined; let healthCheckTimer: number | undefined; let healthCheckAttempts = 0; const clearHealthCheckTimer = () => { if (healthCheckTimer !== undefined) { clearTimeout(healthCheckTimer); healthCheckTimer = undefined; } }; const pollStatus = async () => { try { const currentStatus = await UpdatesAPI.getUpdateStatus(); setStatus(currentStatus); // Check if restarting if (currentStatus.status === 'restarting') { setIsRestarting(true); if (pollInterval) { clearInterval(pollInterval); } // Start health check polling startHealthCheckPolling(); return; } // Check if complete or error if ( currentStatus.status === 'completed' || currentStatus.status === 'idle' || currentStatus.status === 'error' ) { // If completed successfully, verify backend health and reload to get new version if (currentStatus.status === 'completed' && !currentStatus.error) { if (pollInterval) { clearInterval(pollInterval); } // Verify backend is healthy and reload try { const healthCheck = await fetch('/api/health', { cache: 'no-store' }); if (healthCheck.ok) { logger.info('Update completed, backend healthy, reloading...'); window.location.reload(); return; } } catch (error) { logger.warn('Update completed but health check failed, assuming restart...', error); } // If health check failed, assume restart in progress setIsRestarting(true); startHealthCheckPolling(); return; } setIsComplete(true); if (currentStatus.status === 'error' || currentStatus.error) { setHasError(true); } if (pollInterval) { clearInterval(pollInterval); } } } catch (error) { logger.error('Failed to poll update status', error); // If we get errors during update, assume we're restarting const currentStatus = status(); const shouldAssumeRestart = !isRestarting() && (!currentStatus || (currentStatus.status !== 'idle' && currentStatus.status !== 'error')); if (shouldAssumeRestart) { if (!currentStatus) { setStatus({ status: 'restarting', progress: 95, message: 'Restarting service...', updatedAt: new Date().toISOString(), }); } setIsRestarting(true); if (pollInterval) { clearInterval(pollInterval); } startHealthCheckPolling(); } } }; const startHealthCheckPolling = () => { clearHealthCheckTimer(); healthCheckAttempts = 0; const checkHealth = async () => { let isHealthy = false; try { const response = await fetch('/api/health', { cache: 'no-store' }); if (response.ok) { isHealthy = true; } } catch (error) { logger.warn('Health check request failed, will retry', error); } if (isHealthy) { // Backend is back! Reload the page to get the new version logger.info('Backend is healthy again, reloading...'); window.location.reload(); return; } const attempt = Math.min(healthCheckAttempts, 3); const nextDelay = Math.min(2000 * Math.pow(2, attempt), 15000); healthCheckAttempts++; clearHealthCheckTimer(); healthCheckTimer = window.setTimeout(checkHealth, nextDelay); }; // Start checking immediately healthCheckTimer = window.setTimeout(checkHealth, 0); }; // Watch websocket status during restart createEffect(() => { if (!isRestarting()) return; const connected = props.connected?.(); const reconnecting = props.reconnecting?.(); // Track if websocket disconnected during restart if (connected === false && !reconnecting) { setWsDisconnected(true); } // If websocket reconnected after being disconnected, the backend is likely back if (wsDisconnected() && connected === true && !reconnecting) { logger.info('WebSocket reconnected after restart, verifying health...'); // Give it a moment for the backend to fully initialize setTimeout(async () => { try { const response = await fetch('/api/health', { cache: 'no-store' }); if (response.ok) { logger.info('Backend healthy after websocket reconnect, reloading...'); window.location.reload(); } } catch (_error) { logger.warn('Health check failed after websocket reconnect, will keep trying'); } }, 1000); } }); // Start/stop polling based on modal visibility createEffect(() => { if (props.isOpen) { // Start polling immediately when modal opens pollStatus(); // Then poll every 2 seconds pollInterval = setInterval(pollStatus, 2000) as unknown as number; } else { // Stop polling when modal closes if (pollInterval) { clearInterval(pollInterval); pollInterval = undefined; } clearHealthCheckTimer(); } }); onCleanup(() => { if (pollInterval) { clearInterval(pollInterval); } clearHealthCheckTimer(); }); const getStageIcon = () => { const currentStatus = status(); if (!currentStatus) return null; if (hasError()) { return ( ); } if (isComplete() && !hasError()) { return ( ); } return ( ); }; const getStatusText = () => { const currentStatus = status(); if (isRestarting()) { return 'Pulse is restarting...'; } if (!currentStatus) return 'Initializing...'; if (hasError()) { return 'Update Failed'; } if (isComplete() && !hasError()) { return 'Update Completed Successfully'; } return currentStatus.message || 'Updating...'; }; return (
{/* Header */}

Updating Pulse

{/* Body */}
{/* Icon and Status */}
{getStageIcon()}
{getStatusText()}
{status()!.status.replace('-', ' ')}
{/* Progress Bar */}
Progress {status()!.progress}%
{/* Error Message */}
Error Details:
{status()!.error}
{/* Warning / Info */}
Pulse is restarting with the new version... }> Waiting for Pulse to complete restart. This page will reload automatically.
5}>
Please do not close this window or refresh the page during the update.
{/* Footer */}
); }