Skip to content

Commit 4d4b3a9

Browse files
fix(admin): harden timezone dashboard edge cases
1 parent d0edf3c commit 4d4b3a9

10 files changed

Lines changed: 437 additions & 28 deletions

File tree

internal/admin/dashboard/static/js/dashboard.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ function dashboard() {
239239
}
240240
},
241241

242+
_isCurrentAbortableRequest(controllerKey, controller) {
243+
if (!controller) {
244+
return true;
245+
}
246+
return this[controllerKey] === controller && !controller.signal.aborted;
247+
},
248+
242249
_isAbortError(error) {
243250
return Boolean(error) && (error.name === 'AbortError' || error.code === 20);
244251
},
@@ -284,6 +291,7 @@ function dashboard() {
284291

285292
async fetchModels() {
286293
const controller = this._startAbortableRequest('_modelsFetchController');
294+
const isCurrentRequest = () => this._isCurrentAbortableRequest('_modelsFetchController', controller);
287295
const options = { headers: this.headers() };
288296
if (controller) {
289297
options.signal = controller.signal;
@@ -295,13 +303,19 @@ function dashboard() {
295303
url += '?category=' + encodeURIComponent(this.activeCategory);
296304
}
297305
const res = await fetch(url, options);
306+
if (!isCurrentRequest()) {
307+
return;
308+
}
298309
if (!this.handleFetchResponse(res, 'models')) {
310+
if (!isCurrentRequest()) {
311+
return;
312+
}
299313
this.models = [];
300314
if (typeof this.syncDisplayModels === 'function') this.syncDisplayModels();
301315
return;
302316
}
303317
const payload = await res.json();
304-
if (controller && controller.signal.aborted) {
318+
if (!isCurrentRequest()) {
305319
return;
306320
}
307321
this.models = payload;
@@ -310,6 +324,9 @@ function dashboard() {
310324
if (this._isAbortError(e)) {
311325
return;
312326
}
327+
if (!isCurrentRequest()) {
328+
return;
329+
}
313330
console.error('Failed to fetch models:', e);
314331
this.models = [];
315332
if (typeof this.syncDisplayModels === 'function') this.syncDisplayModels();

internal/admin/dashboard/static/js/modules/contribution-calendar.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
const controller = typeof this._startAbortableRequest === 'function'
1111
? this._startAbortableRequest('_calendarFetchController')
1212
: null;
13+
const isCurrentRequest = () => {
14+
if (!controller) {
15+
return true;
16+
}
17+
if (typeof this._isCurrentAbortableRequest === 'function') {
18+
return this._isCurrentAbortableRequest('_calendarFetchController', controller);
19+
}
20+
return this._calendarFetchController === controller && !controller.signal.aborted;
21+
};
1322
const options = { headers: this.headers() };
1423
if (controller) {
1524
options.signal = controller.signal;
@@ -18,26 +27,38 @@
1827
this.calendarLoading = true;
1928
try {
2029
const res = await fetch('/admin/api/v1/usage/daily?days=365&interval=daily', options);
30+
if (!isCurrentRequest()) {
31+
return;
32+
}
2133
if (!this.handleFetchResponse(res, 'calendar')) {
34+
if (!isCurrentRequest()) {
35+
return;
36+
}
2237
this.calendarData = [];
2338
return;
2439
}
2540
const payload = await res.json();
26-
if (controller && controller.signal.aborted) {
41+
if (!isCurrentRequest()) {
2742
return;
2843
}
2944
this.calendarData = payload;
3045
} catch (e) {
3146
if (typeof this._isAbortError === 'function' && this._isAbortError(e)) {
3247
return;
3348
}
49+
if (!isCurrentRequest()) {
50+
return;
51+
}
3452
console.error('Failed to fetch calendar data:', e);
3553
this.calendarData = [];
3654
} finally {
37-
if (typeof this._clearAbortableRequest === 'function') {
55+
const currentRequest = isCurrentRequest();
56+
if (currentRequest && typeof this._clearAbortableRequest === 'function') {
3857
this._clearAbortableRequest('_calendarFetchController', controller);
3958
}
40-
this.calendarLoading = false;
59+
if (currentRequest) {
60+
this.calendarLoading = false;
61+
}
4162
}
4263
},
4364

internal/admin/dashboard/static/js/modules/request-cancellation.test.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,23 @@ function createPendingFetchQueue() {
8080
return { fetch, requests };
8181
}
8282

83+
function createNonAbortableFetchQueue() {
84+
const requests = [];
85+
86+
function fetch(url, options = {}) {
87+
return new Promise((resolve, reject) => {
88+
requests.push({
89+
url,
90+
options,
91+
resolve,
92+
reject
93+
});
94+
});
95+
}
96+
97+
return { fetch, requests };
98+
}
99+
83100
function jsonResponse(payload) {
84101
return {
85102
ok: true,
@@ -192,6 +209,71 @@ test('fetchModels aborts stale in-flight requests before applying new data', asy
192209
);
193210
});
194211

212+
test('fetchModels ignores stale unauthorized responses from superseded requests', async() => {
213+
const queue = createNonAbortableFetchQueue();
214+
const app = loadDashboardApp({ fetch: queue.fetch });
215+
const originalModels = [{ provider_type: 'openai', model: { id: 'existing-model' } }];
216+
app.models = originalModels.slice();
217+
218+
const firstFetch = app.fetchModels();
219+
assert.equal(queue.requests.length, 1);
220+
const firstSignal = queue.requests[0].options.signal;
221+
222+
const secondFetch = app.fetchModels();
223+
assert.equal(queue.requests.length, 2);
224+
assert.equal(firstSignal.aborted, true);
225+
226+
queue.requests[0].resolve({
227+
ok: false,
228+
status: 401,
229+
statusText: 'Unauthorized',
230+
json: async() => ({})
231+
});
232+
await firstFetch;
233+
234+
assert.equal(app.authError, false);
235+
assert.equal(app.needsAuth, false);
236+
assert.equal(JSON.stringify(app.models), JSON.stringify(originalModels));
237+
238+
queue.requests[1].resolve(jsonResponse([{ provider_type: 'openai', model: { id: 'gpt-5' } }]));
239+
await secondFetch;
240+
241+
assert.equal(app.authError, false);
242+
assert.equal(app.needsAuth, false);
243+
assert.equal(
244+
JSON.stringify(app.models),
245+
JSON.stringify([{ provider_type: 'openai', model: { id: 'gpt-5' } }])
246+
);
247+
});
248+
249+
test('fetchModels ignores stale errors from superseded requests', async() => {
250+
const queue = createNonAbortableFetchQueue();
251+
const app = loadDashboardApp({ fetch: queue.fetch });
252+
const originalModels = [{ provider_type: 'openai', model: { id: 'existing-model' } }];
253+
app.models = originalModels.slice();
254+
255+
const firstFetch = app.fetchModels();
256+
assert.equal(queue.requests.length, 1);
257+
const firstSignal = queue.requests[0].options.signal;
258+
259+
const secondFetch = app.fetchModels();
260+
assert.equal(queue.requests.length, 2);
261+
assert.equal(firstSignal.aborted, true);
262+
263+
queue.requests[0].reject(new Error('stale models failure'));
264+
await firstFetch;
265+
266+
assert.equal(JSON.stringify(app.models), JSON.stringify(originalModels));
267+
268+
queue.requests[1].resolve(jsonResponse([{ provider_type: 'openai', model: { id: 'gpt-5' } }]));
269+
await secondFetch;
270+
271+
assert.equal(
272+
JSON.stringify(app.models),
273+
JSON.stringify([{ provider_type: 'openai', model: { id: 'gpt-5' } }])
274+
);
275+
});
276+
195277
test('fetchCalendarData aborts stale in-flight requests before applying new data', async() => {
196278
const queue = createPendingFetchQueue();
197279
const app = loadDashboardApp({ fetch: queue.fetch });
@@ -213,3 +295,70 @@ test('fetchCalendarData aborts stale in-flight requests before applying new data
213295
JSON.stringify([{ date: '2026-03-29', total_tokens: 11 }])
214296
);
215297
});
298+
299+
test('fetchCalendarData ignores stale unauthorized responses while a newer request is active', async() => {
300+
const queue = createNonAbortableFetchQueue();
301+
const app = loadDashboardApp({ fetch: queue.fetch });
302+
const originalCalendarData = [{ date: '2026-03-28', total_tokens: 3 }];
303+
app.calendarData = originalCalendarData.slice();
304+
305+
const firstFetch = app.fetchCalendarData();
306+
assert.equal(queue.requests.length, 1);
307+
const firstSignal = queue.requests[0].options.signal;
308+
309+
const secondFetch = app.fetchCalendarData();
310+
assert.equal(queue.requests.length, 2);
311+
assert.equal(firstSignal.aborted, true);
312+
313+
queue.requests[0].resolve({
314+
ok: false,
315+
status: 401,
316+
statusText: 'Unauthorized',
317+
json: async() => ({})
318+
});
319+
await firstFetch;
320+
321+
assert.equal(JSON.stringify(app.calendarData), JSON.stringify(originalCalendarData));
322+
assert.equal(app.calendarLoading, true);
323+
assert.notEqual(app._calendarFetchController, null);
324+
325+
queue.requests[1].resolve(jsonResponse([{ date: '2026-03-29', total_tokens: 11 }]));
326+
await secondFetch;
327+
328+
assert.equal(app.calendarLoading, false);
329+
assert.equal(
330+
JSON.stringify(app.calendarData),
331+
JSON.stringify([{ date: '2026-03-29', total_tokens: 11 }])
332+
);
333+
});
334+
335+
test('fetchCalendarData ignores stale errors while a newer request is active', async() => {
336+
const queue = createNonAbortableFetchQueue();
337+
const app = loadDashboardApp({ fetch: queue.fetch });
338+
const originalCalendarData = [{ date: '2026-03-28', total_tokens: 3 }];
339+
app.calendarData = originalCalendarData.slice();
340+
341+
const firstFetch = app.fetchCalendarData();
342+
assert.equal(queue.requests.length, 1);
343+
const firstSignal = queue.requests[0].options.signal;
344+
345+
const secondFetch = app.fetchCalendarData();
346+
assert.equal(queue.requests.length, 2);
347+
assert.equal(firstSignal.aborted, true);
348+
349+
queue.requests[0].reject(new Error('stale calendar failure'));
350+
await firstFetch;
351+
352+
assert.equal(JSON.stringify(app.calendarData), JSON.stringify(originalCalendarData));
353+
assert.equal(app.calendarLoading, true);
354+
assert.notEqual(app._calendarFetchController, null);
355+
356+
queue.requests[1].resolve(jsonResponse([{ date: '2026-03-29', total_tokens: 11 }]));
357+
await secondFetch;
358+
359+
assert.equal(app.calendarLoading, false);
360+
assert.equal(
361+
JSON.stringify(app.calendarData),
362+
JSON.stringify([{ date: '2026-03-29', total_tokens: 11 }])
363+
);
364+
});

internal/admin/dashboard/static/js/modules/timezone-layout.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ test('dashboard templates expose a settings page and timezone context in activit
2323
assert.match(template, /x-text="calendarTimeZoneText\(\)"/);
2424
assert.match(template, /usage-ts[^>]*x-text="formatTimestamp\(entry\.timestamp\)"[^>]*:title="timestampTitle\(entry\.timestamp\)"/);
2525
assert.match(template, /audit-entry-meta[\s\S]*x-text="formatTimestamp\(entry\.timestamp\)"[\s\S]*:title="timestampTitle\(entry\.timestamp\)"/);
26+
assert.match(template, /<button(?=[^>]*class="audit-conversation-trigger")(?=[^>]*type="button")[^>]*>/);
2627
});

internal/admin/dashboard/static/js/modules/timezone.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
}
1010

1111
function browserStorage() {
12-
return global.localStorage || null;
12+
try {
13+
return global.localStorage || null;
14+
} catch (_) {
15+
return null;
16+
}
1317
}
1418

1519
function formatterCacheKey(locale, options) {
@@ -73,7 +77,12 @@
7377
return;
7478
}
7579

76-
const saved = storage.getItem(TIMEZONE_STORAGE_KEY) || '';
80+
let saved = '';
81+
try {
82+
saved = storage.getItem(TIMEZONE_STORAGE_KEY) || '';
83+
} catch (_) {
84+
saved = '';
85+
}
7786
this.timezoneOverride = this.isSupportedTimeZone(saved) ? saved : '';
7887
},
7988

@@ -99,7 +108,7 @@
99108
},
100109

101110
formatTimestampInTimeZone(ts, timeZone) {
102-
if (!ts) {
111+
if (ts === null || ts === undefined) {
103112
return '-';
104113
}
105114

@@ -277,9 +286,17 @@
277286
const storage = browserStorage();
278287
if (storage) {
279288
if (this.timezoneOverride && this.isSupportedTimeZone(this.timezoneOverride)) {
280-
storage.setItem(TIMEZONE_STORAGE_KEY, this.timezoneOverride);
289+
try {
290+
storage.setItem(TIMEZONE_STORAGE_KEY, this.timezoneOverride);
291+
} catch (_) {
292+
// Ignore storage failures and keep the in-memory override active.
293+
}
281294
} else {
282-
storage.removeItem(TIMEZONE_STORAGE_KEY);
295+
try {
296+
storage.removeItem(TIMEZONE_STORAGE_KEY);
297+
} catch (_) {
298+
// Ignore storage failures and still clear the in-memory override.
299+
}
283300
this.timezoneOverride = '';
284301
}
285302
}
@@ -293,7 +310,11 @@
293310
clearTimezoneOverride() {
294311
const storage = browserStorage();
295312
if (storage) {
296-
storage.removeItem(TIMEZONE_STORAGE_KEY);
313+
try {
314+
storage.removeItem(TIMEZONE_STORAGE_KEY);
315+
} catch (_) {
316+
// Ignore storage failures and still clear the in-memory override.
317+
}
297318
}
298319
this.timezoneOverride = '';
299320
this.calendarMonth = this.startOfMonthDate(this.customEndDate || this.todayDate());

0 commit comments

Comments
 (0)