docs: final 1.0.0 documentation pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
CI / Security audit (push) Successful in 50s
CI / Tests & coverage (push) Successful in 1m3s

README.md:
- Node prerequisite: v12+ → v22+
- Real-Time Updates: describe SSE push, remove polling/refresh-selector wording
- On-demand mode: update for SSE connect triggering poll
- API Endpoints: add /stream, /me, /csrf, /user-summary, /status, /cover-art
- Remove stale /api/qbittorrent proxy entry
- Docker tags: update to 1.0.x

SECURITY.md:
- Supported versions: add 1.0.x, retire 0.2.x
- CSP header: add style-src-attr 'unsafe-inline'
- Nginx example: add proxy_buffering off / proxy_read_timeout for SSE

Diagrams:
- seq-dashboard.puml: rewrite as SSE stream sequence (connect,
  initial payload, pushed updates, heartbeat, disconnect)
- seq-polling.puml: add SSE subscriber notification step after
  cache population
- state-ui.puml: replace Refresh Rate sub-state with SSE Connection
  state machine; update splash loading and logout transitions
- state-poller.puml: add Notifying SSE subscribers step in Polling state

package.json: bump to 1.0.0
This commit is contained in:
2026-05-17 09:19:35 +01:00
parent 0d4b169c79
commit dcf613446e
7 changed files with 85 additions and 106 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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