diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c35764a..d066212 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: NODE_ENV: test - name: Upload coverage report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 if: always() with: name: coverage-report diff --git a/README.md b/README.md index da76491..3154fa6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] ## Prerequisites -- **Docker** (recommended), or Node.js (v12+) for manual installation +- **Docker** (recommended), or Node.js (v22+) for manual installation - At least one of: SABnzbd or qBittorrent - Sonarr (optional, for TV tracking) - Radarr (optional, for movie tracking) @@ -141,8 +141,8 @@ services: | Tag | Description | |-----|-------------| | `latest` | Latest stable release | -| `0.1` | Latest patch for the 0.1.x release line | -| `0.1.0` | Specific version | +| `1.0` | Latest patch for the 1.0.x release line | +| `1.0.0` | Specific version | ### Updating @@ -245,11 +245,12 @@ sofarr polls all configured services in the background and caches the results. D | `POLL_INTERVAL=10000` | Poll every 10 seconds. | | `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. | -**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone. +**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll. ### Real-Time Updates -- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off) +- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests. - In-place DOM updates for smooth UI (no flickering) +- Browser reconnects automatically on network interruption ### Download Information Displayed - **Progress bar** with visual completion percentage @@ -267,18 +268,23 @@ sofarr polls all configured services in the background and caches the results. D ## API Endpoints ### Authentication -- `POST /api/auth/login` - Login with Emby credentials -- `POST /api/auth/logout` - Logout and clear session +- `POST /api/auth/login` — Login with Emby credentials +- `POST /api/auth/logout` — Logout and revoke session +- `GET /api/auth/me` — Check current session +- `GET /api/csrf` — Fetch a CSRF token ### Dashboard -- `GET /api/dashboard/downloads` - Get all downloads for authenticated user +- `GET /api/dashboard/stream` — **SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle +- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming) +- `GET /api/dashboard/user-summary` — Per-user download counts (admin) +- `GET /api/dashboard/status` — Server / polling / cache status (admin) +- `GET /api/dashboard/cover-art` — Proxied cover art image ### Service APIs (proxy to your services) -- `GET /api/sabnzbd/*` - SABnzbd API proxy -- `GET /api/qbittorrent/*` - qBittorrent API proxy -- `GET /api/sonarr/*` - Sonarr API proxy -- `GET /api/radarr/*` - Radarr API proxy -- `GET /api/emby/*` - Emby API proxy +- `GET /api/sabnzbd/*` — SABnzbd API proxy +- `GET /api/sonarr/*` — Sonarr API proxy +- `GET /api/radarr/*` — Radarr API proxy +- `GET /api/emby/*` — Emby API proxy ## Logging Levels diff --git a/SECURITY.md b/SECURITY.md index 406188f..448ed35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | |---------|-----------| -| 0.2.x | ✅ Yes | +| 1.0.x | ✅ Yes | +| 0.2.x | ❌ No | | 0.1.x | ❌ No | ## Reporting a Vulnerability @@ -113,6 +114,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SSE (Server-Sent Events) — disable response buffering + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; } } ``` @@ -123,7 +129,7 @@ server { | Header | Value | |--------|-------| -| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) | | `X-Content-Type-Options` | `nosniff` | | `X-Frame-Options` | `DENY` | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0101a12..d588c67 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -221,7 +221,7 @@ sofarr/ **`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining). -**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request. +**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately. **`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`). @@ -253,18 +253,31 @@ Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`. -### 5.2 Dashboard Request +### 5.2 SSE Stream -When a user requests `/api/dashboard/user-downloads`: +When a browser opens `GET /api/dashboard/stream` (after authentication): -1. Read all `poll:*` keys from cache -2. Build `seriesMap` and `moviesMap` from embedded objects in queue records -3. Build `sonarrTagMap` and `radarrTagMap` from tag data -4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title -5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records -6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history -7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user -8. Return only the user's downloads (or all, if admin with `showAll=true`) +1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`) +2. Immediately builds and sends the first payload (same matching logic as below) +3. Registers a callback with the poller's `onPollComplete` subscriber set +4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame +5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies +6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map + +The browser's native `EventSource` API handles reconnection automatically on network interruption. + +### 5.3 Download Matching + +For each connected user the server: + +1. Reads all `poll:*` keys from cache +2. Builds `seriesMap` and `moviesMap` from embedded objects in queue records +3. Builds `sonarrTagMap` and `radarrTagMap` from tag data +4. For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title +5. For each SABnzbd history slot → tries to match against Sonarr/Radarr history records +6. For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history +7. For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user +8. Returns only the user's downloads (or all, if admin with `showAll=true`) --- @@ -336,7 +349,7 @@ Users are matched to downloads via tags in Sonarr/Radarr: ### Active Client Tracking -Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display. +SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The `type: 'sse'` field distinguishes SSE clients from any legacy HTTP clients. --- @@ -476,15 +489,38 @@ Clear session and revoke the Emby token server-side. Does **not** require a CSRF --- +### `GET /api/dashboard/stream` + +Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle. + +**Query Parameters:** +| Param | Type | Description | +|-------|------|-------------| +| `showAll` | `"true"` | (Admin) Include all users' downloads | + +**Response:** `Content-Type: text/event-stream` + +Each event is a `data:` frame containing JSON: +```json +{ + "user": "Alice", + "isAdmin": false, + "downloads": [ /* download objects — same shape as /user-downloads */ ] +} +``` + +The connection is kept alive with `: heartbeat` comments every 25 seconds. The browser's `EventSource` reconnects automatically on failure. + +--- + ### `GET /api/dashboard/user-downloads` -Fetch downloads for the authenticated user. +Fetch downloads for the authenticated user (single HTTP request, no streaming). **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `showAll` | `"true"` | (Admin) Show all users' downloads | -| `refreshRate` | number (ms) | Client's current refresh rate for tracking | **Response (200):** ```json @@ -531,7 +567,7 @@ Admin-only server status. ] }, "clients": [ - { "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 } + { "user": "Alice", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 } ] } ``` @@ -577,13 +613,13 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid |----------|---------| | `checkAuthentication()` | On load: check session → show dashboard or login | | `handleLogin()` | Authenticate, fade login → splash → dashboard | -| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render | +| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide | +| `stopSSE()` | Close `EventSource` and cancel reconnect timer | | `renderDownloads()` | Diff-based card rendering (create/update/remove) | | `createDownloadCard()` | Build DOM for a single download card; renders tag badges | | `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | | `toggleStatusPanel()` | Show/hide admin status panel | -| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) | -| `startAutoRefresh()` | Start periodic `fetchUserDownloads` | +| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) | | `initThemeSwitcher()` | Light / Dark / Mono theme support | ### Themes @@ -604,9 +640,11 @@ Download cards render tag badges in the card header: - Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost) - Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost) -### Auto-Refresh +### Live Push via SSE -The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking. +The dashboard receives updates via a persistent `EventSource` connection to `GET /api/dashboard/stream`. The server pushes a new `data:` event immediately after every poll cycle completes — there is no client-side timer. The browser's `EventSource` implementation handles reconnection automatically on network interruption. + +The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration. --- diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml index fce162f..9d7eb87 100644 --- a/docs/diagrams/class-server.puml +++ b/docs/diagrams/class-server.puml @@ -42,9 +42,11 @@ package "server/routes" { - activeClients : Map - CLIENT_STALE_MS : 30000 -- + + GET /stream (SSE, text/event-stream) + GET /user-downloads + GET /user-summary + GET /status + + GET /cover-art -- - getCoverArt(item) : string|null - extractAllTags(tags, tagMap) : string[] @@ -224,7 +226,8 @@ package "server/utils" { class "ClientInfo" as ci <> { + user : string - + refreshRateMs : number + + type : 'sse' + + connectedAt : number (timestamp) + lastSeen : number (timestamp) } } diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml index 59eead1..0d9cc9b 100644 --- a/docs/diagrams/component.puml +++ b/docs/diagrams/component.puml @@ -30,7 +30,7 @@ package "Express Server" as server { package "Routes" as routes { [auth.js\n/api/auth\n(pre-CSRF)] as auth - [dashboard.js\n/api/dashboard] as dashboard + [dashboard.js\n/api/dashboard\n(+SSE /stream)] as dashboard [emby.js\n/api/emby] as emby_route [sabnzbd.js\n/api/sabnzbd] as sab_route [sonarr.js\n/api/sonarr] as sonarr_route @@ -86,6 +86,9 @@ package "Express Server" as server { auth ..> sanitize dashboard ..> sanitize + + note "Browser uses EventSource\n(SSE) to /stream.\nServer pushes on each\npoll cycle — no client timer." as sseNote + sseNote .. dashboard } cloud "External Services" as external { diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml index 34f1772..15e6578 100644 --- a/docs/diagrams/seq-dashboard.puml +++ b/docs/diagrams/seq-dashboard.puml @@ -1,6 +1,6 @@ @startuml seq-dashboard !theme plain -title sofarr — Dashboard Request Sequence +title sofarr — Dashboard SSE Stream Sequence actor User as user participant "Browser\n(app.js)" as browser @@ -9,47 +9,28 @@ participant "MemoryCache" as cache participant "Poller" as poller participant "External\nServices" as ext -== Periodic Refresh (or Initial Load) == -user -> browser : (auto-refresh fires) +== SSE Connection (on login / page load) == +user -> browser : Login success\nor valid session activate browser -browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false +browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user) activate dashboard -dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin -dashboard -> dashboard : Track client refresh rate\nin activeClients Map +dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin +dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no +dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt } alt Polling disabled AND cache empty dashboard -> poller : pollAllServices() activate poller - poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit) + poller -> ext : Parallel API calls ext --> poller : Raw data - poller -> cache : set poll:* keys\n(TTL = 30s) + poller -> cache : set poll:* keys (TTL=30s) deactivate poller end -dashboard -> cache : get('poll:sab-queue') -cache --> dashboard : { slots, status, speed } -dashboard -> cache : get('poll:sab-history') -cache --> dashboard : { slots } -dashboard -> cache : get('poll:sonarr-tags') -cache --> dashboard : [{ instance, data }] -dashboard -> cache : get('poll:sonarr-queue') -cache --> dashboard : { records } (with embedded series) -dashboard -> cache : get('poll:sonarr-history') -cache --> dashboard : { records } -dashboard -> cache : get('poll:radarr-queue') -cache --> dashboard : { records } (with embedded movie) -dashboard -> cache : get('poll:radarr-history') -cache --> dashboard : { records } -dashboard -> cache : get('poll:radarr-tags') -cache --> dashboard : [{id, label}] -dashboard -> cache : get('poll:qbittorrent') -cache --> dashboard : [torrent, ...] - -dashboard -> dashboard : Build seriesMap from\nSonarr queue records -dashboard -> dashboard : Build moviesMap from\nRadarr queue records -dashboard -> dashboard : Build tag maps\n(id → label) - +== Initial Payload (sent immediately on connect) == +dashboard -> cache : get all poll:* keys +dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap alt showAll=true dashboard -> cache : get('emby:users') alt cache miss @@ -57,44 +38,30 @@ alt showAll=true ext --> dashboard : [{ Name, ... }] dashboard -> cache : set('emby:users', map, 60s) end - dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName) +end +dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges +dashboard --> browser : data: { user, isAdmin, downloads } +browser -> browser : hideLoading()\nrenderDownloads() + +== Pushed Updates (on every poll cycle) == +loop Each poll cycle completes + poller -> poller : pollAllServices() complete + poller -> dashboard : onPollComplete callback fires + dashboard -> cache : get all poll:* keys + dashboard -> dashboard : Rebuild download payload + dashboard --> browser : data: { user, isAdmin, downloads } + browser -> browser : renderDownloads() (diff-based) end -group SABnzbd Queue Matching - loop each queue slot - dashboard -> dashboard : Match title vs Sonarr queue - dashboard -> dashboard : Match title vs Radarr queue - dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap) - end -end +== Heartbeat (every 25s) == +dashboard --> browser : : heartbeat +note right : Keeps connection alive\nthrough idle-timeout proxies -group SABnzbd History Matching - loop each history slot - dashboard -> dashboard : Match title vs Sonarr/Radarr history - dashboard -> dashboard : Same tag extraction + inclusion logic - end -end - -group qBittorrent Matching - loop each torrent - dashboard -> dashboard : 1. Match vs Sonarr queue - dashboard -> dashboard : 2. Match vs Radarr queue - dashboard -> dashboard : 3. Match vs Sonarr history - dashboard -> dashboard : 4. Match vs Radarr history - dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges - end -end - -dashboard --> browser : { user, isAdmin,\ndownloads: [...] } +== Client Disconnects == +user -> browser : Close tab / logout +browser -> dashboard : TCP close (req 'close' event) +dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key] deactivate dashboard - -browser -> browser : renderDownloads() (diff-based) -note right - createDownloadCard() renders tag badges: - - Normal: accent badge for matchedUserTag - - showAll: amber badges (unmatched tags) - accent badges (matched → show Emby displayName) -end note deactivate browser @enduml diff --git a/docs/diagrams/seq-polling.puml b/docs/diagrams/seq-polling.puml index 5014f2d..7866218 100644 --- a/docs/diagrams/seq-polling.puml +++ b/docs/diagrams/seq-polling.puml @@ -82,6 +82,10 @@ poller -> cache : set('poll:radarr-history', ..., cacheTTL) poller -> cache : set('poll:radarr-tags', ..., cacheTTL) poller -> cache : set('poll:qbittorrent', ..., cacheTTL) +poller -> poller : Notify SSE subscribers\npollSubscribers.forEach(cb => cb()) + +note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame + poller -> poller : polling = false\nlog elapsed time deactivate poller diff --git a/docs/diagrams/state-poller.puml b/docs/diagrams/state-poller.puml index e3bf585..a4a24e9 100644 --- a/docs/diagrams/state-poller.puml +++ b/docs/diagrams/state-poller.puml @@ -32,7 +32,9 @@ state Polling { lock --> fetching fetching --> storing : All promises resolved fetching --> ErrorState : Any individual service\nerror (caught per-service) - storing --> timing + storing --> notifying : Cache updated + state "Notifying SSE\nsubscribers" as notifying + notifying --> timing timing --> [*] : polling = false } diff --git a/docs/diagrams/state-ui.puml b/docs/diagrams/state-ui.puml index 5642922..6254602 100644 --- a/docs/diagrams/state-ui.puml +++ b/docs/diagrams/state-ui.puml @@ -32,48 +32,42 @@ state FadeOutLogin { FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading state SplashScreen2 as "Splash (loading data)" { - state "fetchUserDownloads()" as fetching + state "startSSE() — awaiting\nfirst SSE message" as fetching } SplashScreen2 --> Dashboard : Data loaded\ndismissSplash() state Dashboard { state "Rendering Cards" as rendering - state "Auto Refreshing" as refreshing state "Status Panel Open" as status_open state "Status Panel Closed" as status_closed [*] --> rendering - rendering --> refreshing : startAutoRefresh() - refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads() + rendering --> rendering : SSE message received +→ renderDownloads() rendering --> rendering : Theme change - status_closed --> status_open : Click "Status" btn\n(admin only) + status_closed --> status_open : Click "Status" btn +(admin only) status_open --> status_closed : Click close (×) - status_open --> status_open : Auto-refresh\nrenderStatusPanel() + status_open --> status_open : 5s timer +→ renderStatusPanel() [*] --> status_closed - state "Refresh Rate" as rr { - state "1s" as r1 - state "5s (default)" as r5 - state "10s" as r10 - state "Off" as roff - r5 --> r1 : User selects - r5 --> r10 - r5 --> roff - r1 --> r5 - r1 --> r10 - r1 --> roff - r10 --> r1 - r10 --> r5 - r10 --> roff - roff --> r1 - roff --> r5 - roff --> r10 + state "SSE Connection" as sse { + state "Connecting" as sc + state "Connected" as scon + state "Reconnecting" as srec + sc --> scon : First message received + scon --> srec : Connection lost + srec --> scon : Browser auto-reconnects + scon --> sc : showAll toggle changed } } -Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh) +Dashboard --> LoginForm : Logout +(stopSSE, +clear state) @enduml diff --git a/package.json b/package.json index 2e11fe6..efccb67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "0.2.0", + "version": "1.0.0", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/public/app.js b/public/app.js index eacfc87..ef1498c 100644 --- a/public/app.js +++ b/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,8 @@ async function handleLogin(e) { async function handleLogout() { try { - stopAutoRefresh(); + stopSSE(); + if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } await fetch('/api/auth/logout', { method: 'POST', headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} @@ -216,6 +233,10 @@ function showDashboard() { document.getElementById('login-container').style.display = 'none'; document.getElementById('dashboard-container').style.display = 'block'; document.getElementById('currentUser').textContent = currentUser.name || '-'; + // Always start with status panel hidden (guards against stale display value on re-login) + const sp = document.getElementById('status-panel'); + sp.style.display = 'none'; + sp.innerHTML = ''; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; } @@ -230,40 +251,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 +617,7 @@ function escapeHtml(str) { } let statusRefreshHandle = null; +const STATUS_REFRESH_MS = 5000; async function toggleStatusPanel() { const panel = document.getElementById('status-panel'); @@ -638,11 +628,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 +680,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 += `
Background poll${pollIntervalMs / 1000}s
`; @@ -705,19 +688,15 @@ function renderStatusPanel(data, panel) { html += `
Background pollDisabled
`; } - if (hasForegroundClient) { - html += `
Effective modeForeground ${fastestClient.refreshRateMs / 1000}s
`; - } else if (activeRefreshers.length > 0) { - html += `
Effective mode${data.polling.enabled ? 'Background' : 'On-demand'}
`; - } else { - html += `
Effective modeIdle (no active clients)
`; - } + const mode = sseClients.length > 0 + ? `SSE push` + : (data.polling.enabled ? 'Background' : 'On-demand (idle)'); + html += `
Delivery mode${mode}
`; - html += `
Active clients${clients.length}
`; - 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 += `
${escapeHtml(c.user)}${rate} (${age}s ago)
`; + html += `
SSE clients${sseClients.length}
`; + for (const c of sseClients) { + const age = Math.round((Date.now() - c.connectedAt) / 1000); + html += `
${escapeHtml(c.user)}connected ${age}s ago
`; } html += ``; @@ -730,12 +709,13 @@ function renderStatusPanel(data, panel) {
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
`; + const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1); for (const t of lp.tasks) { - const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0; + const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100); html += `
${escapeHtml(t.label)} -
+
${t.ms}ms
`; } @@ -759,6 +739,10 @@ function renderStatusPanel(data, panel) { html += `
`; panel.innerHTML = html; + // Set bar widths via JS DOM assignment — immune to CSP style-src restrictions + panel.querySelectorAll('.timing-bar[data-w]').forEach(el => { + el.style.width = el.dataset.w + '%'; + }); } function formatSize(size) { diff --git a/public/index.html b/public/index.html index 5c23feb..ae54cd0 100644 --- a/public/index.html +++ b/public/index.html @@ -53,15 +53,6 @@ -
- - -