ROOT CAUSE: The onMount hook checked props.isOpen, but onMount only runs ONCE when the component first mounts. Since UpdateProgressModal mounts when the app loads (before the user clicks "Apply Update"), props.isOpen is false at mount time, so polling never initializes. When the user later clicks "Apply Update" and props.isOpen becomes true, onMount doesn't re-run, leaving the modal in a broken state with no polling, no restart detection, and no auto-reload - exactly what users reported (stuck for 30+ mins). SOLUTION: Changed from onMount to createEffect watching props.isOpen. Now: - Polling starts immediately when the modal opens (user clicks "Apply Update") - Polling stops when the modal closes (cleanup) - The entire update flow works as designed This was the ACTUAL bug - the previous commits (global watcher, fallback polling) were helpful additions but didn't fix the root cause.
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
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<UpdateStatus | null>(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 (
|
|
<svg class="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
if (isComplete() && !hasError()) {
|
|
return (
|
|
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<svg class="w-12 h-12 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<Show when={props.isOpen}>
|
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full">
|
|
{/* Header */}
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
Updating Pulse
|
|
</h2>
|
|
<Show when={isComplete()}>
|
|
<button
|
|
onClick={props.onClose}
|
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div class="px-6 py-8">
|
|
{/* Icon and Status */}
|
|
<div class="flex flex-col items-center text-center space-y-4">
|
|
{getStageIcon()}
|
|
<div>
|
|
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
{getStatusText()}
|
|
</div>
|
|
<Show when={status()?.status && !isComplete()}>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1 capitalize">
|
|
{status()!.status.replace('-', ' ')}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<Show when={!isComplete() && status()?.progress !== undefined}>
|
|
<div class="mt-6">
|
|
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
<span>Progress</span>
|
|
<span>{status()!.progress}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${status()!.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Error Message */}
|
|
<Show when={hasError() && status()?.error}>
|
|
<div class="mt-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<div class="text-sm text-red-800 dark:text-red-200">
|
|
<div class="font-medium mb-1">Error Details:</div>
|
|
<div class="text-red-700 dark:text-red-300">{status()!.error}</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Warning / Info */}
|
|
<Show when={!isComplete()}>
|
|
<Show when={isRestarting()}>
|
|
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
|
<div class="flex items-start gap-2">
|
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div class="flex-1">
|
|
<div class="text-sm text-blue-800 dark:text-blue-200">
|
|
<Show when={wsDisconnected()} fallback={
|
|
<span>Pulse is restarting with the new version...</span>
|
|
}>
|
|
<span>Waiting for Pulse to complete restart. This page will reload automatically.</span>
|
|
</Show>
|
|
</div>
|
|
<Show when={wsDisconnected() && healthCheckAttempts > 5}>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
class="mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
|
>
|
|
Reload Now
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
<Show when={!isRestarting()}>
|
|
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
|
<div class="flex items-start gap-2">
|
|
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
|
Please do not close this window or refresh the page during the update.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<Show when={isComplete()}>
|
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
|
<Show when={!hasError()}>
|
|
<button
|
|
onClick={props.onViewHistory}
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
>
|
|
View History
|
|
</button>
|
|
</Show>
|
|
<Show when={hasError()}>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
|
>
|
|
Retry
|
|
</button>
|
|
</Show>
|
|
<button
|
|
onClick={props.onClose}
|
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
);
|
|
}
|