Skip to content

Commit ce6eddd

Browse files
authored
Merge pull request #2960 from GSA/fix/status-page-updates
Refactored polling for status page
2 parents f2f0d0e + 1667a76 commit ce6eddd

File tree

19 files changed

+388
-706
lines changed

19 files changed

+388
-706
lines changed

.ds.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
"filename": "app/config.py",
162162
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
163163
"is_verified": false,
164-
"line_number": 123,
164+
"line_number": 120,
165165
"is_secret": false
166166
}
167167
],
@@ -634,5 +634,5 @@
634634
}
635635
]
636636
},
637-
"generated_at": "2025-09-29T15:45:16Z"
637+
"generated_at": "2025-10-01T14:58:39Z"
638638
}

.github/workflows/checks.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ jobs:
165165
run: make run-flask &
166166
env:
167167
NOTIFY_ENVIRONMENT: scanning
168-
FEATURE_SOCKET_ENABLED: true
169168
- name: Run OWASP Baseline Scan
170169
uses: zaproxy/[email protected]
171170
with:

app/__init__.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,24 @@ def add_security_headers(response):
192192
response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
193193
return response
194194

195-
@application.context_processor
196-
def inject_feature_flags():
197-
# this is where feature flags can be easily added as a dictionary within context
198-
feature_socket_enabled = application.config.get("FEATURE_SOCKET_ENABLED", False)
199-
200-
current_app.logger.info(
201-
f"FEATURE_SOCKET_ENABLED value in __init__.py coming \
202-
from config is {application.config.get('FEATURE_SOCKET_ENABLED')} and \
203-
the ending value is {feature_socket_enabled}"
204-
)
205-
return dict(
206-
FEATURE_SOCKET_ENABLED=feature_socket_enabled,
207-
)
208-
209195
@application.context_processor
210196
def inject_is_api_down():
211197
return {"is_api_down": is_api_down()}
212198

199+
# @application.context_processor
200+
# def inject_feature_flags():
201+
# this is where feature flags can be easily added as a dictionary within context
202+
# feature_socket_enabled = application.config.get("FEATURE_SOCKET_ENABLED", True)
203+
204+
# current_app.logger.info(
205+
# f"FEATURE_SOCKET_ENABLED value in __init__.py coming \
206+
# from config is {application.config.get('FEATURE_SOCKET_ENABLED')} and \
207+
# the ending value is {feature_socket_enabled}"
208+
# )
209+
# return dict(
210+
# FEATURE_SOCKET_ENABLED=feature_socket_enabled,
211+
# )
212+
213213
@application.context_processor
214214
def inject_initial_signin_url():
215215
ttl = 24 * 60 * 60
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
(function() {
2+
'use strict';
3+
4+
const POLLING_CONFIG = {
5+
POLL_INTERVAL_MS: 5000,
6+
MAX_RETRY_ATTEMPTS: 3,
7+
MAX_BACKOFF_MS: 60000
8+
};
9+
10+
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;
11+
12+
13+
class StatusPoller {
14+
constructor(serviceId, jobId, countsContainer) {
15+
this.serviceId = serviceId;
16+
this.jobId = jobId;
17+
this.countsContainer = countsContainer;
18+
this.pollInterval = null;
19+
this.isPolling = false;
20+
this.abortController = null;
21+
this.lastFinishedState = false;
22+
this.lastResponse = null;
23+
this.currentInterval = POLLING_CONFIG.POLL_INTERVAL_MS;
24+
}
25+
26+
async poll(retryCount = 0) {
27+
if (this.isPolling || document.hidden || this.lastFinishedState) {
28+
return;
29+
}
30+
31+
this.isPolling = true;
32+
33+
if (this.abortController) {
34+
this.abortController.abort();
35+
}
36+
37+
this.abortController = new AbortController();
38+
39+
try {
40+
const response = await fetch(
41+
`/services/${this.serviceId}/jobs/${this.jobId}/status.json`,
42+
{ signal: this.abortController.signal }
43+
);
44+
45+
if (!response.ok) {
46+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
47+
}
48+
49+
const data = await response.json();
50+
51+
const responseChanged = this.lastResponse === null ||
52+
JSON.stringify(data) !== JSON.stringify(this.lastResponse);
53+
54+
if (responseChanged) {
55+
const wasBackedOff = this.currentInterval !== POLLING_CONFIG.POLL_INTERVAL_MS;
56+
this.currentInterval = POLLING_CONFIG.POLL_INTERVAL_MS;
57+
if (wasBackedOff) {
58+
this.reschedulePolling();
59+
}
60+
} else {
61+
const oldInterval = this.currentInterval;
62+
this.currentInterval = Math.min(
63+
this.currentInterval * 2,
64+
POLLING_CONFIG.MAX_BACKOFF_MS
65+
);
66+
if (this.currentInterval !== oldInterval) {
67+
this.reschedulePolling();
68+
}
69+
}
70+
71+
this.lastResponse = data;
72+
this.updateStatusCounts(data);
73+
74+
if (data.finished === true && !this.lastFinishedState) {
75+
this.lastFinishedState = true;
76+
this.stop();
77+
78+
setTimeout(() => {
79+
this.loadNotificationsTable();
80+
}, 1000);
81+
}
82+
83+
return 0;
84+
} catch (error) {
85+
return this.handleError(error, retryCount);
86+
} finally {
87+
this.isPolling = false;
88+
this.abortController = null;
89+
}
90+
}
91+
92+
handleError(error, retryCount) {
93+
if (error.name === 'AbortError') {
94+
console.debug('Status poll aborted');
95+
return;
96+
}
97+
98+
const nextRetryCount = retryCount + 1;
99+
const backoffDelay = Math.min(
100+
Math.pow(2, retryCount) * 1000,
101+
POLLING_CONFIG.MAX_BACKOFF_MS
102+
);
103+
104+
if (retryCount < POLLING_CONFIG.MAX_RETRY_ATTEMPTS) {
105+
console.debug(
106+
`Status polling retry ${nextRetryCount}/${POLLING_CONFIG.MAX_RETRY_ATTEMPTS}`,
107+
error.message
108+
);
109+
} else {
110+
console.debug(
111+
`Status polling retry ${nextRetryCount} (backing off ${backoffDelay}ms)`,
112+
error.message
113+
);
114+
}
115+
116+
setTimeout(() => {
117+
this.poll(nextRetryCount);
118+
}, backoffDelay);
119+
}
120+
121+
updateStatusCounts(data) {
122+
const countElements = this.countsContainer.querySelectorAll('.big-number-number');
123+
124+
if (countElements.length >= 4) {
125+
countElements[0].textContent = (data.total || 0).toLocaleString();
126+
countElements[1].textContent = (data.pending || 0).toLocaleString();
127+
countElements[2].textContent = (data.delivered || 0).toLocaleString();
128+
countElements[3].textContent = (data.failed || 0).toLocaleString();
129+
}
130+
}
131+
132+
loadNotificationsTable() {
133+
const url = `${window.location.href.split('?')[0]}?_=${Date.now()}`;
134+
135+
fetch(url, {
136+
headers: {
137+
'Cache-Control': 'no-cache',
138+
'Pragma': 'no-cache'
139+
}
140+
})
141+
.then(response => response.text())
142+
.then(html => {
143+
const parser = new DOMParser();
144+
const doc = parser.parseFromString(html, 'text/html');
145+
const notificationsTable = doc.querySelector('.job-status-table');
146+
147+
if (notificationsTable) {
148+
const insertPoint = document.querySelector('.notification-status');
149+
if (insertPoint) {
150+
insertPoint.insertAdjacentElement('afterend', notificationsTable);
151+
} else {
152+
window.location.reload();
153+
}
154+
} else {
155+
window.location.reload();
156+
}
157+
})
158+
.catch(error => {
159+
console.error('Failed to load notifications:', error);
160+
window.location.reload();
161+
});
162+
}
163+
164+
reschedulePolling() {
165+
if (this.pollInterval) {
166+
clearInterval(this.pollInterval);
167+
}
168+
169+
this.pollInterval = setInterval(() => {
170+
this.poll();
171+
}, this.currentInterval);
172+
}
173+
174+
async start() {
175+
await this.poll();
176+
177+
this.pollInterval = setInterval(() => {
178+
this.poll();
179+
}, this.currentInterval);
180+
}
181+
182+
stop() {
183+
if (this.pollInterval) {
184+
clearInterval(this.pollInterval);
185+
this.pollInterval = null;
186+
}
187+
188+
if (this.abortController) {
189+
this.abortController.abort();
190+
this.abortController = null;
191+
}
192+
}
193+
194+
handleVisibilityChange() {
195+
if (document.hidden) {
196+
this.stop();
197+
} else if (!this.lastFinishedState) {
198+
this.start();
199+
}
200+
}
201+
}
202+
203+
function initializeJobPolling() {
204+
const isJobPage = window.location.pathname.includes('/jobs/');
205+
if (!isJobPage) return;
206+
207+
const countsContainer = document.querySelector('[data-key="counts"]');
208+
if (!countsContainer) return;
209+
210+
const pathParts = window.location.pathname.split('/');
211+
if (pathParts.length < 5 || pathParts[1] !== 'services' || pathParts[3] !== 'jobs') {
212+
return;
213+
}
214+
215+
const serviceId = pathParts[2];
216+
const jobId = pathParts[4];
217+
218+
if (!UUID_REGEX.test(serviceId) || !UUID_REGEX.test(jobId)) {
219+
return;
220+
}
221+
222+
const jobElement = document.querySelector('[data-job-id]');
223+
const isJobFinished = jobElement && jobElement.dataset.jobFinished === 'true';
224+
225+
if (isJobFinished) {
226+
return;
227+
}
228+
229+
const statusPoller = new StatusPoller(serviceId, jobId, countsContainer);
230+
231+
document.addEventListener('visibilitychange', () => {
232+
statusPoller.handleVisibilityChange();
233+
});
234+
235+
window.addEventListener('beforeunload', () => {
236+
statusPoller.stop();
237+
});
238+
239+
statusPoller.start();
240+
}
241+
242+
document.addEventListener('DOMContentLoaded', initializeJobPolling);
243+
})();

0 commit comments

Comments
 (0)