Admin panel B2B e-shopu není dashboard, na který se kouká pro radost. Když přijde objednávka, reklamace nebo nový zákazník, admin to potřebuje vidět hned — ne až po zmáčknutí F5. Věta „refreshni si to” je v provozu s desítkami denních objednávek cesta k vyhořelému skladovému oknu.
Nedávno jsem do jednoho white-label autodílárny přidal živý náhled bez jediného refresh tlačítka. Server posílá změny, klient je zobrazí. Žádný WebSocket, žádná externí služba, žádná závislost na Redis Pub/Sub. Stačilo pár set řádků.
Jak vypadá problém
Každý admin vidí v panelu:
- počty objednávek, faktur, dodacích listů, dobropisů a reklamací podle stavu
- CRM úkoly s termíny
- skladové pohyby a opravy
Když operátor potvrdí objednávku, číslo v badge „čeká na odeslání” musí klesnout a v badge „odesláno” stoupnout — a to dřív, než kolega v druhém skladu stihne říct „refreshni to”.
Řešení typu CRON dotaz každých pár sekund z prohlížeče (polling) funguje, ale deset adminů = deset HTTP requestů každé dvě sekundy = zbytečná zátěž na API server. A polling v cyklu, kde se nic neděje, topí daty zákazníkům na mobilním tarifu.
Proč SSE a ne WebSocket
Server-Sent Events je HTTP protokol, kde server otevře spojení a posílá data, dokud klient neodejde. Proti WebSocketu:
- Jedna cesta — data jdou jen server → klient. Když admin neposílá nic zpátky, nepotřebujeme plně duplexní kanál.
- Standardní HTTP — žádné upgrade handshake, žádné problémy s proxy, žádná knihovna na klientovi. Prohlížeč má
EventSourcevestavěný. - Automatické znovupřipojení — při pádu spojení se
EventSourcesám pokusí reconnectovat.
WebSocket by byl overengineering. Tohle je notifikační kanál, ne multiplayerová hra.
Architektura: jeden poller pro všechny
Klíčové rozhodnutí: když je připojených deset adminů, nechceme deset databázových dotazů. Poller běží jednou v procesu a výsledek posílá všem najednou.
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ EventSource │────▶│ subscribe() │◀────▶│ Interval (2s) │
│ (admin 1)│ │ EventEmitter │ │ computeRevision()│
├──────────┤ │ │ └────────┬─────────┘
│ EventSource │────▶│ │ │
│ (admin 2)│ │ │ prisma.groupBy
└──────────┘ └──────────────┘ ┌─────────┐
│orders │
│invoices │
│complaints│
│...8 tables│
└─────────┘
subscribeAdminChanges()přidá listener do EventEmitteru a spustí interval (pouze pokud ještě neběží).- Každé ~2 vteřiny běží
computeAdminRevision()— spočítá fingerprint napříč 8+ tabulkami (groupBy podle statusu, počet řádků). - Když se fingerprint změní (přibyla/zmizela objednávka, změnil se status), rozešle se událost všem připojeným adminům.
- Když poslední admin zavře prohlížeč, interval se zastaví.
Tenhle pattern (in-process, reference-based fingerprint) nepotřebuje Redis ani Postgres LISTEN/NOTIFY. Pokud by aplikace někdy škálovala na víc instancí, stačí vyměnit pulse-stream.ts za sdílenou frontu — zbytek se nemění.
Fingerprint: co se vlastně změnilo
Místo porovnávání celých objektů nebo timestampů počítáme textový fingerprint:
o[pending:5,shipped:12]|i[issued:3,paid:8]|r[open:2,resolved:1]|...
Každá tabulka je seskupená podle (indexovaného) statusu. Když se změní počet v nějaké skupině — ať už insertem, deletem nebo přechodem mezi stavy — fingerprint je jiný. A jen tehdy se adminům pošle nová revize.
Žádné porovnávání JSONů, žádné sledování timestampů. Jeden string, jedna podmínka.
Co se děje na klientovi
Komponenta AdminLive je nasazená jednou v layoutu a nerenderuje žádné HTML:
- Otevře
EventSourcena/api/admin/events - První přijatá revize je baseline — nezpůsobí refresh
- Každá další revize, která se liší, spustí
router.refresh()po 400ms koalescenci - Když admin rychle potvrdí tři objednávky za sebou, refresh proběhne jen jednou
400ms koalescence není náhoda. Při dávkovém zpracování (např. hromadné potvrzení faktur) by každá změna poslala samostatnou událost a bez koalescence by se stránka zbytečně překreslovala pětkrát za vteřinu.
Specifika provozu
- Nginx: hlavička
X-Accel-Buffering: nozakáže buffering, jinak by nginx držel odpověď, dokud se nenaplní buffer. - Heartbeat: každých 25 vteřin pošleme comment ping (
: ping\n\n), aby proxy server nepovažoval spojení za mrtvé. - Cleanup: při zavření karty nebo odchodu z adminu se listener odregistruje a interval se zastaví. Žádné plovoucí spojení.
Výsledek
Admin panel, který žije. Objednávka přijde, badge změní číslo, operátor ji vidí — bez F5, bez notifikace, bez otázky „refreshnul jsi?”.
A celé to stojí na jednom modulu a jednom EventSource listeneru. Žádný Redis, žádný WebSocket server, žádná nová infrastruktura. Jen HTTP, jak ho známe.