diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 71d3923..4a24aef 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -241,25 +241,84 @@ function setConnectionStatus(level) { // ─── 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} */ async function pollSessions() { - try { - const res = await api('GET', '/api/sessions'); - const sessions = await res.json(); - const prev = _currentSessions; - _currentSessions = sessions; + // Falls back to local-only if _sources is empty + if (!_sources || _sources.length === 0) { + try { + const res = await api('GET', '/api/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; setConnectionStatus('ok'); - renderGrid(sessions); - renderSidebar(sessions, _viewingSession); - handleBellTransitions(prev, sessions); - updateSessionPill(sessions); - } catch (err) { + } else { _pollFailCount++; setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); } + + const prev = _currentSessions; + _currentSessions = merged; + renderGrid(merged); + renderSidebar(merged, _viewingSession); + handleBellTransitions(prev, merged); + updateSessionPill(merged); } /** diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index c458314..5c050c4 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2133,4 +2133,61 @@ test('mergeSources returns 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([]); +}); +