import { createSignal, Show, onCleanup, createEffect } from 'solid-js'; import { UpdatesAPI, type UpdateStatus } from '@/api/updates'; import { apiFetch } from '@/utils/apiClient'; 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; let eventSource: EventSource | undefined; const clearHealthCheckTimer = () => { if (healthCheckTimer !== undefined) { clearTimeout(healthCheckTimer); healthCheckTimer = undefined; } }; const closeSSE = () => { if (!eventSource) { return; } eventSource.close(); eventSource = undefined; logger.info('SSE connection closed'); }; const setupSSE = () => { // Close existing connection if any closeSSE(); try { // Create EventSource connection to SSE endpoint eventSource = new EventSource('/api/updates/stream'); eventSource.onopen = () => { logger.info('SSE connection established'); }; eventSource.onmessage = (event) => { try { const updateStatus = JSON.parse(event.data) as UpdateStatus; setStatus(updateStatus); // Check if restarting if (updateStatus.status === 'restarting') { setIsRestarting(true); closeSSE(); startHealthCheckPolling(); return; } // Check if complete or error if ( updateStatus.status === 'completed' || updateStatus.status === 'idle' || updateStatus.status === 'error' ) { if (updateStatus.status === 'completed' && !updateStatus.error) { closeSSE(); // Verify backend health and reload apiFetch('/api/health', { cache: 'no-store' }) .then((healthCheck) => { if (healthCheck.ok) { logger.info('Update completed, backend healthy, reloading...'); window.location.reload(); } else { // Health check failed, assume restart in progress setIsRestarting(true); startHealthCheckPolling(); } }) .catch((error) => { logger.warn('Update completed but health check failed, assuming restart...', error); setIsRestarting(true); startHealthCheckPolling(); }); return; } setIsComplete(true); if (updateStatus.status === 'error' || updateStatus.error) { setHasError(true); } closeSSE(); } } catch (error) { logger.error('Failed to parse SSE update status', error); } }; eventSource.onerror = (error) => { logger.warn('SSE connection error, falling back to polling', error); closeSSE(); // Fall back to polling startPolling(); }; } catch (error) { logger.error('Failed to setup SSE, falling back to polling', error); closeSSE(); // Fall back to polling startPolling(); } }; const startPolling = () => { // Don't start polling if already polling if (pollInterval) { return; } logger.info('Starting status polling (SSE not available)'); pollStatus(); pollInterval = setInterval(pollStatus, 2000) as unknown as number; }; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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 SSE or polling based on modal visibility createEffect(() => { if (props.isOpen) { // Try SSE first, will fall back to polling if it fails setupSSE(); } else { // Stop everything when modal closes closeSSE(); if (pollInterval) { clearInterval(pollInterval); pollInterval = undefined; } clearHealthCheckTimer(); } }); onCleanup(() => { closeSSE(); 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 */}
); }