feat: replace client polling with Server-Sent Events (SSE)
Server: - poller.js: add pollSubscribers Set with onPollComplete/offPollComplete; notify all SSE callbacks immediately after every successful poll - dashboard.js: add GET /api/dashboard/stream endpoint (text/event-stream) - requireAuth enforced via cookie (no CSRF needed — GET is a safe method) - X-Accel-Buffering: no for nginx proxy compatibility - 25s heartbeat comments to survive proxy idle timeouts - initial payload sent immediately on connect - cleanup on req.close: deregister callback, stop heartbeat, remove client - active client tracking updated: type='sse', connectedAt, no refreshRateMs Frontend: - app.js: replace setInterval/fetchUserDownloads with EventSource - startSSE() opens /api/dashboard/stream; stopSSE() closes it - first incoming message hides loading spinner - showAll toggle re-opens stream with ?showAll=true param - logout calls stopSSE() before POST /api/auth/logout - status panel: fixed 5s refresh, shows SSE clients + connect duration - statusRefreshHandle now always 5s, not tied to old refresh-rate selector - index.html: remove now-unused refresh-rate <select> element Docs: - ARCHITECTURE.md §4.3: update poller description - ARCHITECTURE.md §5: rename to SSE Stream (§5.2) + Download Matching (§5.3) - ARCHITECTURE.md §7: update active client tracking description - ARCHITECTURE.md §9: add /stream endpoint, update /status clients schema - ARCHITECTURE.md §10: update key functions table; replace Auto-Refresh section with Live Push via SSE - class-server.puml: add /stream to dashboard routes; update ClientInfo - component.puml: annotate dashboard with SSE note; update label
This commit is contained in:
150
public/app.js
150
public/app.js
@@ -1,12 +1,15 @@
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// SSE stream state
|
||||
let sseSource = null;
|
||||
let sseReconnectTimer = null;
|
||||
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
(function() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
@@ -20,7 +23,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
});
|
||||
@@ -41,37 +43,51 @@ function setTheme(theme) {
|
||||
});
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (currentRefreshRate > 0) {
|
||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
||||
}
|
||||
// --- SSE connection management ---
|
||||
|
||||
function startSSE() {
|
||||
stopSSE();
|
||||
const params = showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
function handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
// Restart status panel refresh if it's open
|
||||
const statusPanel = document.getElementById('status-panel');
|
||||
if (statusPanel && statusPanel.style.display !== 'none') {
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
function stopSSE() {
|
||||
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
||||
if (sseSource) {
|
||||
sseSource.close();
|
||||
sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowAllToggle(e) {
|
||||
showAll = e.target.checked;
|
||||
fetchUserDownloads(true);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
}
|
||||
|
||||
function fadeOutLogin() {
|
||||
@@ -132,8 +148,8 @@ async function checkAuthentication() {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
showDashboard();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
@@ -169,7 +185,7 @@ async function handleLogin(e) {
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||
// Fade out login, then show splash while loading data.
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
@@ -177,9 +193,9 @@ async function handleLogin(e) {
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
@@ -192,7 +208,7 @@ async function handleLogin(e) {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
stopSSE();
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
@@ -230,40 +246,8 @@ function hideLoginError() {
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
async function fetchUserDownloads(isInitialLoad = false) {
|
||||
if (isInitialLoad) {
|
||||
showLoading();
|
||||
}
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (showAll) params.set('showAll', 'true');
|
||||
params.set('refreshRate', currentRefreshRate);
|
||||
const url = '/api/dashboard/user-downloads?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
|
||||
// Debug: log first download to see what fields are present
|
||||
if (downloads.length > 0) {
|
||||
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
|
||||
}
|
||||
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
} catch (err) {
|
||||
showError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
}
|
||||
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||
|
||||
function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
@@ -628,6 +612,7 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
let statusRefreshHandle = null;
|
||||
const STATUS_REFRESH_MS = 5000;
|
||||
|
||||
async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
@@ -638,11 +623,8 @@ async function toggleStatusPanel() {
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
await refreshStatusPanel();
|
||||
// Auto-refresh in sync with dashboard refresh rate
|
||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
function closeStatusPanel() {
|
||||
@@ -693,11 +675,7 @@ function renderStatusPanel(data, panel) {
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
||||
const fastestClient = activeRefreshers.length > 0
|
||||
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
|
||||
: null;
|
||||
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
@@ -705,19 +683,15 @@ function renderStatusPanel(data, panel) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
if (hasForegroundClient) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
||||
} else if (activeRefreshers.length > 0) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
||||
}
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
||||
for (const c of clients) {
|
||||
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
||||
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
@@ -53,15 +53,6 @@
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div class="refresh-control">
|
||||
<label for="refresh-rate">Refresh:</label>
|
||||
<select id="refresh-rate">
|
||||
<option value="1000">1s</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="0">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
|
||||
Reference in New Issue
Block a user