- Add Dockerfile, docker-compose.yml, .dockerignore for containerised deployment - Add deploy/ scripts (deploy.sh, nginx/apache configs, password generator) - Replace MSAL/Azure AD auth with local username/password authentication - Add login.html template - Simplify app.py, middleware, and auth routes for production use - Update gunicorn_config.py and wsgi.py for Docker/production - Update templates to work with new auth and URL prefix handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
313 lines
9.2 KiB
JavaScript
313 lines
9.2 KiB
JavaScript
/**
|
|
* Unified Progress Tracking for HM QC Platform
|
|
*
|
|
* Supports both Server-Sent Events (SSE) and polling fallback
|
|
*/
|
|
|
|
class ProgressTracker {
|
|
/**
|
|
* Create a progress tracker
|
|
* @param {string} sessionId - Unique session identifier
|
|
* @param {object} options - Configuration options
|
|
*/
|
|
constructor(sessionId, options = {}) {
|
|
this.sessionId = sessionId;
|
|
this.options = {
|
|
useSSE: true, // Try SSE first
|
|
pollInterval: 1000, // Poll every 1 second
|
|
sseUrl: null, // SSE endpoint URL (auto-detected if null)
|
|
pollUrl: null, // Polling endpoint URL (auto-detected if null)
|
|
onUpdate: null, // Callback for progress updates
|
|
onComplete: null, // Callback for completion
|
|
onError: null, // Callback for errors
|
|
...options
|
|
};
|
|
|
|
this.eventSource = null;
|
|
this.pollTimer = null;
|
|
this.isRunning = false;
|
|
}
|
|
|
|
/**
|
|
* Start tracking progress
|
|
*/
|
|
start() {
|
|
if (this.isRunning) {
|
|
console.warn('Progress tracker already running');
|
|
return;
|
|
}
|
|
|
|
this.isRunning = true;
|
|
|
|
// Try SSE first
|
|
if (this.options.useSSE && typeof EventSource !== 'undefined') {
|
|
this.startSSE();
|
|
} else {
|
|
// Fall back to polling
|
|
this.startPolling();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start Server-Sent Events tracking
|
|
*/
|
|
startSSE() {
|
|
const url = this.options.sseUrl || `${window.BASE_URL || ''}/hm-qc/progress/${this.sessionId}`;
|
|
|
|
this.eventSource = new EventSource(url);
|
|
|
|
this.eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handleUpdate(data);
|
|
} catch (error) {
|
|
console.error('Error parsing SSE data:', error);
|
|
}
|
|
};
|
|
|
|
this.eventSource.onerror = (error) => {
|
|
console.error('SSE connection error:', error);
|
|
this.eventSource.close();
|
|
|
|
// Fall back to polling
|
|
console.log('Falling back to polling...');
|
|
this.startPolling();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start polling-based tracking
|
|
*/
|
|
startPolling() {
|
|
const poll = async () => {
|
|
if (!this.isRunning) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.options.pollUrl || `${window.BASE_URL || ''}/hm-qc/api/progress/${this.sessionId}`);
|
|
const data = await response.json();
|
|
|
|
this.handleUpdate(data);
|
|
|
|
// Continue polling if not finished
|
|
if (data.status === 'running' && this.isRunning) {
|
|
this.pollTimer = setTimeout(poll, this.options.pollInterval);
|
|
}
|
|
} catch (error) {
|
|
console.error('Polling error:', error);
|
|
if (this.options.onError) {
|
|
this.options.onError(error);
|
|
}
|
|
|
|
// Retry polling
|
|
if (this.isRunning) {
|
|
this.pollTimer = setTimeout(poll, this.options.pollInterval * 2);
|
|
}
|
|
}
|
|
};
|
|
|
|
poll();
|
|
}
|
|
|
|
/**
|
|
* Handle progress update
|
|
* @param {object} data - Progress data
|
|
*/
|
|
handleUpdate(data) {
|
|
// Call update callback
|
|
if (this.options.onUpdate) {
|
|
this.options.onUpdate(data);
|
|
}
|
|
|
|
// Check if complete
|
|
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
|
this.stop();
|
|
|
|
if (this.options.onComplete) {
|
|
this.options.onComplete(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop tracking
|
|
*/
|
|
stop() {
|
|
this.isRunning = false;
|
|
|
|
// Close SSE connection
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
|
|
// Clear polling timer
|
|
if (this.pollTimer) {
|
|
clearTimeout(this.pollTimer);
|
|
this.pollTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize a progress widget
|
|
* @param {string} sessionId - Session identifier
|
|
* @param {string} containerId - Container element ID
|
|
* @param {object} options - Configuration options
|
|
* @returns {ProgressTracker} Progress tracker instance
|
|
*/
|
|
function initProgressWidget(sessionId, containerId, options = {}) {
|
|
const container = document.getElementById(containerId);
|
|
|
|
if (!container) {
|
|
console.error(`Container element #${containerId} not found`);
|
|
return null;
|
|
}
|
|
|
|
// Create progress UI
|
|
container.innerHTML = `
|
|
<div class="progress-widget">
|
|
<div class="progress mb-3" style="height: 1.5rem;">
|
|
<div class="progress-bar progress-bar-animated progress-bar-striped"
|
|
role="progressbar"
|
|
style="width: 0%"
|
|
aria-valuenow="0"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100">
|
|
0%
|
|
</div>
|
|
</div>
|
|
<div class="progress-message text-center">
|
|
<i class="bi bi-hourglass-split me-2"></i>
|
|
<span>Initializing...</span>
|
|
</div>
|
|
<div class="progress-details mt-2 text-muted small" style="display: none;"></div>
|
|
</div>
|
|
`;
|
|
|
|
// Create progress tracker
|
|
const tracker = new ProgressTracker(sessionId, {
|
|
...options,
|
|
onUpdate: (data) => {
|
|
updateProgressWidget(container, data);
|
|
|
|
// Call user-provided callback
|
|
if (options.onUpdate) {
|
|
options.onUpdate(data);
|
|
}
|
|
},
|
|
onComplete: (data) => {
|
|
updateProgressWidget(container, data);
|
|
|
|
// Call user-provided callback
|
|
if (options.onComplete) {
|
|
options.onComplete(data);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
showProgressError(container, error);
|
|
|
|
// Call user-provided callback
|
|
if (options.onError) {
|
|
options.onError(error);
|
|
}
|
|
}
|
|
});
|
|
|
|
tracker.start();
|
|
|
|
return tracker;
|
|
}
|
|
|
|
/**
|
|
* Update progress widget UI
|
|
* @param {HTMLElement} container - Container element
|
|
* @param {object} data - Progress data
|
|
*/
|
|
function updateProgressWidget(container, data) {
|
|
const progressBar = container.querySelector('.progress-bar');
|
|
const messageEl = container.querySelector('.progress-message span');
|
|
const detailsEl = container.querySelector('.progress-details');
|
|
|
|
// Update progress bar
|
|
if (progressBar) {
|
|
const percent = Math.round(data.percent || 0);
|
|
progressBar.style.width = `${percent}%`;
|
|
progressBar.setAttribute('aria-valuenow', percent);
|
|
progressBar.textContent = `${percent}%`;
|
|
|
|
// Change color based on status
|
|
progressBar.classList.remove('bg-success', 'bg-danger', 'bg-warning');
|
|
|
|
if (data.status === 'completed') {
|
|
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
|
progressBar.classList.add('bg-success');
|
|
} else if (data.status === 'failed') {
|
|
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
|
progressBar.classList.add('bg-danger');
|
|
}
|
|
}
|
|
|
|
// Update message
|
|
if (messageEl && data.message) {
|
|
let icon = 'hourglass-split';
|
|
|
|
if (data.status === 'completed') {
|
|
icon = 'check-circle';
|
|
} else if (data.status === 'failed') {
|
|
icon = 'x-circle';
|
|
} else if (data.status === 'cancelled') {
|
|
icon = 'exclamation-circle';
|
|
}
|
|
|
|
messageEl.parentElement.innerHTML = `
|
|
<i class="bi bi-${icon} me-2"></i>
|
|
<span>${data.message}</span>
|
|
`;
|
|
}
|
|
|
|
// Update details
|
|
if (detailsEl && data.details) {
|
|
detailsEl.style.display = 'block';
|
|
let detailsHtml = '';
|
|
|
|
if (typeof data.details === 'object') {
|
|
for (const [key, value] of Object.entries(data.details)) {
|
|
detailsHtml += `<div><strong>${key}:</strong> ${value}</div>`;
|
|
}
|
|
} else {
|
|
detailsHtml = data.details;
|
|
}
|
|
|
|
detailsEl.innerHTML = detailsHtml;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show error in progress widget
|
|
* @param {HTMLElement} container - Container element
|
|
* @param {Error} error - Error object
|
|
*/
|
|
function showProgressError(container, error) {
|
|
const progressBar = container.querySelector('.progress-bar');
|
|
const messageEl = container.querySelector('.progress-message');
|
|
|
|
if (progressBar) {
|
|
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
|
progressBar.classList.add('bg-danger');
|
|
}
|
|
|
|
if (messageEl) {
|
|
messageEl.innerHTML = `
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
<span class="text-danger">Error: ${error.message || 'Unknown error occurred'}</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Make functions available globally
|
|
window.ProgressTracker = ProgressTracker;
|
|
window.initProgressWidget = initProgressWidget;
|
|
window.updateProgressWidget = updateProgressWidget;
|
|
window.showProgressError = showProgressError;
|