/** * Job Runner Dashboard - PAGI Demo * * Frontend JavaScript for the async job runner application. * Demonstrates WebSocket and SSE integration. */ (function() { 'use strict'; // ===== State ===== const state = { ws: null, jobs: {}, // job_id => job object jobTypes: [], // Available job types selectedJobType: null, selectedJobId: null, sseConnection: null, // Current SSE connection for selected job reconnectAttempts: 0, maxReconnectDelay: 30000, }; // ===== DOM Elements ===== const elements = { connectionStatus: document.getElementById('connection-status'), statPending: document.getElementById('stat-pending'), statRunning: document.getElementById('stat-running'), statCompleted: document.getElementById('stat-completed'), statFailed: document.getElementById('stat-failed'), jobTypes: document.getElementById('job-types'), jobForm: document.getElementById('job-form'), jobParams: document.getElementById('job-params'), submitBtn: document.getElementById('submit-btn'), jobList: document.getElementById('job-list'), jobDetails: document.getElementById('job-details'), clearCompletedBtn: document.getElementById('clear-completed-btn'), workerActive: document.getElementById('worker-active'), workerCapacity: document.getElementById('worker-capacity'), workerProcessed: document.getElementById('worker-processed'), }; // ===== Connection Status ===== function setConnectionStatus(status) { const el = elements.connectionStatus; el.classList.remove('connected', 'disconnected'); const text = el.querySelector('.status-text'); switch (status) { case 'connected': el.classList.add('connected'); text.textContent = 'Connected'; break; case 'disconnected': el.classList.add('disconnected'); text.textContent = 'Disconnected'; break; default: text.textContent = 'Connecting...'; } } // ===== WebSocket Connection ===== function calculateReconnectDelay() { const baseDelay = 1000 * Math.pow(2, state.reconnectAttempts); const jitter = Math.random() * 1000; return Math.min(baseDelay + jitter, state.maxReconnectDelay); } function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/queue`; setConnectionStatus('connecting'); state.ws = new WebSocket(wsUrl); state.ws.onopen = () => { setConnectionStatus('connected'); state.reconnectAttempts = 0; }; state.ws.onclose = () => { setConnectionStatus('disconnected'); state.reconnectAttempts++; const delay = calculateReconnectDelay(); setTimeout(connectWebSocket, delay); }; state.ws.onerror = (error) => { console.error('WebSocket error:', error); }; state.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch (e) { console.error('Failed to parse message:', e); } }; } function handleWebSocketMessage(data) { switch (data.type) { case 'queue_state': // Initial state state.jobs = {}; data.jobs.forEach(job => state.jobs[job.id] = job); state.jobTypes = data.job_types || []; updateStats(data.stats); updateWorkerStats(data.worker); renderJobTypes(); renderJobList(); break; case 'job_created': // data.data contains the full job object state.jobs[data.data.id] = data.data; renderJobList(); updateStatsFromJobs(); break; case 'job_started': if (state.jobs[data.data.job_id]) { state.jobs[data.data.job_id].status = 'running'; state.jobs[data.data.job_id].started_at = data.data.started_at; renderJobList(); updateStatsFromJobs(); if (state.selectedJobId === data.data.job_id) { renderJobDetails(data.data.job_id); } } break; case 'job_progress': if (state.jobs[data.data.job_id]) { state.jobs[data.data.job_id].progress = { percent: data.data.percent, message: data.data.message, }; renderJobList(); if (state.selectedJobId === data.data.job_id) { updateJobProgress(data.data.job_id); } } break; case 'job_completed': if (state.jobs[data.data.job_id]) { state.jobs[data.data.job_id].status = 'completed'; state.jobs[data.data.job_id].result = data.data.result; state.jobs[data.data.job_id].progress = { percent: 100, message: 'Complete' }; renderJobList(); updateStatsFromJobs(); if (state.selectedJobId === data.data.job_id) { renderJobDetails(data.data.job_id); } } break; case 'job_failed': if (state.jobs[data.data.job_id]) { state.jobs[data.data.job_id].status = 'failed'; state.jobs[data.data.job_id].error = data.data.error; renderJobList(); updateStatsFromJobs(); if (state.selectedJobId === data.data.job_id) { renderJobDetails(data.data.job_id); } } break; case 'job_cancelled': if (state.jobs[data.data.job_id]) { state.jobs[data.data.job_id].status = 'cancelled'; renderJobList(); updateStatsFromJobs(); if (state.selectedJobId === data.data.job_id) { renderJobDetails(data.data.job_id); } } break; case 'jobs_cleared': // Remove completed/failed/cancelled jobs from state Object.keys(state.jobs).forEach(id => { const job = state.jobs[id]; if (['completed', 'failed', 'cancelled'].includes(job.status)) { delete state.jobs[id]; } }); if (state.selectedJobId && !state.jobs[state.selectedJobId]) { state.selectedJobId = null; renderJobDetails(null); } renderJobList(); updateStatsFromJobs(); break; case 'worker_stats': // Update worker stats from broadcast updateWorkerStats(data.data); break; case 'ping': // Respond to server ping sendWebSocket({ type: 'pong', ts: data.ts }); break; } } function sendWebSocket(data) { if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify(data)); } } // ===== Stats Updates ===== function updateStats(stats) { elements.statPending.textContent = stats.pending || 0; elements.statRunning.textContent = stats.running || 0; elements.statCompleted.textContent = stats.completed || 0; elements.statFailed.textContent = stats.failed || 0; } function updateStatsFromJobs() { const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 }; Object.values(state.jobs).forEach(job => { if (stats[job.status] !== undefined) { stats[job.status]++; } }); updateStats(stats); } function updateWorkerStats(worker) { if (worker) { elements.workerActive.textContent = worker.active || 0; elements.workerCapacity.textContent = worker.capacity || 3; elements.workerProcessed.textContent = worker.processed || 0; } } // ===== Job Types ===== function renderJobTypes() { elements.jobTypes.innerHTML = state.jobTypes.map(type => ` `).join(''); // Add click handlers elements.jobTypes.querySelectorAll('.job-type-btn').forEach(btn => { btn.addEventListener('click', () => selectJobType(btn.dataset.type)); }); } function selectJobType(typeName) { state.selectedJobType = state.jobTypes.find(t => t.name === typeName); // Update button states elements.jobTypes.querySelectorAll('.job-type-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.type === typeName); }); // Render form renderJobForm(); } function renderJobForm() { if (!state.selectedJobType) { elements.jobParams.innerHTML = '
Select a job type above
'; elements.submitBtn.disabled = true; return; } const params = state.selectedJobType.params || []; elements.jobParams.innerHTML = params.map(param => `No jobs in queue
'; return; } elements.jobList.innerHTML = jobs.map(job => `Select a job to view details
'; return; } const job = state.jobs[jobId]; let resultHtml = ''; if (job.status === 'completed' && job.result) { resultHtml = `