feat: rewrite pollSessions for multi-source parallel polling (task-5)
This commit is contained in:
+70
-11
@@ -241,25 +241,84 @@ function setConnectionStatus(level) {
|
|||||||
|
|
||||||
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Fetch /api/sessions and update the UI. Called by startPolling.
|
* Fetch sessions from all configured _sources in parallel and update the UI.
|
||||||
|
* Falls back to local-only polling when _sources is empty.
|
||||||
|
* Called by startPolling.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function pollSessions() {
|
async function pollSessions() {
|
||||||
try {
|
// Falls back to local-only if _sources is empty
|
||||||
const res = await api('GET', '/api/sessions');
|
if (!_sources || _sources.length === 0) {
|
||||||
const sessions = await res.json();
|
try {
|
||||||
const prev = _currentSessions;
|
const res = await api('GET', '/api/sessions');
|
||||||
_currentSessions = sessions;
|
const sessions = await res.json();
|
||||||
|
const prev = _currentSessions;
|
||||||
|
_currentSessions = sessions;
|
||||||
|
_pollFailCount = 0;
|
||||||
|
setConnectionStatus('ok');
|
||||||
|
renderGrid(sessions);
|
||||||
|
renderSidebar(sessions, _viewingSession);
|
||||||
|
handleBellTransitions(prev, sessions);
|
||||||
|
updateSessionPill(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
_pollFailCount++;
|
||||||
|
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-source parallel polling
|
||||||
|
const now = Date.now();
|
||||||
|
const fetchResults = await Promise.all(
|
||||||
|
_sources.map(async (source) => {
|
||||||
|
// Skip sources currently in backoff (nextRetryAt in future)
|
||||||
|
if (source.nextRetryAt && now < source.nextRetryAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api('GET', '/api/sessions', undefined, source.url || undefined);
|
||||||
|
const sessions = await res.json();
|
||||||
|
// Reset source status on success
|
||||||
|
source.status = 'authenticated';
|
||||||
|
source.backoffMs = 2000;
|
||||||
|
delete source.nextRetryAt;
|
||||||
|
return { source, sessions };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || '';
|
||||||
|
if (msg.includes('401') || msg.includes('403')) {
|
||||||
|
source.status = 'auth_required';
|
||||||
|
} else {
|
||||||
|
source.status = 'unreachable';
|
||||||
|
// Exponential backoff: current * 2, capped at 30s
|
||||||
|
const newBackoff = Math.min((source.backoffMs || 2000) * 2, 30000);
|
||||||
|
source.backoffMs = newBackoff;
|
||||||
|
source.nextRetryAt = Date.now() + newBackoff;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter skipped/failed sources and merge results
|
||||||
|
const validResults = fetchResults.filter((r) => r !== null);
|
||||||
|
const merged = mergeSources(validResults);
|
||||||
|
|
||||||
|
// Connection status is based on local source health
|
||||||
|
const localSource = _sources.find((s) => s.type === 'local' || s.url === '');
|
||||||
|
if (localSource && localSource.status === 'authenticated') {
|
||||||
_pollFailCount = 0;
|
_pollFailCount = 0;
|
||||||
setConnectionStatus('ok');
|
setConnectionStatus('ok');
|
||||||
renderGrid(sessions);
|
} else {
|
||||||
renderSidebar(sessions, _viewingSession);
|
|
||||||
handleBellTransitions(prev, sessions);
|
|
||||||
updateSessionPill(sessions);
|
|
||||||
} catch (err) {
|
|
||||||
_pollFailCount++;
|
_pollFailCount++;
|
||||||
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
|
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prev = _currentSessions;
|
||||||
|
_currentSessions = merged;
|
||||||
|
renderGrid(merged);
|
||||||
|
renderSidebar(merged, _viewingSession);
|
||||||
|
handleBellTransitions(prev, merged);
|
||||||
|
updateSessionPill(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2133,4 +2133,61 @@ test('mergeSources returns empty array for empty input', () => {
|
|||||||
assert.deepStrictEqual(result, [], 'mergeSources should return empty array for empty input');
|
assert.deepStrictEqual(result, [], 'mergeSources should return empty array for empty input');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- pollSessions multi-source (task-5) ---
|
||||||
|
|
||||||
|
test('pollSessions fetches from all sources and merges results', async () => {
|
||||||
|
const mockStatusEl = { textContent: '', className: '' };
|
||||||
|
const mockGrid = { innerHTML: '' };
|
||||||
|
const mockEmptyState = { style: {}, classList: { add: () => {}, remove: () => {} } };
|
||||||
|
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQSA = globalThis.document.querySelectorAll;
|
||||||
|
globalThis.document.getElementById = (id) => {
|
||||||
|
if (id === 'connection-status') return mockStatusEl;
|
||||||
|
if (id === 'session-grid') return mockGrid;
|
||||||
|
if (id === 'empty-state') return mockEmptyState;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
globalThis.document.querySelectorAll = () => [];
|
||||||
|
|
||||||
|
// Set up sources: local + remote
|
||||||
|
app._setSources([
|
||||||
|
{ url: '', name: 'Local', type: 'local', status: 'authenticated', backoffMs: 2000 },
|
||||||
|
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fetchCalls = [];
|
||||||
|
globalThis.fetch = async (url, opts) => {
|
||||||
|
fetchCalls.push(url);
|
||||||
|
if (url === '/api/sessions') {
|
||||||
|
return { ok: true, json: async () => [{ name: 'local-session' }] };
|
||||||
|
}
|
||||||
|
if (url === 'https://remote.example.com/api/sessions') {
|
||||||
|
return { ok: true, json: async () => [{ name: 'remote-session' }] };
|
||||||
|
}
|
||||||
|
return { ok: true, json: async () => [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.pollSessions();
|
||||||
|
|
||||||
|
// Both sources should have been fetched
|
||||||
|
assert.ok(fetchCalls.some((url) => url === '/api/sessions'), 'should fetch local sessions');
|
||||||
|
assert.ok(
|
||||||
|
fetchCalls.some((url) => url === 'https://remote.example.com/api/sessions'),
|
||||||
|
'should fetch remote sessions',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grid should contain sessions from both sources (merged)
|
||||||
|
assert.ok(mockGrid.innerHTML.includes('local-session'), 'grid should include local sessions');
|
||||||
|
assert.ok(mockGrid.innerHTML.includes('remote-session'), 'grid should include remote sessions');
|
||||||
|
|
||||||
|
// Connection status should be ok (local source succeeded)
|
||||||
|
assert.strictEqual(mockStatusEl.className, 'connection-status--ok', 'connection status should be ok when local source succeeds');
|
||||||
|
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelectorAll = origQSA;
|
||||||
|
globalThis.fetch = undefined;
|
||||||
|
app._setSources([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user