diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js index be15a3aad..d7b598f2e 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -5,6 +5,9 @@ const tableContainer = document.getElementById('activityContainer'); const currentUserName = tableContainer.getAttribute('data-currentUserName'); const currentServiceId = tableContainer.getAttribute('data-currentServiceId'); + let pollInterval; + let isPolling = false; + const POLL_INTERVAL_MS = 25000; const COLORS = { delivered: '#0076d6', failed: '#fa9441', @@ -153,8 +156,6 @@ .on('mouseout', function() { tooltip.style('display', 'none'); }) - .transition() - .duration(1000) .attr('y', d => y(d[1])) .attr('height', d => { const calculatedHeight = y(d[0]) - y(d[1]); @@ -209,28 +210,35 @@ table.append(tbody); }; - const fetchData = function(type) { + const fetchData = async function(type) { + if (isPolling) { + return; + } + + if (document.hidden) { + return; + } var ctx = document.getElementById('weeklyChart'); if (!ctx) { return; } + isPolling = true; + var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; var url = type === 'service' ? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}` : `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error('Network response was not ok'); + } - return fetch(url) - .then(response => { - if (!response.ok) { - throw new Error('Network response was not ok'); - } - return response.json(); - }) - .then(data => { + const data = await response.json(); labels = []; deliveredData = []; failedData = []; @@ -277,10 +285,38 @@ } return data; - }) - .catch(error => console.error('Error fetching daily stats:', error)); - }; - setInterval(() => fetchData(currentType), 25000); + } catch (error) { + console.error('Error fetching daily stats:', error); + } finally { + isPolling = false; + } + }; + + function startPolling() { + fetchData(currentType); + + pollInterval = setInterval(() => { + fetchData(currentType); + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + } + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopPolling(); + } else { + stopPolling(); + startPolling(); + } + }); + + startPolling(); const handleDropdownChange = function(event) { const selectedValue = event.target.value; currentType = selectedValue; diff --git a/app/assets/javascripts/modules/all.mjs b/app/assets/javascripts/modules/all.mjs index 6312f03dc..2d64ae97e 100644 --- a/app/assets/javascripts/modules/all.mjs +++ b/app/assets/javascripts/modules/all.mjs @@ -14,7 +14,6 @@ import Button from 'govuk-frontend/components/button/button'; import Radios from 'govuk-frontend/components/radios/radios'; // Modules from 3rd party vendors -import morphdom from 'morphdom'; /** * TODO: Ideally this would be a NodeList.prototype.forEach polyfill @@ -67,13 +66,8 @@ var Frontend = { "initAll": initAll } -var vendor = { - "morphdom": morphdom -} - // The exported object will be assigned to window.GOVUK in our production code // (bundled into an IIFE by RollupJS) export { - Frontend, - vendor + Frontend } diff --git a/app/assets/javascripts/socketio.js b/app/assets/javascripts/socketio.js index 754ea5c01..41910bc3c 100644 --- a/app/assets/javascripts/socketio.js +++ b/app/assets/javascripts/socketio.js @@ -1,11 +1,3 @@ -function debounce(func, wait) { - let timeout; - return function (...args) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} - document.addEventListener('DOMContentLoaded', function () { const isJobPage = window.location.pathname.includes('/jobs/'); if (!isJobPage) return; @@ -15,69 +7,105 @@ document.addEventListener('DOMContentLoaded', function () { const featureEnabled = jobEl?.dataset?.feature === 'true'; const apiHost = jobEl?.dataset?.host; - if (!jobId) return; + if (!jobId || !featureEnabled) return; - if (featureEnabled) { - const socket = io(apiHost); + const DEFAULT_INTERVAL_MS = 10000; + const MIN_INTERVAL_MS = 1000; + const MAX_INTERVAL_MS = 30000; - socket.on('connect_error', (err) => { - console.error('Socket connect_error:', err); - }); + let pollInterval; + let currentInterval = DEFAULT_INTERVAL_MS; + let isPolling = false; - socket.on('error', (err) => { - console.error('Socket error:', err); - }); - - socket.on('connect', () => { - socket.emit('join', { room: `job-${jobId}` }); - }); - - window.addEventListener('beforeunload', () => { - socket.emit('leave', { room: `job-${jobId}` }); - }); - - const debouncedUpdate = debounce((data) => { - updateAllJobSections(); - }, 1000); - - socket.on('job_updated', (data) => { - if (data.job_id !== jobId) return; - debouncedUpdate(data); - }); + function calculateBackoff(responseTime) { + return Math.min( + MAX_INTERVAL_MS, + Math.max( + MIN_INTERVAL_MS, + Math.floor((250 * Math.sqrt(responseTime)) - 1000) + ) + ); } - function updateAllJobSections() { + async function updateAllJobSections() { + if (document.hidden || isPolling) return; + + isPolling = true; + const startTime = Date.now(); + const resourceEl = document.querySelector('[data-socket-update="status"]'); const url = resourceEl?.dataset?.resource; if (!url) { - console.warn('No resource URL found for job updates'); + isPolling = false; return; } - fetch(url) - .then((res) => res.json()) - .then(({ status, counts, notifications }) => { - const sections = { - status: document.querySelector('[data-socket-update="status"]'), - counts: document.querySelector('[data-socket-update="counts"]'), - notifications: document.querySelector( - '[data-socket-update="notifications"]' - ), - }; + try { + const response = await fetch(url); + const data = await response.json(); - if (status && sections.status) { - sections.status.innerHTML = status; - } - if (counts && sections.counts) { - sections.counts.innerHTML = counts; - } - if (notifications && sections.notifications) { - sections.notifications.innerHTML = notifications; - } - }) - .catch((err) => { - console.error('Error fetching job update partials:', err); - }); + const sections = { + status: document.querySelector('[data-socket-update="status"]'), + counts: document.querySelector('[data-socket-update="counts"]'), + notifications: document.querySelector('[data-socket-update="notifications"]'), + }; + + if (data.status && sections.status) { + sections.status.innerHTML = data.status; + } + if (data.counts && sections.counts) { + sections.counts.innerHTML = data.counts; + } + if (data.notifications && sections.notifications) { + sections.notifications.innerHTML = data.notifications; + } + + const responseTime = Date.now() - startTime; + currentInterval = calculateBackoff(responseTime); + + if (data.stop === 1 || data.finished === true) { + stopPolling(); + } + + } catch (error) { + console.error('Error fetching job updates:', error); + 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(); }); diff --git a/app/templates/components/ajax-block.html b/app/templates/components/ajax-block.html index c73978725..f85fc5ff3 100644 --- a/app/templates/components/ajax-block.html +++ b/app/templates/components/ajax-block.html @@ -1,14 +1,3 @@ {% macro ajax_block(partials, url, key, finished=False, form='') %} - {% if not finished %} -