Two initialization-page defects made failures look like progress: - A cache read exception while rendering a model/recipe page forced is_initializing=True, showing an endless spinner; worse, the polling fallback then reported the idle scanner as "complete", reloading the page straight back into the same exception. Page handlers now pass init_error to the initialization template, which renders the error state (with retry) server-side, and initialization.js stays on it instead of starting WebSocket/polling. - The WebSocket path reloaded once the page's own scanner finished, while the polling fallback waited for every scanner. The polling fallback now mirrors the per-page semantics: /api/lm/init-status accepts ?page=<pageType> (aggregate behavior preserved without it), and ModelScanner records _init_error so a failed initial scan is reported as "error" instead of masquerading as "complete". Polling stops on error instead of reloading into the failure.
588 lines
20 KiB
JavaScript
588 lines
20 KiB
JavaScript
/**
|
|
* Initialization Component
|
|
* Manages the display of initialization progress and status
|
|
*/
|
|
import { appCore } from '../core.js';
|
|
import { getSessionItem, setSessionItem } from '../utils/storageHelpers.js';
|
|
|
|
class InitializationManager {
|
|
constructor() {
|
|
this.currentTipIndex = 0;
|
|
this.tipInterval = null;
|
|
this.websocket = null;
|
|
this.progress = 0;
|
|
this.processingStartTime = null;
|
|
this.processedFilesCount = 0;
|
|
this.totalFilesCount = 0;
|
|
this.averageProcessingTime = null;
|
|
this.pageType = null; // Added page type property
|
|
}
|
|
|
|
/**
|
|
* Initialize the component
|
|
*/
|
|
initialize() {
|
|
// Initialize core application for theme and header functionality
|
|
appCore.initialize().then(() => {
|
|
console.log('Core application initialized for initialization component');
|
|
});
|
|
|
|
// Detect the current page type
|
|
this.detectPageType();
|
|
|
|
// Check session storage for saved progress
|
|
this.restoreProgress();
|
|
|
|
// Wire up the error retry button
|
|
const retryBtn = document.getElementById('initRetryBtn');
|
|
if (retryBtn) {
|
|
retryBtn.addEventListener('click', () => window.location.reload());
|
|
}
|
|
|
|
// A server-rendered error means the failure happened while this page
|
|
// was being generated (e.g. the cache read threw). Polling would
|
|
// report "complete" for the idle scanner and reload straight back
|
|
// into the same failure, so stay on the error state until the user
|
|
// retries manually.
|
|
const serverError = document.getElementById('initError');
|
|
if (serverError && !serverError.hidden) {
|
|
return;
|
|
}
|
|
|
|
// Setup the tip carousel
|
|
this.setupTipCarousel();
|
|
|
|
// Connect to WebSocket for progress updates
|
|
this.connectWebSocket();
|
|
|
|
// Add event listeners for tip navigation
|
|
this.setupTipNavigation();
|
|
|
|
// Show first tip as active
|
|
document.querySelector('.tip-item').classList.add('active');
|
|
}
|
|
|
|
/**
|
|
* Detect the current page type
|
|
*/
|
|
detectPageType() {
|
|
// Get the current page type from URL or data attribute
|
|
const path = window.location.pathname;
|
|
// The recipes page lives at /loras/recipes, so it must be matched
|
|
// before the generic '/loras' check.
|
|
if (path.includes('/recipes')) {
|
|
this.pageType = 'recipes';
|
|
} else if (path.includes('/checkpoints')) {
|
|
this.pageType = 'checkpoints';
|
|
} else if (path.includes('/other')) {
|
|
this.pageType = 'other';
|
|
} else if (path.includes('/loras')) {
|
|
this.pageType = 'loras';
|
|
} else if (path.includes('/embeddings')) {
|
|
this.pageType = 'embeddings';
|
|
} else {
|
|
// Default to loras if can't determine
|
|
this.pageType = 'loras';
|
|
}
|
|
console.log(`Initialization component detected page type: ${this.pageType}`);
|
|
}
|
|
|
|
/**
|
|
* Get the storage key with page type prefix
|
|
*/
|
|
getStorageKey(key) {
|
|
return `${this.pageType}_${key}`;
|
|
}
|
|
|
|
/**
|
|
* Restore progress from session storage if available
|
|
*/
|
|
restoreProgress() {
|
|
const savedProgress = getSessionItem(this.getStorageKey('initProgress'));
|
|
if (savedProgress) {
|
|
console.log(`Restoring ${this.pageType} progress from session storage:`, savedProgress);
|
|
|
|
// Restore progress percentage
|
|
if (savedProgress.progress !== undefined) {
|
|
this.updateProgress(savedProgress.progress);
|
|
}
|
|
|
|
// Restore processed files count and total files
|
|
if (savedProgress.processedFiles !== undefined) {
|
|
this.processedFilesCount = savedProgress.processedFiles;
|
|
}
|
|
|
|
if (savedProgress.totalFiles !== undefined) {
|
|
this.totalFilesCount = savedProgress.totalFiles;
|
|
}
|
|
|
|
// Restore processing time metrics if available
|
|
if (savedProgress.averageProcessingTime !== undefined) {
|
|
this.averageProcessingTime = savedProgress.averageProcessingTime;
|
|
this.updateRemainingTime();
|
|
}
|
|
|
|
// Restore progress status message
|
|
if (savedProgress.details) {
|
|
this.updateStatusMessage(savedProgress.details);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect to WebSocket for initialization progress updates
|
|
*/
|
|
connectWebSocket() {
|
|
try {
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
|
this.websocket = new WebSocket(`${wsProtocol}${window.location.host}/ws/init-progress`);
|
|
|
|
this.websocket.onopen = () => {
|
|
console.log('Connected to initialization progress WebSocket');
|
|
};
|
|
|
|
this.websocket.onmessage = (event) => {
|
|
this.handleProgressUpdate(JSON.parse(event.data));
|
|
};
|
|
|
|
this.websocket.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
// Fall back to polling if WebSocket fails
|
|
this.fallbackToPolling();
|
|
};
|
|
|
|
this.websocket.onclose = () => {
|
|
console.log('WebSocket connection closed');
|
|
// Check if we need to fall back to polling
|
|
if (!this.pollingActive) {
|
|
this.fallbackToPolling();
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to connect to WebSocket:', error);
|
|
this.fallbackToPolling();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fall back to polling if WebSocket connection fails
|
|
*/
|
|
fallbackToPolling() {
|
|
this.pollingActive = true;
|
|
this.pollProgress();
|
|
|
|
// Set a simulated progress that moves forward slowly
|
|
// This gives users feedback even if the backend isn't providing updates
|
|
let simulatedProgress = this.progress || 0;
|
|
const simulateInterval = setInterval(() => {
|
|
simulatedProgress += 0.5;
|
|
if (simulatedProgress > 95) {
|
|
clearInterval(simulateInterval);
|
|
return;
|
|
}
|
|
|
|
// Only use simulated progress if we haven't received a real update
|
|
if (this.progress < simulatedProgress) {
|
|
this.updateProgress(simulatedProgress);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
/**
|
|
* Poll for progress updates from the server
|
|
*/
|
|
pollProgress() {
|
|
const checkProgress = () => {
|
|
// Scope the status check to this page's scanner(s), mirroring the
|
|
// WebSocket completion semantics (per-pageType broadcasts).
|
|
fetch(`/api/lm/init-status?page=${encodeURIComponent(this.pageType)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
this.handleProgressUpdate(data);
|
|
|
|
if (data.status === 'complete') {
|
|
// Initialization is complete, stop polling
|
|
window.location.reload();
|
|
} else if (data.status !== 'error') {
|
|
// Errors are already displayed by handleProgressUpdate;
|
|
// polling again would just reload into the same failure.
|
|
setTimeout(checkProgress, 2000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error polling for progress:', error);
|
|
setTimeout(checkProgress, 3000); // Try again after a longer delay
|
|
});
|
|
};
|
|
|
|
checkProgress();
|
|
}
|
|
|
|
/**
|
|
* Handle progress updates from WebSocket or polling
|
|
*/
|
|
handleProgressUpdate(data) {
|
|
if (!data) return;
|
|
console.log('Received progress update:', data);
|
|
|
|
// Handle cache health warning messages
|
|
if (data.type === 'cache_health_warning') {
|
|
this.handleCacheHealthWarning(data);
|
|
return;
|
|
}
|
|
|
|
// The first-run welcome page owns the pre-scan state; once the scan
|
|
// starts the page reloads into the progress view on its own.
|
|
if (data.stage === 'scan_started') {
|
|
return;
|
|
}
|
|
|
|
// Check if this update is for our page type
|
|
if (data.pageType && data.pageType !== this.pageType) {
|
|
console.log(`Ignoring update for ${data.pageType}, we're on ${this.pageType}`);
|
|
return;
|
|
}
|
|
|
|
// If no pageType is specified in the data but we have scanner_type, map it to pageType
|
|
if (!data.pageType && data.scanner_type) {
|
|
const scannerTypeToPageType = {
|
|
'lora': 'loras',
|
|
'checkpoint': 'checkpoints',
|
|
'embedding': 'embeddings',
|
|
'other': 'other',
|
|
'recipe': 'recipes'
|
|
};
|
|
|
|
if (scannerTypeToPageType[data.scanner_type] !== this.pageType) {
|
|
console.log(`Ignoring update for ${data.scanner_type}, we're on ${this.pageType}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Show scan failures instead of silently sitting on the progress view
|
|
if (data.status === 'error') {
|
|
this.showError(data.details);
|
|
return;
|
|
}
|
|
|
|
// Save progress data to session storage
|
|
setSessionItem(this.getStorageKey('initProgress'), {
|
|
...data,
|
|
averageProcessingTime: this.averageProcessingTime,
|
|
processedFiles: this.processedFilesCount,
|
|
totalFiles: this.totalFilesCount
|
|
});
|
|
|
|
// Update progress percentage
|
|
if (data.progress !== undefined) {
|
|
this.updateProgress(data.progress);
|
|
}
|
|
|
|
// Update stage-specific details
|
|
if (data.details) {
|
|
this.updateStatusMessage(data.details);
|
|
}
|
|
|
|
// Track files count for time estimation
|
|
if (data.stage === 'count_models' && data.details) {
|
|
const match = data.details.match(/Found (\d+)/);
|
|
if (match && match[1]) {
|
|
this.totalFilesCount = parseInt(match[1]);
|
|
}
|
|
}
|
|
|
|
// Track processed files for time estimation
|
|
if (data.stage === 'process_models' && data.details) {
|
|
const match = data.details.match(/Processing .* files: (\d+)\/(\d+)/);
|
|
if (match && match[1] && match[2]) {
|
|
const currentCount = parseInt(match[1]);
|
|
const totalCount = parseInt(match[2]);
|
|
|
|
// Make sure we have valid total count
|
|
if (totalCount > 0 && this.totalFilesCount === 0) {
|
|
this.totalFilesCount = totalCount;
|
|
}
|
|
|
|
// Start tracking processing time once we've processed some files
|
|
if (currentCount > 0 && !this.processingStartTime && this.processedFilesCount === 0) {
|
|
this.processingStartTime = Date.now();
|
|
}
|
|
|
|
// Calculate average processing time based on elapsed time and files processed
|
|
if (this.processingStartTime && currentCount > this.processedFilesCount) {
|
|
const newFiles = currentCount - this.processedFilesCount;
|
|
const elapsedTime = Date.now() - this.processingStartTime;
|
|
const timePerFile = elapsedTime / currentCount; // ms per file
|
|
|
|
// Update moving average
|
|
if (!this.averageProcessingTime) {
|
|
this.averageProcessingTime = timePerFile;
|
|
} else {
|
|
// Simple exponential moving average
|
|
this.averageProcessingTime = this.averageProcessingTime * 0.7 + timePerFile * 0.3;
|
|
}
|
|
|
|
// Update remaining time estimate
|
|
this.updateRemainingTime();
|
|
}
|
|
|
|
this.processedFilesCount = currentCount;
|
|
}
|
|
}
|
|
|
|
// If initialization is complete, reload the page
|
|
if (data.status === 'complete') {
|
|
this.showCompletionMessage();
|
|
|
|
// Remove session storage data since we're done
|
|
setSessionItem(this.getStorageKey('initProgress'), null);
|
|
|
|
// Give the user a moment to see the completion message
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the remaining time display based on current progress
|
|
*/
|
|
updateRemainingTime() {
|
|
if (!this.averageProcessingTime || !this.totalFilesCount || this.totalFilesCount <= 0) {
|
|
document.getElementById('remainingTime').textContent = 'Estimating...';
|
|
return;
|
|
}
|
|
|
|
const remainingFiles = this.totalFilesCount - this.processedFilesCount;
|
|
const remainingTimeMs = remainingFiles * this.averageProcessingTime;
|
|
|
|
if (remainingTimeMs <= 0) {
|
|
document.getElementById('remainingTime').textContent = 'Almost done...';
|
|
return;
|
|
}
|
|
|
|
// Format the time for display
|
|
let formattedTime;
|
|
if (remainingTimeMs < 60000) {
|
|
// Less than a minute
|
|
formattedTime = 'Less than a minute';
|
|
} else if (remainingTimeMs < 3600000) {
|
|
// Less than an hour
|
|
const minutes = Math.round(remainingTimeMs / 60000);
|
|
formattedTime = `~${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
|
} else {
|
|
// Hours and minutes
|
|
const hours = Math.floor(remainingTimeMs / 3600000);
|
|
const minutes = Math.round((remainingTimeMs % 3600000) / 60000);
|
|
formattedTime = `~${hours} hour${hours !== 1 ? 's' : ''} ${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
document.getElementById('remainingTime').textContent = formattedTime + ' remaining';
|
|
}
|
|
|
|
/**
|
|
* Update status message
|
|
*/
|
|
updateStatusMessage(message) {
|
|
const progressStatus = document.getElementById('progressStatus');
|
|
if (progressStatus) {
|
|
progressStatus.textContent = message;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the progress bar and percentage
|
|
*/
|
|
updateProgress(progress) {
|
|
this.progress = progress;
|
|
const progressBar = document.getElementById('initProgressBar');
|
|
const progressPercentage = document.getElementById('progressPercentage');
|
|
|
|
if (progressBar && progressPercentage) {
|
|
progressBar.style.width = `${progress}%`;
|
|
progressPercentage.textContent = `${Math.round(progress)}%`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup the tip carousel to rotate through tips
|
|
*/
|
|
setupTipCarousel() {
|
|
const tipItems = document.querySelectorAll('.tip-item');
|
|
if (tipItems.length === 0) return;
|
|
|
|
// Show the first tip
|
|
tipItems[0].classList.add('active');
|
|
document.querySelector('.tip-dot').classList.add('active');
|
|
|
|
// Set up automatic rotation
|
|
this.tipInterval = setInterval(() => {
|
|
this.showNextTip();
|
|
}, 8000); // Change tip every 8 seconds
|
|
}
|
|
|
|
/**
|
|
* Setup tip navigation dots
|
|
*/
|
|
setupTipNavigation() {
|
|
const tipDots = document.querySelectorAll('.tip-dot');
|
|
|
|
tipDots.forEach((dot, index) => {
|
|
dot.addEventListener('click', () => {
|
|
this.showTipByIndex(index);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show the next tip in the carousel
|
|
*/
|
|
showNextTip() {
|
|
const tipItems = document.querySelectorAll('.tip-item');
|
|
const tipDots = document.querySelectorAll('.tip-dot');
|
|
|
|
if (tipItems.length === 0) return;
|
|
|
|
// Hide current tip
|
|
tipItems[this.currentTipIndex].classList.remove('active');
|
|
tipDots[this.currentTipIndex].classList.remove('active');
|
|
|
|
// Calculate next index
|
|
this.currentTipIndex = (this.currentTipIndex + 1) % tipItems.length;
|
|
|
|
// Show next tip
|
|
tipItems[this.currentTipIndex].classList.add('active');
|
|
tipDots[this.currentTipIndex].classList.add('active');
|
|
}
|
|
|
|
/**
|
|
* Show a specific tip by index
|
|
*/
|
|
showTipByIndex(index) {
|
|
const tipItems = document.querySelectorAll('.tip-item');
|
|
const tipDots = document.querySelectorAll('.tip-dot');
|
|
|
|
if (index >= tipItems.length || index < 0) return;
|
|
|
|
// Hide current tip
|
|
tipItems[this.currentTipIndex].classList.remove('active');
|
|
tipDots[this.currentTipIndex].classList.remove('active');
|
|
|
|
// Update index and show selected tip
|
|
this.currentTipIndex = index;
|
|
|
|
// Show selected tip
|
|
tipItems[this.currentTipIndex].classList.add('active');
|
|
tipDots[this.currentTipIndex].classList.add('active');
|
|
|
|
// Reset interval to prevent quick tip change
|
|
if (this.tipInterval) {
|
|
clearInterval(this.tipInterval);
|
|
this.tipInterval = setInterval(() => {
|
|
this.showNextTip();
|
|
}, 8000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an initialization error with a retry button
|
|
*/
|
|
showError(details) {
|
|
const loadingContent = document.querySelector('.initialization-container .loading-content');
|
|
const errorEl = document.getElementById('initError');
|
|
const errorText = document.getElementById('initErrorText');
|
|
|
|
if (loadingContent) {
|
|
loadingContent.style.display = 'none';
|
|
}
|
|
|
|
if (errorText) {
|
|
errorText.textContent = details || '';
|
|
}
|
|
|
|
if (errorEl) {
|
|
errorEl.hidden = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show completion message
|
|
*/
|
|
showCompletionMessage() {
|
|
// Update progress to 100%
|
|
this.updateProgress(100);
|
|
|
|
// Update status message
|
|
this.updateStatusMessage('Initialization complete!');
|
|
|
|
// Update title and subtitle
|
|
const initTitle = document.getElementById('initTitle');
|
|
const initSubtitle = document.getElementById('initSubtitle');
|
|
const remainingTime = document.getElementById('remainingTime');
|
|
|
|
if (initTitle) {
|
|
initTitle.textContent = 'Initialization Complete';
|
|
}
|
|
|
|
if (initSubtitle) {
|
|
initSubtitle.textContent = 'Reloading page...';
|
|
}
|
|
|
|
if (remainingTime) {
|
|
remainingTime.textContent = 'Done!';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle cache health warning messages from WebSocket
|
|
*/
|
|
handleCacheHealthWarning(data) {
|
|
console.log('Cache health warning received:', data);
|
|
|
|
// Import bannerService dynamically to avoid circular dependencies
|
|
import('../managers/BannerService.js').then(({ bannerService }) => {
|
|
// Initialize banner service if not already done
|
|
if (!bannerService.initialized) {
|
|
bannerService.initialize().then(() => {
|
|
bannerService.registerCacheHealthBanner(data);
|
|
}).catch(err => {
|
|
console.error('Failed to initialize banner service:', err);
|
|
});
|
|
} else {
|
|
bannerService.registerCacheHealthBanner(data);
|
|
}
|
|
}).catch(err => {
|
|
console.error('Failed to load banner service:', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up resources when the component is destroyed
|
|
*/
|
|
cleanup() {
|
|
if (this.tipInterval) {
|
|
clearInterval(this.tipInterval);
|
|
}
|
|
|
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
|
this.websocket.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create and export the initialization manager
|
|
export const initManager = new InitializationManager();
|
|
|
|
// Initialize the component when the DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Only initialize if we're in initialization mode
|
|
const initContainer = document.getElementById('initializationContainer');
|
|
if (initContainer) {
|
|
initManager.initialize();
|
|
}
|
|
});
|
|
|
|
// Clean up when the page is unloaded
|
|
window.addEventListener('beforeunload', () => {
|
|
initManager.cleanup();
|
|
}); |