mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-07-18 05:30:21 -04:00
Refactored polling for status page
This commit is contained in:
243
app/assets/javascripts/job-polling.js
Normal file
243
app/assets/javascripts/job-polling.js
Normal file
@@ -0,0 +1,243 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const POLLING_CONFIG = {
|
||||
POLL_INTERVAL_MS: 5000,
|
||||
MAX_RETRY_ATTEMPTS: 3,
|
||||
MAX_BACKOFF_MS: 60000
|
||||
};
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
|
||||
class StatusPoller {
|
||||
constructor(serviceId, jobId, countsContainer) {
|
||||
this.serviceId = serviceId;
|
||||
this.jobId = jobId;
|
||||
this.countsContainer = countsContainer;
|
||||
this.pollInterval = null;
|
||||
this.isPolling = false;
|
||||
this.abortController = null;
|
||||
this.lastFinishedState = false;
|
||||
this.lastResponse = null;
|
||||
this.currentInterval = POLLING_CONFIG.POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
async poll(retryCount = 0) {
|
||||
if (this.isPolling || document.hidden || this.lastFinishedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPolling = true;
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/services/${this.serviceId}/jobs/${this.jobId}/status.json`,
|
||||
{ signal: this.abortController.signal }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const responseChanged = this.lastResponse === null ||
|
||||
JSON.stringify(data) !== JSON.stringify(this.lastResponse);
|
||||
|
||||
if (responseChanged) {
|
||||
const wasBackedOff = this.currentInterval !== POLLING_CONFIG.POLL_INTERVAL_MS;
|
||||
this.currentInterval = POLLING_CONFIG.POLL_INTERVAL_MS;
|
||||
if (wasBackedOff) {
|
||||
this.reschedulePolling();
|
||||
}
|
||||
} else {
|
||||
const oldInterval = this.currentInterval;
|
||||
this.currentInterval = Math.min(
|
||||
this.currentInterval * 2,
|
||||
POLLING_CONFIG.MAX_BACKOFF_MS
|
||||
);
|
||||
if (this.currentInterval !== oldInterval) {
|
||||
this.reschedulePolling();
|
||||
}
|
||||
}
|
||||
|
||||
this.lastResponse = data;
|
||||
this.updateStatusCounts(data);
|
||||
|
||||
if (data.finished === true && !this.lastFinishedState) {
|
||||
this.lastFinishedState = true;
|
||||
this.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadNotificationsTable();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
return this.handleError(error, retryCount);
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error, retryCount) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.debug('Status poll aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRetryCount = retryCount + 1;
|
||||
const backoffDelay = Math.min(
|
||||
Math.pow(2, retryCount) * 1000,
|
||||
POLLING_CONFIG.MAX_BACKOFF_MS
|
||||
);
|
||||
|
||||
if (retryCount < POLLING_CONFIG.MAX_RETRY_ATTEMPTS) {
|
||||
console.debug(
|
||||
`Status polling retry ${nextRetryCount}/${POLLING_CONFIG.MAX_RETRY_ATTEMPTS}`,
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
console.debug(
|
||||
`Status polling retry ${nextRetryCount} (backing off ${backoffDelay}ms)`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.poll(nextRetryCount);
|
||||
}, backoffDelay);
|
||||
}
|
||||
|
||||
updateStatusCounts(data) {
|
||||
const countElements = this.countsContainer.querySelectorAll('.big-number-number');
|
||||
|
||||
if (countElements.length >= 4) {
|
||||
countElements[0].textContent = (data.total || 0).toLocaleString();
|
||||
countElements[1].textContent = (data.pending || 0).toLocaleString();
|
||||
countElements[2].textContent = (data.delivered || 0).toLocaleString();
|
||||
countElements[3].textContent = (data.failed || 0).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
loadNotificationsTable() {
|
||||
const url = `${window.location.href.split('?')[0]}?_=${Date.now()}`;
|
||||
|
||||
fetch(url, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const notificationsTable = doc.querySelector('.job-status-table');
|
||||
|
||||
if (notificationsTable) {
|
||||
const insertPoint = document.querySelector('.notification-status');
|
||||
if (insertPoint) {
|
||||
insertPoint.insertAdjacentElement('afterend', notificationsTable);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load notifications:', error);
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
reschedulePolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.poll();
|
||||
}, this.currentInterval);
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.poll();
|
||||
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.poll();
|
||||
}, this.currentInterval);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
this.stop();
|
||||
} else if (!this.lastFinishedState) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeJobPolling() {
|
||||
const isJobPage = window.location.pathname.includes('/jobs/');
|
||||
if (!isJobPage) return;
|
||||
|
||||
const countsContainer = document.querySelector('[data-key="counts"]');
|
||||
if (!countsContainer) return;
|
||||
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts.length < 5 || pathParts[1] !== 'services' || pathParts[3] !== 'jobs') {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceId = pathParts[2];
|
||||
const jobId = pathParts[4];
|
||||
|
||||
if (!UUID_REGEX.test(serviceId) || !UUID_REGEX.test(jobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobElement = document.querySelector('[data-job-id]');
|
||||
const isJobFinished = jobElement && jobElement.dataset.jobFinished === 'true';
|
||||
|
||||
if (isJobFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusPoller = new StatusPoller(serviceId, jobId, countsContainer);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
statusPoller.handleVisibilityChange();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
statusPoller.stop();
|
||||
});
|
||||
|
||||
statusPoller.start();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeJobPolling);
|
||||
})();
|
||||
@@ -1,185 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Verify we are on the job page
|
||||
const isJobPage = window.location.pathname.includes('/jobs/');
|
||||
if (!isJobPage) return;
|
||||
|
||||
// Check if polling elements exist
|
||||
const hasPollingElements = document.querySelector('[data-key="counts"]');
|
||||
if (!hasPollingElements) return;
|
||||
|
||||
// Extract job info from URL path: /services/{serviceId}/jobs/{jobId}
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts.length < 5 || pathParts[1] !== 'services' || pathParts[3] !== 'jobs') return;
|
||||
|
||||
const serviceId = pathParts[2];
|
||||
const jobId = pathParts[4];
|
||||
|
||||
// Validate service and job IDs to prevent path injection
|
||||
function isValidUuid(id) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
}
|
||||
|
||||
// Validate both IDs are UUIDs to prevent path injection attacks
|
||||
if (!isValidUuid(serviceId) || !isValidUuid(jobId)) {
|
||||
console.warn('Invalid service or job ID format detected');
|
||||
return;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 10000;
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
const MAX_INTERVAL_MS = 30000;
|
||||
|
||||
let pollInterval;
|
||||
let currentInterval = DEFAULT_INTERVAL_MS;
|
||||
let isPolling = false;
|
||||
let lastProcessedCount = 0;
|
||||
|
||||
function calculateBackoff(responseTime) {
|
||||
return Math.min(
|
||||
MAX_INTERVAL_MS,
|
||||
Math.max(
|
||||
MIN_INTERVAL_MS,
|
||||
Math.floor((250 * Math.sqrt(responseTime)) - 1000)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function updateNotifications() {
|
||||
const notificationsUrl = `/services/${serviceId}/jobs/${jobId}.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(notificationsUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch notifications: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update notifications container if it exists
|
||||
const notificationsContainer = document.querySelector('[data-key="notifications"]');
|
||||
if (notificationsContainer && data.notifications) {
|
||||
notificationsContainer.innerHTML = data.notifications;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update notifications:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAllJobSections(retryCount = 0) {
|
||||
if (isPolling || document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
|
||||
const pollStatusUrl = `/services/${serviceId}/jobs/${jobId}/status.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(pollStatusUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const countsContainer = document.querySelector('[data-key="counts"]');
|
||||
if (countsContainer) {
|
||||
// Get all big-number elements in order: total, pending, delivered, failed
|
||||
const countElements = countsContainer.querySelectorAll('.big-number-number');
|
||||
|
||||
if (countElements.length >= 4) {
|
||||
if (data.total_count !== undefined) {
|
||||
countElements[0].textContent = data.total_count.toLocaleString();
|
||||
}
|
||||
|
||||
if (data.pending_count !== undefined) {
|
||||
countElements[1].textContent = data.pending_count.toLocaleString();
|
||||
}
|
||||
|
||||
if (data.sent_count !== undefined) {
|
||||
countElements[2].textContent = data.sent_count.toLocaleString();
|
||||
}
|
||||
|
||||
if (data.failed_count !== undefined) {
|
||||
countElements[3].textContent = data.failed_count.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentInterval = calculateBackoff(DEFAULT_INTERVAL_MS);
|
||||
|
||||
// Calculate how many messages have been processed
|
||||
const processedCount = (data.sent_count || 0) + (data.failed_count || 0);
|
||||
|
||||
// Update notifications conditionally:
|
||||
// 1. If we have new messages and still under 50 total
|
||||
// 2. Always when job is finished
|
||||
if (processedCount > lastProcessedCount && processedCount <= 50 && !data.finished) {
|
||||
// Update notifications for first 50 messages to show early results
|
||||
await updateNotifications();
|
||||
lastProcessedCount = processedCount;
|
||||
}
|
||||
|
||||
if (data.finished === true) {
|
||||
await updateNotifications();
|
||||
stopPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
if (retryCount < 3) {
|
||||
console.debug(`Job polling retry ${retryCount}`, error.message);
|
||||
isPolling = false;
|
||||
|
||||
const retryDelay = Math.pow(2, retryCount) * 1000;
|
||||
setTimeout(() => {
|
||||
updateAllJobSections(retryCount + 1);
|
||||
}, retryDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('Job polling failed after 3 retries:', {
|
||||
error: error.message,
|
||||
url: pollStatusUrl,
|
||||
jobId: jobId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
currentInterval = Math.min(currentInterval * 2, MAX_INTERVAL_MS);
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
updateAllJobSections();
|
||||
|
||||
function scheduleNext() {
|
||||
if (pollInterval) clearTimeout(pollInterval);
|
||||
pollInterval = setTimeout(() => {
|
||||
updateAllJobSections();
|
||||
scheduleNext();
|
||||
}, currentInterval);
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearTimeout(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', stopPolling);
|
||||
|
||||
startPolling();
|
||||
});
|
||||
Reference in New Issue
Block a user