From ea9387bea9ec558ec376968698220e00a4797858 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:41:42 +0200 Subject: [PATCH] Seed z repozitare, zatezove testy, worker bez spanku, dialogy bez rozmazani Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu, akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje. Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety a zatezovy skript (npm run load) proti bezici instanci vcetne davky udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop posluchacu streamu zvednut na 2 000. Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s. Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana. Co-Authored-By: Claude Fable 5.1 --- .env.example | 3 + .gitignore | 3 + .prettierignore | 1 + Dockerfile | 4 + documentation/01-prehled-a-stav.md | 13 +- documentation/03-architektura-a-mapa-kodu.md | 12 +- documentation/04-api.md | 16 + documentation/14-databaze.md | 67 ++ documentation/15-rejstrik-funkci.md | 4 + documentation/19-kapacita-200-firem.md | 135 ++++ documentation/20-fronta-a-runtime.md | 7 + documentation/22-znacka-a-design.md | 12 +- documentation/99-zmeny.md | 81 ++ eslint.config.js | 5 + package.json | 3 + scripts/export-seed.mjs | 89 +++ scripts/load-test.mjs | 741 ++++++++++++++++++ seed/records.json | 383 +++++++++ src/config.ts | 20 + src/data/seedExport.ts | 26 + src/data/store/index.ts | 10 +- src/data/store/seedFile.ts | 153 ++++ src/events/bus.ts | 11 +- src/openapi/components/admin.ts | 38 + src/openapi/components/index.ts | 2 + src/openapi/paths/admin.ts | 23 +- src/routes/admin.ts | 33 + src/runtime/worker.ts | 39 +- tests/data/seedFile.test.ts | 184 +++++ tests/perf/executor.perf.test.ts | 256 ++++++ tests/perf/fixtures.ts | 280 +++++++ tests/perf/measure.ts | 79 ++ tests/perf/permissions.perf.test.ts | 173 ++++ tests/perf/stream.perf.test.ts | 117 +++ tests/perf/tickets.perf.test.ts | 203 +++++ tests/perf/widgets.perf.test.ts | 153 ++++ tests/routes/adminExport.test.ts | 90 +++ tests/setup.ts | 5 +- vitest.config.ts | 5 +- vitest.perf.config.ts | 32 + .../components/dashboard/DashboardLayout.tsx | 2 +- web/src/components/dashboard/EventToasts.tsx | 10 +- .../components/dashboard/LiveIndicator.tsx | 8 +- web/src/components/ui/Modal.tsx | 9 +- 44 files changed, 3512 insertions(+), 28 deletions(-) create mode 100644 scripts/export-seed.mjs create mode 100644 scripts/load-test.mjs create mode 100644 seed/records.json create mode 100644 src/data/seedExport.ts create mode 100644 src/data/store/seedFile.ts create mode 100644 src/openapi/components/admin.ts create mode 100644 tests/data/seedFile.test.ts create mode 100644 tests/perf/executor.perf.test.ts create mode 100644 tests/perf/fixtures.ts create mode 100644 tests/perf/measure.ts create mode 100644 tests/perf/permissions.perf.test.ts create mode 100644 tests/perf/stream.perf.test.ts create mode 100644 tests/perf/tickets.perf.test.ts create mode 100644 tests/perf/widgets.perf.test.ts create mode 100644 tests/routes/adminExport.test.ts create mode 100644 vitest.perf.config.ts diff --git a/.env.example b/.env.example index 57a15fc..b6a2024 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,9 @@ DATABASE_SSL=false DATA_DIR=./data # 1 = nasypat ukazkove tickety do prazdneho uloziste. SEED_DEMO= +# Nastaveni z repozitare (vystup `npm run seed:export`), nasype se jen do +# prazdneho uloziste. Vychozi ./seed/records.json, prazdna hodnota vypne. +SEED_FILE= # --- znacka -------------------------------------------------------------- BRAND_NAME=WorkNuke diff --git a/.gitignore b/.gitignore index ab52083..1662272 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Sestavene platno navrhu. 2,5 MB generovaneho souboru, ktery vznikne znovu # ze zdroju vedle nej (design/*.dc.html a canvas.json). design/detail-ticketu.html + +# Vystup zatezoveho testu (npm run load). Cisla patri do dokumentace, ne do gitu. +load-report.json diff --git a/.prettierignore b/.prettierignore index b453722..afd9c48 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ design package-lock.json # Dokumentace ma vlastni styl tabulek, prettier by je prelamal. *.md +seed diff --git a/Dockerfile b/Dockerfile index 352ddeb..b88ba48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,10 @@ COPY --from=build /app/dist ./dist # Skripty konektoru jsou obycejny JavaScript, nekompiluji se. Musi se ale # dostat do image, jinak by konektory nemely zadnou vykonnou cast. COPY --from=build /app/connectors ./connectors +# Nastaveni z repozitare (seed/records.json): bez databaze a svazku by kazdy +# redeploy zacinal od ukazkovych dat. Cte se jen do prazdneho uloziste, +# viz documentation/14-databaze.md. +COPY --from=build /app/seed ./seed # Migrace jsou .sql, ktere tsc do dist nekopiruje. Bez nich by se schema # nevytvorilo, aplikace by spadla na migracich a jela dal v pameti - tedy presne # to, co ma databaze resit. Cesta musi odpovidat dist/db/migrations, protoze diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 9c89322..59dbe62 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -82,7 +82,8 @@ React aplikaci ze slozky `dist/public`. | Struktura podle zasad | hotovo | `index.ts` a `app.ts`, routy a data po slozkach, `connectors/` | | Lint a formatovani v repu | hotovo | eslint a prettier, `npm run lint` cisty bez vyjimek | | Prisny TypeScript | hotovo | `noUncheckedIndexedAccess` v obou tsconfig, zadne `!` | -| Testy | castecne | vitest v `tests/`, 12 souboru a 130 testu: prava, tickety, prilohy, kontakt, executor, sit, health | +| Nastaveni prezije nasazeni | hotovo | `seed/records.json` do prazdneho uloziste, `npm run seed:export` | +| Testy | castecne | vitest v `tests/`, 15 souboru a 149 testu: prava, tickety, prilohy, kontakt, executor, sit, health, seed, export | ## Znama omezeni @@ -99,6 +100,16 @@ v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz Rezim je jeden pro vsechna uloziste vcetne konektoru. Driv se mohlo stat, ze konektory jely z databaze a tickety ze souboru. +Nasazeni jede v rezimu `file` bez svazku, takze redeploy maze data. Nastaveni +(firmy, ucty, role, typy, akce, widgety, rozlozeni, automatizace) to prezije +pres `seed/records.json` v repu: pri startu se nasype do prazdneho uloziste +a obnovuje se prikazem `npm run seed:export`. Je to nahrada, ne reseni: +provozni data (tickety, incidenty, audit) se ztraceji dal, konektory se musi +zadat znovu (jejich klic se meni s nasazenim), zmena v portalu bez exportu +se pri dalsim nasazeni ztrati a soubor nese hashe hesel, takze repozitar +musi zustat soukromy. Reseni je `DATABASE_URL` nebo `DATA_DIR` na svazku. +Viz [14-databaze.md](14-databaze.md). + Beh automatizaci jde pres frontu a worker: webhook odpovi 202 a strom se vykona na pozadi, pri chybe se opakuje jen to, co muze pominout. Fronta je ale **v pameti jednoho procesu**: vic instanci by si vzalo tentyz beh, nad diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 78e7e9e..6cb1742 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -46,14 +46,16 @@ viz [99-zmeny.md](99-zmeny.md). | Soubor | K cemu | | -------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu; zadne `any`, zadny prazdny `catch` | +| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu, `scripts/**/*.mjs` jako Node ESM; zadne `any`, zadny prazdny `catch` | | `.prettierrc`, `.prettierignore` | jednotne formatovani (jednoduche uvozovky, sirka 100) | | `.editorconfig` | odsazeni, konce radku a kodovani pro editor | | `.nvmrc` | Node 20, stejne jako `engines` v `package.json` | | `.env.example` | vsechny promenne prostredi s popisem; `.env` neni v gitu | | `vitest.config.ts` | testy z `tests/**/*.test.ts`, alias `@shared`, `tests/setup.ts` pred kazdym souborem | | `tests/tsconfig.json` | typecheck testu nad `src/` bez emitu | -| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist` a `connectors` | +| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist`, `connectors` a `seed` | +| `seed/records.json` | nastaveni z repozitare pro prazdne uloziste, vystup `npm run seed:export`; viz [14-databaze.md](14-databaze.md) | +| `scripts/export-seed.mjs` | pomocny skript vyvoje (`npm run seed:export`): prihlasi se, stahne `GET /api/admin/export` a zapise `seed/records.json` | ## Mapa kodu - server @@ -64,7 +66,7 @@ viz [99-zmeny.md](99-zmeny.md). | `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `_BASE_URL` | | `src/openapi/index.ts` | `buildOpenApiDocument()`: sklada dokument, `servers` s prefixem proxy | | `src/openapi/helpers.ts` | `crudPaths` a opakujici se parametry, tela a odpovedi | -| `src/openapi/components/` | schemata a zabezpeceni po domenach (`common`, `auth`, `tickets`, `attachments`, `automations`, `connectors`, `scripts`, `settings`), `index.ts` je sklada | +| `src/openapi/components/` | schemata a zabezpeceni po domenach (`common`, `auth`, `tickets`, `attachments`, `automations`, `connectors`, `scripts`, `settings`, `admin`), `index.ts` je sklada | | `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `attachments`, `public`, `webhook` | | `src/types.ts` | typy uzivatele a JWT payloadu | | `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web | @@ -96,6 +98,7 @@ viz [99-zmeny.md](99-zmeny.md). | `src/routes/settings/catalog.ts` | co jde v nastaveni zvolit: prava, moduly, limity, widgety | | `src/routes/settings/shared.ts` | `memberOf` pro ucty a resitele | | `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy | +| `src/routes/admin.ts` | prepnuti na jiny ucet, audit, export nastaveni (`/api/admin/export`) | | `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP | | `src/routes/scripts.ts`, `tenantScripts.ts` | skripty konektoru a skripty firmy | | `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele | @@ -104,7 +107,8 @@ viz [99-zmeny.md](99-zmeny.md). | `src/routes/public.ts` | verejne udaje bez prihlaseni: `GET /api/public/brand` z provozovatele | | `src/routes/bodyLimit.ts` | `jsonLimitFor`, `hasOwnBodyLimit`: strop tela pro routy se soubory v base64 | | `src/ares/client.ts` | klient verejneho API ARES | -| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` | +| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores`; `seedFile.ts` (nastaveni z repa do prazdneho uloziste, `SEED_KINDS`) | +| `src/data/seedExport.ts` | `exportSeed`: vsechny zaznamy konfiguracnich druhu z ulozist pro `seed/records.json` | | `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` | | `src/data/ticketStore.ts` | fasada nad `src/data/tickets/`, importy zustavaji | | `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar, poznamka o priloze), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` | diff --git a/documentation/04-api.md b/documentation/04-api.md index bc7c017..a273534 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -99,6 +99,7 @@ Vyzaduji `Authorization: Bearer `: | POST | `/api/admin/impersonate/stop` | | GET | `/api/admin/impersonate/candidates` | | GET | `/api/admin/audit` | +| GET | `/api/admin/export` | Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni, uprava, mazani) na `/api/dashboard/settings/`, protoze ji dela jedna @@ -478,6 +479,7 @@ Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je | prilohy ticketu (POST, DELETE) | `ticket.comment` za firmu ticketu plus strop viditelnosti | | `/api/admin/impersonate*` | `impersonate` | | `/api/admin/audit` | `audit.view` | +| `/api/admin/export` | `audit.view` (vraci i hashe hesel, stejna citlivost jako audit) | | `/storage`, `/scripts` s cestami na serveru | cesty jen spravci platformy, ostatni dostanou odpoved bez nich | Spravce firmy s `user.manage` **nenastavi `platformAdmin`**, neprida clenstvi @@ -496,6 +498,20 @@ s 403. Kazde prepnuti i ukonceni je v auditu vcetne toho, kdo to byl doopravdy. Svuj puvodni token si klient odklada do `sessionStorage`, server o nem nic nevi. +## Export nastaveni + +`GET /api/admin/export` vraci `{ exportedAt, kinds: { : [zaznam] } }` +se vsemi zaznamy konfiguracnich druhu tak, jak lezi v ulozisti: firmy, ucty +(**vcetne `passwordHash`**), role, skupiny, moduly firem, typy ticketu, +akce, widgety, rozlozeni, automatizace, skripty firmy. Konektory, tickety, +incidenty, audit, upozorneni, prilohy ani pozvanky v nem nejsou. Kazdy +export je v auditu jako `admin.export` s pocty po druzich. + +Je to zdroj pro `seed/records.json`, ktery se pri startu nasype do prazdneho +uloziste; stahuje ho `npm run seed:export` (`scripts/export-seed.mjs`). +Proc a co to obnasi je v [14-databaze.md](14-databaze.md), sekce Nastaveni +v repozitari. + ## Sluzby a konektory Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API. diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index ea7261c..3571885 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -49,6 +49,7 @@ Psat do rozbiteho schematu je horsi nez psat do souboru. | `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 | | `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS | | `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor | +| `SEED_FILE` | nastaveni z repozitare pro prazdne uloziste, vychozi `./seed/records.json`. Prazdna hodnota vypne | `SECRETS_KEY` ma byt nahodny retezec, ne heslo: @@ -100,6 +101,72 @@ a nic se neztrati. Chyba cteni ale neznamena, ze data neexistuji - a start s prazdnem, ktery by je pri prvnim zapisu prepsal, je jedina cesta, jak o ne v rezimu `file` opravdu prijit. +## Nastaveni v repozitari (seed/records.json) + +Nasazeni bezi v rezimu `file` bez svazku, takze kazdy redeploy smaze data +a spravce platformy zadaval znovu firmu provozovatele, ucty, widgety +a rozlozeni. Spravne reseni je `DATABASE_URL` nebo `DATA_DIR` na svazku, +obe jsou vec infrastruktury mimo tento repozitar. Do te doby se konfigurace +drzi v repu a pri startu se nasype sama. + +**Proc soubor v repu a ne seed v kodu.** Seed v kodu je ukazka pro prazdnou +instalaci a meni se jen s kodem. Nastaveni provozovatele se meni v portalu +a nikdo ho nema prepisovat do TypeScriptu. Export je jeden prikaz a vysledek +je data, ne kod. + +| Co | Jak | +| ----------------------- | ----------------------------------------------------------------------------------------- | +| soubor | `seed/records.json`, cesta z `SEED_FILE` (`src/config.ts`), v gitu | +| tvar | `{ "exportedAt": "...", "kinds": { "": [zaznam, ...] } }`, druh je `store.kind` | +| kdy se pouzije | **jen do prazdneho uloziste**, presne tam, kde by se pouzil seed z kodu (`store.init`) | +| co vyhrava | druh v souboru ma prednost pred kodem, i kdyz je prazdny (`[]` = spravce je smazal) | +| chybejici soubor | ticho, jede se ze seedu v kodu (bezny stav pri vyvoji) | +| rozbity soubor | `console.error` a seed z kodu; nikdy nepada start | +| druh mimo konfiguraci | ignoruje se s varovanim | +| log | `[seed] : N zaznamu ze souboru` nebo `[seed] : kod`, jen u prazdneho uloziste | + +Rozhodnuti "soubor, nebo kod" je na jednom miste: `seedFromFile(kind, seed)` +v `src/data/store/seedFile.ts`, ktere vola `defineStore` v `init`. Uloziste +(`local.ts`, `postgres.ts`) o souboru nevi, `bootstrap.ts` se nemeni +a plati to pro vsechny tri rezimy vcetne `withMirror` (automatizace, +rozlozeni). Automatizace ze souboru prochazi stejnym `withDerived` jako ty +z kodu, nedodelky a pocet kroku se pri startu prepocitaji. + +**Co v souboru je**: `tenant`, `user`, `role`, `personGroup`, +`tenantFeatures`, `ticketType`, `ticketAction`, `customWidget`, +`dashboardLayout`, `automation`, `tenantScript` (seznam `SEED_KINDS`). + +**Co v nem neni a proc**: + +| Druh | Proc ne | +| ------------------------------------------------ | ------------------------------------------------------------------------- | +| tickety, incidenty, audit, upozorneni, prilohy, fronta | provozni data, vznikaji provozem; v repu nemaji co delat | +| konektory | udaje jsou sifrovane klicem, ktery se bez `SECRETS_KEY` meni s nasazenim | +| pozvanky | maji platnost a kod, po nasazeni jsou stejne prosle | + +**Hash hesla.** Export bere ucty tak, jak lezi v ulozisti, tedy vcetne +`passwordHash` (bcrypt). Bez nej by se po nasazeni nikdo neprihlasil. +Bcrypt neni plaintext, ale hash v gitu je hash v gitu: **repozitar musi +zustat soukromy** a tokeny firem (`intakeToken`, webhooky automatizaci) +jsou v nem take. Kdo to nechce, nastavi `DATABASE_URL` a soubor smaze. + +**Export**: `GET /api/admin/export` (spravce platformy, pravo `audit.view`, +audit `admin.export`) vraci stejny tvar primo z ulozist (`listAll`). +Skript `scripts/export-seed.mjs` se prihlasi a soubor zapise: + +```bash +SEED_EXPORT_URL=https://services.csbot.cz/apps/csbot-prototype SEED_EXPORT_EMAIL=admin@... SEED_EXPORT_PASSWORD=... npm run seed:export +``` + +Vypise pocty po druzich, zapisuje UTF-8 bez BOM s LF. Po exportu se soubor +commitne a dalsi nasazeni z nej vyjde. Zmena v portalu, ktera se +nevyexportuje, se pri dalsim nasazeni ztrati - to je cena za chybejici +databazi, ne vlastnost seedu. + +Testy: `tests/data/seedFile.test.ts` (rezim souboru nad docasnou slozkou), +`tests/routes/adminExport.test.ts`. V testech je `SEED_FILE` prazdny, aby +nezavisely na obsahu repa. + ## Vrstvy nad ulozistem Dve obalky, kazda pro jiny druh dat (podrobne diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index 1f43542..128bcfc 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -21,6 +21,10 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `nowIso`, `minutesAgo`, `highestNumber`, `writableOrWarn` | `src/data/store/types.ts` | Drobnosti pro moduly úložišť: časová značka, čas před N minutami, nejvyšší číslo ID pro čítač, varování při zápisu do úložiště jen pro čtení. | | `mergeValues(current, patch)` | `src/data/connectors/types.ts` | Sloučení hodnot konektoru při `PATCH`: prázdný řetězec maže, chybějící klíč nechává. Jedna implementace pro soubor i Postgres. | | `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. | +| `storeByKind(kind)` | `src/data/store/index.ts` | Uloziste podle druhu z registru. Jen pro export nastaveni; modul entity si bere svoje uloziste primo. | +| `seedFromFile(kind, seed?, file?)` | `src/data/store/seedFile.ts` | Seed pro prazdne uloziste: ze `seed/records.json`, kdyz druh obsahuje, jinak z kodu. Vola `defineStore` v `init`, jinde se nevola. | +| `loadSeedFile(file)`, `SEED_KINDS`, `isSeedKind` | `src/data/store/seedFile.ts` | Nacteni a kontrola souboru (jednou za cestu, zod na obalku a `id`), seznam konfiguracnich druhu. `SEED_KINDS` je jediny seznam pro import i export. | +| `exportSeed()` | `src/data/seedExport.ts` | Vsechny zaznamy konfiguracnich druhu z ulozist ve tvaru `seed/records.json`. Pouziva `GET /api/admin/export`. | | `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. | | `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). | | `isVisible(entity, options)` | `src/data/store/types.ts` | Vidí volající tenhle záznam? Prázdný seznam firem znamená "nic", ne "vše". | diff --git a/documentation/19-kapacita-200-firem.md b/documentation/19-kapacita-200-firem.md index 8fed481..d1e0bf1 100644 --- a/documentation/19-kapacita-200-firem.md +++ b/documentation/19-kapacita-200-firem.md @@ -158,3 +158,138 @@ akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila. Na jednu firmu s desítkami ticketů denně je současný stav použitelný. Na 200 firem ne. + +## Měření 2026-09-09 + +Dvě sady měření, obě spustitelné znovu: + +- **`npm run test:perf`** - výkonové testy v procesu (`tests/perf/`, konfigurace + `vitest.perf.config.ts`). Postaví si v paměti 200 firem, 1 000 účtů, + 50 vlastních rolí a 10 000 ticketů a měří jednotlivé funkce bez HTTP. + Každý scénář se měří třikrát a bere se medián; rozpočet je 4x až 5x nad + hodnotou z tohoto měření, aby test nepadal na pomalejším stroji, ale + chytil řádový propad. Do `npm test` tyto testy nepatří (vitest.config.ts + je vylučuje), trvají zhruba 12 s. +- **`npm run load`** - zátěžový test přes HTTP (`scripts/load-test.mjs`). + Virtuální uživatelé dělají to, co prohlížeč po otevření přehledu: práva, + seznam ticketů, detail, souhrn, data widgetů výchozího rozložení, lidé. + Každý desátý drží otevřený živý stream (SSE). Proměnné `LOAD_URL`, + `LOAD_USERS`, `LOAD_DURATION_SEC`, `LOAD_EMAIL`, `LOAD_PASSWORD`; + `LOAD_WEBHOOK_TOKEN` (a `LOAD_WEBHOOK_EVENTS`, výchozí 2 000) přidá dávku + na `/webhook/` a čeká, až fronta doběhne; `LOAD_START_LOCAL=1` + spustí `node dist/index.js` na volném portu s dočasným `DATA_DIR` + (režim souboru) a na konci ho ukončí. Výsledek jde na terminál a do + `load-report.json` v pracovním adresáři (je v `.gitignore`). Přihlášení + je jedno pro všechny uživatele, protože server pouští z jedné adresy + 20 přihlášení za čtvrt hodiny. + +Měřeno na vývojovém stroji (Windows, Node 22), tedy horní hranice latence. + +### V procesu (`npm run test:perf`) + +| Scénář | Medián | Rozpočet | Poznámka | +| ---------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------- | +| Založení 10 000 ticketů (`createTicket` + `intakeEvent`) | 337 ms | - | 29 700 ticketů/s včetně zápisu do úložiště | +| `listTickets` jedné firmy nad 10 000 tickety, 100x | 17 ms | 100 ms | 0,17 ms na volání, filtr jde přes celé pole | +| `listTickets` s filtrem stav+kanál+řešitel a stránkou, 100x | 14 ms | 100 ms | | +| `getWorkload`, 100x | 20 ms | 100 ms | | +| `getAgentStats` za 30 dní, 100x | 24 ms | 120 ms | | +| `intakeEvent` 1 000 událostí na existující tickety | 13 ms | 80 ms | 78 500 událostí/s, polovina jako opakování | +| `findByExternalId`, 10 000x | 12 ms | 60 ms | index, ne pole | +| `permissionsOf` studené, 1 997 členství | 11 ms | 60 ms | po zahození cache | +| `hasPermission`, 100 000x | 24 ms | 120 ms | 4,2 mil. volání/s z cache | +| `accessFor`, 10 000x | 49 ms | 250 ms | 205 000 volání/s | +| `accessFor` správce platformy přes 200 firem, 1 000x | 91 ms | 400 ms | 0,09 ms na volání, řadí 200 firem `localeCompare` | +| `runFlow` 100 běhů (podmínka + smyčka 200 x 4 kroky) | 229 ms | 1 000 ms | 350 000 kroků/s, kroky jsou atrapy | +| `enqueue` 5 000 běhů | 84 ms | 1 000 ms | 59 000 běhů/s | +| `claimBatch` + `markDone` 5 000 běhů po 4 | 486 ms | 2 000 ms | 10 300 běhů/s, worker bez práce | +| `enqueue` s klíčem proti dvojímu zařazení nad 5 000 čekajícími, 1 000x | 32 ms | 150 ms | lineární hledání klíče | +| `POST /widget-data` s 24 dlaždicemi nad 10 000 tickety | 6,6 ms | 30 ms | přes supertest, včetně autorizace | +| `POST /widget-data` výchozí rozložení (2 dlaždice) | 2,7 ms | 15 ms | | +| `publish` 1 000 událostí pro 500 posluchačů | 11 ms | 60 ms | 0,02 us na doručení, cena na klienta neroste | + +Co z toho plyne: + +- Datová vrstva v paměti je při 10 000 ticketech **rychlá**: výpis 0,17 ms, + přehled s 24 dlaždicemi 7 ms. Lineární průchod polem je při tomto objemu + levný, protože filtr na firmu je jedno porovnání řetězce na ticket. + Problém z kapitoly výše (miliony ticketů) tím nezmizel, jen začíná + o dva řády dál, než se odhadovalo. +- Práva jsou zadarmo: cache za dvojici účet a firma drží 5 s a studený + výpočet je 6 us. `accessFor` se počítá jednou za request (`attachAccess`) + a stojí 5 us, u správce platformy 90 us kvůli řazení 200 firem. +- Fronta a executor stíhají řádově tisíce běhů za sekundu, když kroky + nečekají na cizí službu. Skutečný strop je jinde, viz zátěž níže. +- Sběrnice událostí doručí 1 000 událostí 500 klientům za 11 ms. Při 500 + posluchačích ale Node vypíše `MaxListenersExceededWarning`, protože + `bus.ts` nastavuje strop 200. Je to jen varování, doručení funguje; + při větším počtu spojení je třeba strop zvednout nebo klienty sdružit. + +### Zátěž přes HTTP (`npm run load`) + +`LOAD_START_LOCAL=1 LOAD_USERS=100 LOAD_DURATION_SEC=20 LOAD_WEBHOOK_TOKEN=... npm run load`, +sestavená aplikace v režimu souboru s ukázkovými daty (`SEED_DEMO=1`, tedy +desítky ticketů, ne tisíce). Sto virtuálních uživatelů bez prodlevy mezi +požadavky, 10 otevřených streamů, bez databáze, jeden proces. + +| Endpoint | Požadavků | Chyb | p50 ms | p95 ms | p99 ms | max ms | +| -------------------------------------- | --------- | ---- | ------ | ------ | ------ | ------ | +| `GET /api/dashboard/access` | 9 500 | 0 | 31,7 | 35,0 | 42,1 | 69,1 | +| `GET /api/dashboard/tickets?limit=50` | 9 500 | 0 | 34,1 | 38,3 | 41,9 | 76,4 | +| `GET /api/dashboard/tickets/:id` | 9 500 | 0 | 35,4 | 38,8 | 42,5 | 59,5 | +| `GET /api/dashboard/summary` | 9 500 | 0 | 36,6 | 40,0 | 43,1 | 49,2 | +| `POST /api/dashboard/widget-data` | 9 500 | 0 | 39,1 | 42,1 | 44,9 | 49,5 | +| `GET /api/dashboard/people` | 9 500 | 0 | 35,5 | 39,7 | 41,3 | 45,1 | +| `POST /webhook/:token` (dávka po běhu) | 2 000 | 0 | 16,0 | 20,3 | 24,9 | 28,8 | + +Celkem **57 000 požadavků za 20 s = 2 830 req/s, 0 chyb**. SSE: 10 spojení, +41 730 přijatých událostí, 0 chyb. Paměť serveru (RSS): nejvýš 198 MB, +na konci 116 MB. + +Co ta čísla znamenají: + +- Latence 32 až 39 ms při 100 souběžných uživatelích je **čekání ve frontě + jednoho vlákna**, ne práce: 100 uživatelů / 2 830 req/s dává 35 ms. + Server sám stráví na požadavku zhruba 0,35 ms včetně JWT, výpočtu práv + a logu na stdout. S 10 uživateli by p50 bylo pod 5 ms. +- Rozdíl mezi endpointy je malý, protože ukázková data jsou malá. Objemová + stránka je v tabulce výše: seznam nad 10 000 tickety přidá 0,17 ms, + přehled s 24 dlaždicemi 7 ms. +- `/health` paměť nevystavuje; skript ji čte ze systému jen u lokálně + spuštěného serveru. 198 MB při 100 uživatelích a 10 streamech je + v pořádku, špička byla během dávky webhooku (zápis ticketů do souboru). + +**Dávka 2 000 událostí na webhook:** přijato 2 000 za 1,7 s (server odpoví +202 a zařadí do fronty), ale fronta se **nevyprázdnila ani za 180 s**: +hotovo 724, tedy **4 běhy za sekundu**. To není úložiště - v procesu zvládá +`claimBatch` + `markDone` 10 000 běhů/s - ale smyčka workeru +(`src/runtime/worker.ts`, funkce `loop`): když jsou všechna čtyři místa +(`CONCURRENCY`) obsazená, `claimBatch` vrátí prázdno a smyčka spí `IDLE_MS` +(1 s), i když se místo uvolní za pár milisekund. Strop je proto +`CONCURRENCY / IDLE_MS` = 4 běhy/s bez ohledu na to, jak rychlé kroky jsou. +Při 200 firmách a 100 bězích denně na automatizaci (7 za sekundu v průměru, +100 ve špičce) fronta poroste. Oprava je čekat na uvolnění místa +(`Promise.race` nad běžícími běhy), ne na časovač; spát jen když je fronta +opravdu prázdná. + +**Po opravě (tentýž den):** worker při plném bazénu čeká na první dokončený +běh. Kontrolní měření s dávkou 500 událostí (`LOAD_USERS=20`, +`LOAD_DURATION_SEC=10`): přijato za 251 ms, **fronta prázdná za 1,3 s, tedy +395 běhů za sekundu** (hotovo 500, selhalo 0), při souběžných 2 825 +dotazech za sekundu od dvaceti uživatelů. Strop už neurčuje smyčka, ale +délka kroků a `CONCURRENCY`. + +### Co se změní na Postgresu + +- Zápis ticketu je jeden řádek, ne přepis celého `ticket.json`. Špička + paměti při dávce webhooku (198 MB) tím spadne, protože `snapshot.ts` + kopíruje při každém `save` celé pole záznamů, i když zápis na disk je + sloučený. +- `listTickets` přestane být průchod polem v paměti a stane se dotazem + s indexem: 0,17 ms nad 10 000 tickety se vymění za jednotky milisekund + bez ohledu na objem, tedy u malých dat pomaleji, u milionů jedině možné. +- `claimBatch` musí jít přes `SELECT ... FOR UPDATE SKIP LOCKED` (víc + workerů). Strop 4 běhy/s ze smyčky workeru se tím **neopraví**, ten je + v kódu smyčky, ne v úložišti. +- Práva, přístup a sběrnice se nemění: kopie konfigurace v paměti zůstává, + jen se obnovuje přes `LISTEN/NOTIFY`. diff --git a/documentation/20-fronta-a-runtime.md b/documentation/20-fronta-a-runtime.md index 1838cf9..d2199fe 100644 --- a/documentation/20-fronta-a-runtime.md +++ b/documentation/20-fronta-a-runtime.md @@ -92,6 +92,13 @@ adresa včetně domény**. na ostatni. Prvni verze brala davku a cekala, az dobehne cela: jeden pomaly krok MCP na deset minut blokoval tri prazdne sloty. +Kdyz jsou vsechna mista obsazena, worker **ceka na prvni dokonceny beh** +(`Promise.race` nad bezicimi promisy), ne na casovac. Spi (`IDLE_MS`, 1 s) +jen kdyz je fronta prazdna. Verze se spanim i pri plnem bazenu mela strop +presne `CONCURRENCY` behu za sekundu: zatezovy test odbavil z 2 000 udalosti +webhooku za tri minuty jen 724, i kdyz fronta sama zvlada tisice behu za +sekundu (`npm run test:perf`). + Bezici beh posila kazdou minutu **tlukot** (`touchClaim`). Za zaseknuty se povazuje az 30 minut od posledniho tlukotu (`STUCK_AFTER_MS` v `queue.ts`), ne od vzeti z fronty. Driv to bylo deset minut od vzeti, takze beh s dlouhou diff --git a/documentation/22-znacka-a-design.md b/documentation/22-znacka-a-design.md index e8dc6d5..5aa8a45 100644 --- a/documentation/22-znacka-a-design.md +++ b/documentation/22-znacka-a-design.md @@ -199,8 +199,16 @@ Rozmazani patri jen tam, kde pod nim neco opravdu projizdi: | -------------------------- | ----------------- | | lepici hlavicka dashboardu | `backdrop-blur-xl` | | lepici hlavicka webu | `backdrop-blur-xl` | -| podklad modalu | `backdrop-blur-sm` | -| prekryv menu na mobilu | `backdrop-blur-sm` | +| prekryv menu na verejnem webu | `backdrop-blur-xl` | + +Podklad modalu a prekryv menu v portalu rozmazani **nemaji**: je to vrstva +pres celou obrazovku a pod ni lezi hlavicka, ktera rozmazana je. Kazda +animace pod dialogem (pulz tecky "Zive", spinner, obnova seznamu) pak +nutila prohlizec prepocitat rozmazani cele plochy, jeste pres to druhe - +a vedle portalu sekalo video pri kazdem otevrenem dialogu. Tmavy prekryv +s vyssim krytim (`bg-ink-950/85`) dela tutez praci zadarmo. Ze stejneho +duvodu tecka "Zive" nepulzuje: pulz ma jen stav, kdy se spojeni teprve +navazuje nebo obnovuje. **Zadna nekonecna animace nad velkou rozmazanou plochou.** Zare na prihlaseni a na webu jsou staticke: animovat pruhlednost objektu s `blur(120px)` znamena diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 3b0332c..52a78dc 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,87 @@ Nejnovejsi nahore. +## 2026-09-09 - Vykonnostni a zatezove testy, worker uz nespi pri plnem bazenu + +Aplikace ma zvladat stovky uzivatelu a tisice kroku automatizaci, ale merit +to nebylo cim. Pribyly dve sady: + +- `npm run test:perf` (`tests/perf/*`, `vitest.perf.config.ts`): horka + mista v procesu nad syntetickymi daty (200 firem, 1 000 uctu, 10 000 + ticketu): seznamy a filtry ticketu, vytizeni a statistiky, prijem udalosti + s deduplikaci, prava a pristup, beh stromu se smyckou, fronta (zarazeni + a odbaveni 5 000 behu), data widgetu pro 24 dlazdic, sbernice udalosti pro + 500 klientu. Kazdy test tiskne median ze tri behu a hlida rozpocet 3 az 5x + nad zmerenym casem, aby nekolisal. Mimo `npm test`, bezi na vyzadani. +- `npm run load` (`scripts/load-test.mjs`, bez zavislosti): zatez proti + bezici instanci (lokalni build i Postgres): virtualni uzivatele s realnou + smesi dotazu, otevrene SSE, volitelne davka udalosti webhooku a mereni, + jak rychle ji fronta odbavi. Vystup p50/p95/p99 na endpoint, propustnost, + chyby, `load-report.json`. Cisla z prvniho mereni jsou + v [19-kapacita-200-firem.md](19-kapacita-200-firem.md): 100 uzivatelu, + 2 800 dotazu za sekundu, 0 chyb, p99 pod 45 ms v rezimu souboru. + +Mereni naslo skutecnou brzdu: `worker.ts` po obsazeni vsech ctyr mist spal +sekundu (`IDLE_MS`) a pak teprve zkusil frontu znovu, takze strop byl presne +4 behy za sekundu bez ohledu na to, jak rychle behy konci. Davka 2 000 +udalosti webhooku se za tri minuty odbavila jen z 724. Ted worker pri plnem +bazenu ceka na prvni dokonceny beh (`Promise.race` nad bezicimi) a spi jen +pri prazdne fronte. Strop posluchacu sbernice zvednut z 200 na 2 000 +(`MAX_STREAM_LISTENERS`), 500 otevrenych portalu uz nehlasi varovani. + +Sekani videa vedle portalu s tim nesouviselo: bylo to rozmazani cele +obrazovky pod dialogem, viz zaznam nize. + +## 2026-09-09 - Nastaveni prezije nasazeni: seed z repozitare + +Nasazeni bezi v rezimu `file` bez svazku a bez `DATABASE_URL`, takze kazdy +redeploy smazal data a spravce platformy zadaval znovu provozovatele, ucty, +widgety a rozlozeni. Spravne reseni je databaze nebo svazek (infrastruktura +mimo repo). Do te doby se konfigurace drzi v repu a pri startu se nasype sama. + +| Co | Jak | +| ------- | -------------------------------------------------------------------------------------------- | +| soubor | `seed/records.json` (`SEED_FILE`, vychozi `./seed/records.json`, prazdne = vypnuto) | +| tvar | `{ exportedAt, kinds: { : [zaznam] } }`, druh je `store.kind` | +| druhy | `tenant`, `user`, `role`, `personGroup`, `tenantFeatures`, `ticketType`, `ticketAction`, `customWidget`, `dashboardLayout`, `automation`, `tenantScript` (`SEED_KINDS`) | +| kdy | jen do prazdneho uloziste, na stejnem miste jako seed z kodu; druh v souboru vyhrava, i prazdny | +| kde | `seedFromFile(kind, seed)` v `src/data/store/seedFile.ts`, vola ho `defineStore` v `init`; uloziste ani `bootstrap.ts` se nemeni | +| chyby | chybejici soubor ticho, rozbity soubor `console.error` a kod, cizi druh varovani | +| export | `GET /api/admin/export` (spravce platformy, `audit.view`, audit `admin.export`), `exportSeed` v `src/data/seedExport.ts` | +| skript | `npm run seed:export` (`scripts/export-seed.mjs`, cisty Node): `SEED_EXPORT_URL`, `SEED_EXPORT_EMAIL`, `SEED_EXPORT_PASSWORD` | + +Co v souboru neni: konektory (udaje sifrovane klicem, ktery se meni +s nasazenim), pozvanky (prosle), tickety, incidenty, audit, upozorneni, +prilohy, fronta. Ucty jsou v nem **vcetne `passwordHash`**, jinak by se po +nasazeni nikdo neprihlasil; repozitar proto musi zustat soukromy. + +Testy: `tests/data/seedFile.test.ts` (9, rezim souboru nad docasnou +slozkou: prazdne uloziste bere soubor, neprazdne si necha svoje, prazdny +druh vyhrava, cizi druh se ignoruje, rozbity a chybejici soubor), +`tests/routes/adminExport.test.ts` (5: 401, 403, druhy a hashe, bez +konektoru a provoznich dat, audit). V `tests/setup.ts` je `SEED_FILE` +prazdny, aby testy v pameti nezavisely na obsahu repa. Nove `scripts/**/*.mjs` +v eslintu jako Node ESM, `SEED_FILE` v `.env.example`. + +## 2026-09-09 - Otevreny dialog sekal video (podruhe) + +Po predchozi oprave (`.glass` bez rozmazani) zustalo rozmazani na podkladu +modalu a na prekryvu menu v portalu. Obe vrstvy jsou pres celou obrazovku +a pod nimi lezi lepici hlavicka, ktera je rozmazana taky. V hlavicce navic +trvale pulzovala tecka "Zive" (`animate-pulse`, nekonecna animace). Vysledek: +s kazdym otevrenym dialogem prohlizec 60x za sekundu prepocitaval rozmazani +cele plochy, vnorene dvakrat, a vedle portalu sekalo video. + +Podklad modalu i prekryv menu jsou ted jen tmava plocha bez `backdrop-blur` +(`Modal.tsx`, `DashboardLayout.tsx`). Tecka "Zive" pulzuje jen pri +navazovani a obnove spojeni, ve stavu "Zive" stoji (`LiveIndicator.tsx`). +Rozmazani zustava jen na lepicich hlavickach a na menu verejneho webu, +viz [22-znacka-a-design.md](22-znacka-a-design.md). + +Bublina o udalosti (`EventToasts.tsx`) drzi pul sekundy misto sesti +(rozhodnuti zadavatele): je to zablesk "neco se stalo", podrobnosti jsou +v seznamu, na ktery ukazuje, a cislo u zalozky zustava. + ## 2026-09-09 - Kolacovy graf ve vlastnim widgetu Vlastni widget s kreslenim "Graf" bral jen casovou radu; pokus o graf diff --git a/eslint.config.js b/eslint.config.js index 61f1d6a..31b5ff0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,11 @@ export default tseslint.config( files: ['connectors/**/*.js'], languageOptions: { sourceType: 'module', globals: {} }, }, + { + // Pomocne skripty vyvoje (`npm run seed:export`): obycejny Node ESM. + files: ['scripts/**/*.mjs'], + languageOptions: { sourceType: 'module', globals: globals.node }, + }, { rules: { '@typescript-eslint/no-explicit-any': 'error', diff --git a/package.json b/package.json index 1ddc205..d5b2c26 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", + "test:perf": "vitest run --config vitest.perf.config.ts", + "load": "node scripts/load-test.mjs", + "seed:export": "node scripts/export-seed.mjs", "test:watch": "vitest" }, "dependencies": { diff --git a/scripts/export-seed.mjs b/scripts/export-seed.mjs new file mode 100644 index 0000000..bcc1db0 --- /dev/null +++ b/scripts/export-seed.mjs @@ -0,0 +1,89 @@ +// @ts-check +/** + * Stahne nastaveni z bezici instance do `seed/records.json`. + * + * Prihlasi se jako spravce platformy, zavola `GET /api/admin/export` a vysledek + * ulozi do repozitare. Soubor se pri dalsim nasazeni nasype do prazdneho + * uloziste, viz documentation/14-databaze.md. Obycejny Node bez zavislosti, + * aby sel spustit i bez `npm ci` na vyvojovem stroji. + * + * Pouziti: + * SEED_EXPORT_URL=https://services.csbot.cz/apps/csbot-prototype \ + * SEED_EXPORT_EMAIL=admin@... SEED_EXPORT_PASSWORD=... npm run seed:export + * + * Cil se da prepsat promennou SEED_FILE (stejna, kterou cte server). + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const target = process.env.SEED_FILE || path.join(root, 'seed', 'records.json'); + +/** + * Promenna prostredi, bez ktere skript nema smysl. + * @param {string} name + * @returns {string} + */ +function required(name) { + const value = (process.env[name] ?? '').trim(); + if (value === '') { + console.error(`[seed:export] chybi promenna ${name}`); + process.exit(1); + } + return value; +} + +const baseUrl = required('SEED_EXPORT_URL').replace(/\/+$/, ''); +const email = required('SEED_EXPORT_EMAIL'); +const password = required('SEED_EXPORT_PASSWORD'); + +/** + * Volani API. Chyba nese cele telo odpovedi, protoze prave tam je napsane proc. + * @param {string} route + * @param {RequestInit} init + * @returns {Promise} + */ +async function call(route, init) { + const response = await fetch(`${baseUrl}${route}`, init); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${init.method ?? 'GET'} ${route} vratil ${response.status}: ${text}`); + } + return JSON.parse(text); +} + +async function main() { + const login = await call('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (typeof login.token !== 'string') { + throw new Error('prihlaseni nevratilo token'); + } + + const data = await call('/api/admin/export', { + headers: { authorization: `Bearer ${login.token}` }, + }); + if (!data || typeof data.kinds !== 'object') { + throw new Error('export nevratil pole kinds'); + } + + await fs.mkdir(path.dirname(target), { recursive: true }); + // LF a bez BOM: BOM v JSONu rozbije Node, CRLF by delalo sum v diffu. + const json = JSON.stringify(data, null, 2).replace(/\r\n/g, '\n') + '\n'; + await fs.writeFile(target, json, { encoding: 'utf8' }); + + console.log(`[seed:export] ${target} (${data.exportedAt})`); + for (const [kind, records] of Object.entries(data.kinds)) { + const count = Array.isArray(records) ? records.length : 0; + console.log(` ${kind.padEnd(16)} ${count}`); + } +} + +main().catch((err) => { + console.error('[seed:export] selhalo:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/scripts/load-test.mjs b/scripts/load-test.mjs new file mode 100644 index 0000000..65f314f --- /dev/null +++ b/scripts/load-test.mjs @@ -0,0 +1,741 @@ +// @ts-check +/** + * Zatezovy test portalu pres HTTP. + * + * Virtualni uzivatele se prihlasi a v smycce delaji to, co dela prohlizec + * po otevreni prehledu: prava, seznam ticketu, detail, souhrn, data widgetu + * a lide. Kazdy desaty uzivatel drzi otevreny zivy stream (SSE). Na konci + * se vypise tabulka s p50/p95/p99 za endpoint, propustnost a chybovost + * a totez se zapise do `load-report.json` v pracovnim adresari (je v .gitignore). + * + * Obycejny Node bez zavislosti (`fetch`, `perf_hooks`), aby sel spustit + * i bez `npm ci`. Cisla a jak je cist jsou v documentation/19-kapacita-200-firem.md. + * + * Promenne prostredi: + * LOAD_URL adresa serveru, vychozi http://127.0.0.1:3000 + * LOAD_EMAIL, LOAD_PASSWORD ucet, vychozi demo spravce platformy + * LOAD_USERS pocet virtualnich uzivatelu, vychozi 100 + * LOAD_DURATION_SEC delka behu, vychozi 30 + * LOAD_WEBHOOK_TOKEN kdyz je nastaven, posle se navic davka udalosti na + * /webhook/ a ceka se, az fronta dobehne + * LOAD_WEBHOOK_EVENTS velikost davky, vychozi 2000 + * LOAD_START_LOCAL=1 spusti `node dist/index.js` na volnem portu s docasnym + * DATA_DIR (rezim souboru) a na konci ho ukonci + * + * Prihlaseni je jedno pro vsechny uzivatele: server pousti z jedne adresy + * 20 prihlaseni za ctvrt hodiny a sto virtualnich uzivatelu by narazilo. + */ + +import { execFile, spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// ------------------------------------------------------------------ nastaveni + +const DEFAULT_URL = 'http://127.0.0.1:3000'; +const DEFAULT_EMAIL = 'admin@automia.cz'; +const DEFAULT_PASSWORD = 'demo1234'; +const DEFAULT_USERS = 100; +const DEFAULT_DURATION_SEC = 30; +const DEFAULT_WEBHOOK_EVENTS = 2_000; + +/** Jeden zivy stream na deset uzivatelu, jako kdyz ma kazdy desaty otevreny prehled. */ +const USERS_PER_STREAM = 10; +/** Stranka seznamu ticketu jako v portalu. */ +const TICKET_PAGE_LIMIT = 50; +/** + * Dlazdice, ktere si vychozi rozlozeni pocita na serveru. Ostatni dlazdice + * vychoziho rozlozeni berou data ze souhrnu a seznamu, viz + * src/data/dashboardLayouts.ts a COMPUTED_BUILTINS v web/src/pages/dashboard/Overview.tsx. + */ +const DEFAULT_LAYOUT_WIDGET_IDS = ['list.myTickets', 'list.unassigned']; +/** Kolik udalosti webhooku leti naraz. Odesilatel (e-shop) taky neceka na odpoved. */ +const WEBHOOK_CONCURRENCY = 20; +/** Jak casto se pta na stav fronty a jak dlouho se na vyprazdneni ceka nejvys. */ +const QUEUE_POLL_MS = 500; +const QUEUE_DRAIN_TIMEOUT_MS = 180_000; +/** Cekani na start lokalniho serveru. */ +const HEALTH_WAIT_MS = 30_000; +const HEALTH_POLL_MS = 250; +/** Jak casto se odecita pamet lokalniho serveru. */ +const MEMORY_SAMPLE_MS = 2_000; +/** Kolik se ceka na ukonceni lokalniho serveru, nez se zabije natvrdo. */ +const STOP_TIMEOUT_MS = 10_000; +const REPORT_FILE = 'load-report.json'; +/** System hlasi pamet v kB, tabulka ji ukazuje v MB. */ +const KB_PER_MB = 1024; +const MS_PER_SECOND = 1_000; + +/** + * @param {string} name + * @param {number} fallback + * @returns {number} + */ +function envNumber(name, fallback) { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const settings = { + url: (process.env.LOAD_URL || DEFAULT_URL).replace(/\/+$/, ''), + email: process.env.LOAD_EMAIL || DEFAULT_EMAIL, + password: process.env.LOAD_PASSWORD || DEFAULT_PASSWORD, + users: envNumber('LOAD_USERS', DEFAULT_USERS), + durationSec: envNumber('LOAD_DURATION_SEC', DEFAULT_DURATION_SEC), + webhookToken: (process.env.LOAD_WEBHOOK_TOKEN ?? '').trim(), + webhookEvents: envNumber('LOAD_WEBHOOK_EVENTS', DEFAULT_WEBHOOK_EVENTS), + startLocal: process.env.LOAD_START_LOCAL === '1', +}; + +// -------------------------------------------------------------------- mereni + +/** @typedef {{ count: number, errors: number, latencies: number[] }} EndpointStats */ + +/** @type {Map} */ +const endpoints = new Map(); + +/** + * @param {string} name + * @param {number} ms + * @param {boolean} ok + */ +function record(name, ms, ok) { + let stats = endpoints.get(name); + if (!stats) { + stats = { count: 0, errors: 0, latencies: [] }; + endpoints.set(name, stats); + } + stats.count += 1; + if (ok) stats.latencies.push(ms); + else stats.errors += 1; +} + +/** + * @param {number[]} sorted + * @param {number} share 0 az 1 + * @returns {number} + */ +function percentile(sorted, share) { + if (sorted.length === 0) return 0; + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * share) - 1)); + return sorted[index] ?? 0; +} + +/** + * @param {EndpointStats} stats + */ +function summarise(stats) { + const sorted = [...stats.latencies].sort((a, b) => a - b); + return { + count: stats.count, + errors: stats.errors, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99), + max: sorted[sorted.length - 1] ?? 0, + }; +} + +/** + * Jedno volani s merenim. Chyba site i odpoved mimo 2xx se pocita jako chyba + * a nevyhazuje se - jeden spadly request nesmi zastavit virtualniho uzivatele. + * @param {string} name popisek endpointu v tabulce + * @param {string} url + * @param {RequestInit} init + * @returns {Promise} telo jako JSON, nebo null pri chybe + */ +async function call(name, url, init) { + const start = performance.now(); + try { + const response = await fetch(url, init); + const body = await response.json().catch(() => null); + record(name, performance.now() - start, response.ok); + if (!response.ok) return null; + return body; + } catch (err) { + record(name, performance.now() - start, false); + noteError(name, err); + return null; + } +} + +/** @type {Map} */ +const errorKinds = new Map(); + +/** + * @param {string} name + * @param {unknown} err + */ +function noteError(name, err) { + const key = `${name}: ${err instanceof Error ? err.message : String(err)}`; + errorKinds.set(key, (errorKinds.get(key) ?? 0) + 1); +} + +// ------------------------------------------------------------ virtualni uzivatel + +/** + * @param {string} token + * @returns {Record} + */ +function authHeaders(token) { + return { Authorization: `Bearer ${token}` }; +} + +/** + * @param {string} base + * @param {string} token + * @returns {Promise} + */ +async function login(base, token) { + if (token) return token; + const response = await fetch(`${base}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: settings.email, password: settings.password }), + }); + if (!response.ok) { + throw new Error(`prihlaseni selhalo: ${response.status} ${await response.text()}`); + } + const body = /** @type {{ token: string }} */ (await response.json()); + return body.token; +} + +/** + * Smycka jednoho uzivatele: stejna posloupnost, jakou dela prehled v prohlizeci. + * @param {string} base + * @param {string} token + * @param {number} deadline performance.now(), kdy skoncit + */ +async function virtualUser(base, token, deadline) { + const headers = authHeaders(token); + const jsonHeaders = { ...headers, 'Content-Type': 'application/json' }; + /** @type {string[]} */ + let ticketIds = []; + + while (performance.now() < deadline) { + await call('GET /api/dashboard/access', `${base}/api/dashboard/access`, { headers }); + + const list = /** @type {{ items?: Array<{ id: string }> } | null} */ ( + await call( + `GET /api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`, + `${base}/api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`, + { headers }, + ) + ); + if (list?.items) ticketIds = list.items.map((item) => item.id); + + const ticketId = ticketIds[Math.floor(Math.random() * ticketIds.length)]; + if (ticketId) { + await call('GET /api/dashboard/tickets/:id', `${base}/api/dashboard/tickets/${ticketId}`, { + headers, + }); + } + + await call('GET /api/dashboard/summary', `${base}/api/dashboard/summary`, { headers }); + await call('POST /api/dashboard/widget-data', `${base}/api/dashboard/widget-data`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ widgetIds: DEFAULT_LAYOUT_WIDGET_IDS }), + }); + await call('GET /api/dashboard/people', `${base}/api/dashboard/people`, { headers }); + } +} + +// ------------------------------------------------------------------ zivy stream + +/** @typedef {{ opened: number, events: number, errors: number }} StreamStats */ + +/** + * Drzi jedno spojeni SSE az do zruseni a pocita prijate udalosti. + * @param {string} base + * @param {string} token + * @param {AbortSignal} signal + * @param {StreamStats} stats + */ +async function holdStream(base, token, signal, stats) { + try { + const response = await fetch(`${base}/api/dashboard/stream`, { + headers: authHeaders(token), + signal, + }); + if (!response.ok || !response.body) { + stats.errors += 1; + return; + } + stats.opened += 1; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + stats.events += text.split('\n').filter((line) => line.startsWith('event:')).length; + } + } catch (err) { + // Zruseni na konci behu je ocekavane, cokoliv jineho je chyba spojeni. + if (!signal.aborted) { + stats.errors += 1; + noteError('SSE /api/dashboard/stream', err); + } + } +} + +// ---------------------------------------------------------------- davka webhooku + +/** + * Stav fronty za vsechny firmy (ucet je spravce platformy, jinak jen za jeho firmy). + * @param {string} base + * @param {string} token + * @returns {Promise<{ pending: number, running: number, failed: number, done: number }>} + */ +async function queueStats(base, token) { + const response = await fetch(`${base}/api/dashboard/runs?scope=all&limit=1`, { + headers: authHeaders(token), + }); + if (!response.ok) throw new Error(`stav fronty: ${response.status}`); + const body = + /** @type {{ stats: { pending: number, running: number, failed: number, done: number } }} */ ( + await response.json() + ); + return body.stats; +} + +/** + * Telo udalosti podle kontraktu webhooku (`GET /webhook/` vraci ukazku). + * Textova pole, ktera vypadaji jako identifikator, dostanou unikatni hodnotu, + * aby kazda udalost byla nova a nesla na jeden ticket. + * @param {unknown} example + * @param {Array<{ name: string, path: string, type: string }>} expects + * @param {number} index + * @returns {Record} + */ +function eventBody(example, expects, index) { + const body = /** @type {Record} */ ( + JSON.parse(JSON.stringify(example && typeof example === 'object' ? example : {})) + ); + for (const field of expects) { + if (field.type !== 'string' || !/id$/i.test(field.name)) continue; + const segments = field.path.split('.'); + const last = segments.pop(); + if (last === undefined) continue; + let target = body; + for (const key of segments) { + const next = target[key]; + if (typeof next !== 'object' || next === null) { + target[key] = {}; + } + target = /** @type {Record} */ (target[key]); + } + target[last] = `load-${index}`; + } + return body; +} + +/** + * @param {string} base + * @param {string} token webhooku + * @param {string} auth token prihlaseni pro cteni fronty + * @param {number} count + */ +async function webhookBurst(base, token, auth, count) { + const contractResponse = await fetch(`${base}/webhook/${token}`); + if (!contractResponse.ok) { + throw new Error(`webhook ${token.slice(0, 6)}...: ${contractResponse.status}`); + } + const contract = + /** @type {{ example: unknown, expects: Array<{ name: string, path: string, type: string }> }} */ ( + await contractResponse.json() + ); + + const before = await queueStats(base, auth); + const start = performance.now(); + let accepted = 0; + let rejected = 0; + let next = 0; + + /** Jeden "odesilatel": posila dalsi a dalsi, dokud je co. */ + async function sender() { + while (next < count) { + const index = next; + next += 1; + const body = await call('POST /webhook/:token', `${base}/webhook/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eventBody(contract.example, contract.expects, index)), + }); + if (body) accepted += 1; + else rejected += 1; + } + } + await Promise.all(Array.from({ length: WEBHOOK_CONCURRENCY }, sender)); + const sentAt = performance.now(); + + // Cekani, az worker frontu vyprazdni. + let stats = await queueStats(base, auth); + while (stats.pending + stats.running > 0) { + if (performance.now() - start > QUEUE_DRAIN_TIMEOUT_MS) { + console.warn(`[load] fronta se nevyprazdnila do ${QUEUE_DRAIN_TIMEOUT_MS / MS_PER_SECOND} s`); + break; + } + await sleep(QUEUE_POLL_MS); + stats = await queueStats(base, auth); + } + const drainedAt = performance.now(); + + return { + events: count, + accepted, + rejected, + sendMs: Math.round(sentAt - start), + drainMs: Math.round(drainedAt - start), + runsPerSec: Math.round(accepted / ((drainedAt - start) / MS_PER_SECOND)), + done: stats.done - before.done, + failed: stats.failed - before.failed, + pendingAtEnd: stats.pending + stats.running, + }; +} + +// --------------------------------------------------------------- lokalni server + +/** @returns {Promise} */ +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +/** + * true = na portu nikdo neposloucha. + * @param {number} port + * @returns {Promise} + */ +function portFree(port) { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(false); + }); + socket.once('error', () => resolve(true)); + }); +} + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * @param {string} base + */ +async function waitForHealth(base) { + const deadline = performance.now() + HEALTH_WAIT_MS; + while (performance.now() < deadline) { + try { + const response = await fetch(`${base}/health`); + if (response.ok) return; + } catch { + // Server jeste neposloucha, zkusi se znovu. + } + await sleep(HEALTH_POLL_MS); + } + throw new Error(`server na ${base} nenabehl do ${HEALTH_WAIT_MS / MS_PER_SECOND} s`); +} + +/** + * Pamet procesu v MB (RSS). Server ji sam nevystavuje, tak se cte ze systemu. + * @param {number} pid + * @returns {Promise} + */ +async function residentMb(pid) { + try { + if (process.platform === 'win32') { + const { stdout } = await execFileAsync('tasklist', [ + '/FI', + `PID eq ${pid}`, + '/FO', + 'CSV', + '/NH', + ]); + const cells = stdout.trim().split(','); + const last = cells[cells.length - 1] ?? ''; + const kb = Number(last.replace(/[^0-9]/g, '')); + return Number.isFinite(kb) && kb > 0 ? Math.round(kb / KB_PER_MB) : null; + } + const status = await fs.readFile(`/proc/${pid}/status`, 'utf8'); + const match = /VmRSS:\s+(\d+)\s+kB/.exec(status); + return match ? Math.round(Number(match[1]) / KB_PER_MB) : null; + } catch (err) { + console.warn( + '[load] pamet serveru se nepodarila precist:', + err instanceof Error ? err.message : err, + ); + return null; + } +} + +/** + * Spusti sestaveny server na volnem portu s docasnym DATA_DIR (rezim souboru). + * @param {string} webhookToken + */ +async function startLocal(webhookToken) { + const port = await freePort(); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'csbot-load-')); + const entry = path.join(root, 'dist', 'index.js'); + await fs.access(entry).catch(() => { + throw new Error(`chybi ${entry}, spustte npm run build`); + }); + + /** @type {string[]} */ + const stderr = []; + const child = spawn(process.execPath, [entry], { + cwd: root, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + DATABASE_URL: '', + SEED_DEMO: '1', + JWT_SECRET: 'load-test-jwt-secret', + SECRETS_KEY: 'load-test-secrets-key', + ...(webhookToken ? { WEBHOOK_TOKEN_TEST: webhookToken } : {}), + }, + // Log serveru se zahazuje: kazdy request pise radek a stovky radku za + // sekundu by v terminalu prekryly vysledek. Chyby se drzi pro hlaseni. + stdio: ['ignore', 'ignore', 'pipe'], + }); + child.stderr?.on('data', (chunk) => { + stderr.push(String(chunk)); + if (stderr.length > 50) stderr.shift(); + }); + child.on('exit', (code) => { + if (code !== null && code !== 0) { + console.error(`[load] server skoncil s kodem ${code}\n${stderr.join('')}`); + } + }); + + const base = `http://127.0.0.1:${port}`; + try { + await waitForHealth(base); + } catch (err) { + child.kill(); + await fs.rm(dataDir, { recursive: true, force: true }); + throw err; + } + console.log(`[load] lokalni server bezi na ${base}, data v ${dataDir}`); + + return { + base, + port, + pid: child.pid ?? 0, + async stop() { + if (child.exitCode === null) { + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + const timer = sleep(STOP_TIMEOUT_MS).then(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + await Promise.race([exited, timer]); + } + await fs.rm(dataDir, { recursive: true, force: true }); + const free = await portFree(port); + console.log(`[load] server ukoncen, port ${port} ${free ? 'je volny' : 'JE STALE OBSAZENY'}`); + return free; + }, + }; +} + +// ---------------------------------------------------------------------- vystup + +/** + * @param {Array>} rows + */ +function printTable(rows) { + const widths = rows.reduce( + (acc, row) => row.map((cell, index) => Math.max(acc[index] ?? 0, cell.length)), + /** @type {number[]} */ ([]), + ); + for (const row of rows) { + console.log(row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' ')); + } +} + +/** + * @param {number} value + */ +function ms(value) { + return value.toFixed(1); +} + +// ------------------------------------------------------------------------ beh + +async function main() { + /** @type {Awaited> | null} */ + let local = null; + let portFreed = true; + + try { + if (settings.startLocal) local = await startLocal(settings.webhookToken); + const base = local ? local.base : settings.url; + + const token = await login(base, ''); + console.log( + `[load] ${settings.users} uzivatelu, ${settings.durationSec} s, ${Math.ceil(settings.users / USERS_PER_STREAM)} streamu, cil ${base}`, + ); + + /** @type {number[]} */ + const memorySamples = []; + const memoryTimer = local + ? setInterval(() => { + void residentMb(local.pid).then((value) => { + if (value !== null) memorySamples.push(value); + }); + }, MEMORY_SAMPLE_MS) + : null; + + const streamAbort = new AbortController(); + /** @type {StreamStats} */ + const streams = { opened: 0, events: 0, errors: 0 }; + const streamCount = Math.ceil(settings.users / USERS_PER_STREAM); + const streamTasks = Array.from({ length: streamCount }, () => + holdStream(base, token, streamAbort.signal, streams), + ); + + const startedAt = performance.now(); + const deadline = startedAt + settings.durationSec * MS_PER_SECOND; + await Promise.all( + Array.from({ length: settings.users }, () => virtualUser(base, token, deadline)), + ); + const elapsedMs = performance.now() - startedAt; + + let webhook = null; + if (settings.webhookToken) { + console.log(`[load] davka ${settings.webhookEvents} udalosti na webhook`); + webhook = await webhookBurst(base, settings.webhookToken, token, settings.webhookEvents); + } + + streamAbort.abort(); + await Promise.all(streamTasks); + if (memoryTimer) clearInterval(memoryTimer); + if (local) memorySamples.push((await residentMb(local.pid)) ?? 0); + + // ---------------------------------------------------------------- souhrn + /** @type {Record>} */ + const perEndpoint = {}; + let requests = 0; + let errors = 0; + for (const [name, stats] of endpoints) { + perEndpoint[name] = summarise(stats); + // Davka webhooku se do propustnosti smycky uzivatelu nepocita, bezela az po ni. + if (name.startsWith('POST /webhook')) continue; + requests += stats.count; + errors += stats.errors; + } + const seconds = elapsedMs / MS_PER_SECOND; + const total = { + requests, + errors, + errorRate: requests === 0 ? 0 : Math.round((errors / requests) * 10_000) / 100, + seconds: Math.round(seconds * 10) / 10, + requestsPerSec: Math.round(requests / seconds), + }; + const memory = + memorySamples.length > 0 + ? { + rssMaxMb: Math.max(...memorySamples), + rssLastMb: memorySamples[memorySamples.length - 1] ?? 0, + } + : null; + + console.log(''); + printTable([ + ['Endpoint', 'pozadavku', 'chyb', 'p50 ms', 'p95 ms', 'p99 ms', 'max ms'], + ...Object.entries(perEndpoint).map(([name, row]) => [ + name, + String(row.count), + String(row.errors), + ms(row.p50), + ms(row.p95), + ms(row.p99), + ms(row.max), + ]), + ]); + console.log(''); + console.log( + `Celkem ${total.requests} pozadavku za ${total.seconds} s = ${total.requestsPerSec} req/s, ` + + `chyb ${total.errors} (${total.errorRate} %)`, + ); + console.log( + `SSE: ${streams.opened} spojeni z ${streamCount}, prijato ${streams.events} udalosti, chyb ${streams.errors}`, + ); + if (memory) + console.log( + `Pamet serveru (RSS): max ${memory.rssMaxMb} MB, na konci ${memory.rssLastMb} MB`, + ); + else console.log('Pamet serveru: /health ji nevystavuje, jen cisla z klienta'); + if (webhook) { + console.log( + `Webhook: ${webhook.accepted} prijato, ${webhook.rejected} odmitnuto, odeslano za ${webhook.sendMs} ms, ` + + `fronta prazdna za ${webhook.drainMs} ms = ${webhook.runsPerSec} behu/s ` + + `(hotovo ${webhook.done}, selhalo ${webhook.failed}, zbyva ${webhook.pendingAtEnd})`, + ); + } + if (errorKinds.size > 0) { + console.log('Chyby:'); + for (const [kind, count] of errorKinds) console.log(` ${count}x ${kind}`); + } + + const reportPath = path.join(process.cwd(), REPORT_FILE); + await fs.writeFile( + reportPath, + JSON.stringify( + { + at: new Date().toISOString(), + url: base, + local: local !== null, + users: settings.users, + durationSec: settings.durationSec, + endpoints: perEndpoint, + total, + sse: { ...streams, wanted: streamCount }, + server: { memory: memory ?? null, health: 'bez pameti, jen uptime' }, + webhook, + errors: Object.fromEntries(errorKinds), + }, + null, + 2, + ), + 'utf8', + ); + console.log(`[load] zprava zapsana do ${reportPath}`); + } finally { + if (local) portFreed = await local.stop(); + } + + if (!portFreed) process.exitCode = 1; +} + +main().catch((err) => { + console.error('[load] selhalo:', err instanceof Error ? (err.stack ?? err.message) : err); + process.exit(1); +}); diff --git a/seed/records.json b/seed/records.json new file mode 100644 index 0000000..a3b2952 --- /dev/null +++ b/seed/records.json @@ -0,0 +1,383 @@ +{ + "exportedAt": "2026-09-09T18:11:26.935Z", + "source": "https://services.csbot.cz/apps/csbot-prototype (rucni export pres API, pred prvnim nasazenim seed:export)", + "kinds": { + "tenant": [ + { + "tenantId": null, + "note": "", + "enabled": true, + "helpdeskProviderId": null, + "createdAt": "2026-09-09T17:35:53.410Z", + "updatedAt": "2026-09-09T18:05:08.910Z", + "id": "tnt_automia", + "name": "Automia", + "intakeToken": "Cr5Zyb2V37JxXRavmziSJbVvE7YgBizF", + "portalOperator": true + }, + { + "tenantId": null, + "note": "", + "enabled": true, + "helpdeskProviderId": "tnt_automia", + "createdAt": "2026-09-09T17:35:53.410Z", + "updatedAt": "2026-09-09T17:35:53.410Z", + "id": "tnt_nordis", + "name": "Nordis a.s.", + "intakeToken": "MdbTrIVAbP0XyDf_O6e4KfxZuNZT6YOM" + }, + { + "tenantId": null, + "note": "", + "enabled": true, + "helpdeskProviderId": "tnt_automia", + "createdAt": "2026-09-09T17:35:53.410Z", + "updatedAt": "2026-09-09T17:35:53.410Z", + "id": "tnt_logitrans", + "name": "LogiTrans", + "intakeToken": "MYYSQJvAikHV0jEIlONnporiVehP9tzk" + } + ], + "role": [ + { + "tenantId": null, + "system": true, + "createdAt": "2026-09-09T17:35:53.502Z", + "updatedAt": "2026-09-09T17:35:53.502Z", + "id": "role_admin", + "key": "admin", + "name": "Správce firmy", + "description": "Vidí a nastavuje všechno ve své firmě.", + "permissions": [ + "ticket.view", + "ticket.create", + "ticket.comment", + "ticket.assign.self", + "ticket.assign.others", + "ticket.assign.group", + "ticket.status.change", + "ticket.priority.change", + "ticket.type.change", + "ticket.reopen", + "ticket.tag", + "ticketType.manage", + "helpdesk.view", + "helpdesk.create", + "action.manage", + "automation.edit", + "incident.manage", + "connector.manage", + "widget.manage", + "group.manage", + "people.manage", + "user.manage", + "role.manage" + ] + }, + { + "tenantId": null, + "system": true, + "createdAt": "2026-09-09T17:35:53.502Z", + "updatedAt": "2026-09-09T17:35:53.502Z", + "id": "role_agent", + "key": "agent", + "name": "Řešitel", + "description": "Řeší tickety. Nemůže přehazovat práci ani měnit nastavení.", + "permissions": [ + "ticket.view", + "ticket.create", + "ticket.comment", + "ticket.assign.self", + "ticket.status.change", + "ticket.tag" + ] + }, + { + "tenantId": null, + "system": true, + "createdAt": "2026-09-09T17:35:53.502Z", + "updatedAt": "2026-09-09T17:35:53.502Z", + "id": "role_accountant", + "key": "accountant", + "name": "Účetní", + "description": "Tickety kolem dokladů a akce nad nimi. Ukázka vlastní role.", + "permissions": [ + "ticket.view", + "ticket.comment", + "ticket.assign.self", + "ticket.status.change" + ] + } + ], + "ticketType": [ + { + "id": "tt_order", + "tenantId": "tnt_automia", + "key": "order", + "name": "Objednávka", + "icon": "ShoppingCart", + "statuses": [], + "fields": [ + { + "id": "fld_order_no", + "key": "orderNumber", + "label": "Číslo objednávky", + "type": "string", + "required": true + }, + { + "id": "fld_order_total", + "key": "orderTotal", + "label": "Částka", + "type": "number", + "required": false + }, + { + "id": "fld_order_partner", + "key": "partnerId", + "label": "ID odběratele v iDokladu", + "type": "number", + "required": false, + "hint": "Umí ho dohledat akce Najít kontakt." + } + ], + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z" + } + ], + "ticketAction": [ + { + "id": "tka_send_idoklad", + "tenantId": "tnt_automia", + "ticketTypeIds": [ + "tt_order" + ], + "tags": [], + "label": "Odeslat do iDokladu", + "icon": "Receipt", + "style": "primary", + "order": 1, + "visibleWhen": [ + { + "fieldId": "ticket.status", + "operator": "neq", + "value": "resolved" + } + ], + "confirm": "Objednávka se odešle do iDokladu. Pokračovat?", + "form": [], + "body": { + "kind": "operation", + "serviceId": "idoklad", + "operationId": "create-issued-invoice", + "connectorId": null, + "inputs": { + "partnerId": "{{partnerId}}", + "description": "Objednávka {{orderNumber}}", + "itemName": "Zboží dle objednávky {{orderNumber}}", + "unitPrice": "{{orderTotal}}" + } + }, + "enabled": true, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z" + } + ], + "customWidget": [ + { + "tenantId": "tnt_automia", + "ownerId": null, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z", + "id": "cw_status_month", + "name": "Tickety podle stavu", + "description": "Kolik ticketů je v kterém stavu za tento měsíc.", + "render": "table", + "source": { + "kind": "ticketCount", + "filter": { + "period": "month" + }, + "groupBy": "status" + }, + "size": "half" + }, + { + "tenantId": "tnt_automia", + "ownerId": null, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z", + "id": "cw_idoklad_invoices", + "name": "Faktury v iDokladu", + "description": "Ukázka widgetu nad konektorem. Bez napojení hlásí, co chybí.", + "render": "stat", + "source": { + "kind": "connector", + "serviceId": "idoklad", + "operationId": "find-issued-invoice", + "connectorId": null, + "inputs": {}, + "path": "total", + "ttlSec": 300 + }, + "size": "third" + }, + { + "tenantId": "tnt_automia", + "ownerId": null, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z", + "id": "cw_agents_30d", + "name": "Kdo kolik odbavil", + "description": "Vyřešené za 30 dní, doba řešení, vrácené tickety a fronta.", + "render": "table", + "source": { + "kind": "agentStats", + "period": "30d" + }, + "size": "full" + }, + { + "tenantId": "tnt_automia", + "ownerId": null, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z", + "id": "cw_orders_open", + "name": "Otevřené objednávky", + "description": "Tickety typu Objednávka, které nejsou vyřešené.", + "render": "stat", + "source": { + "kind": "ticketCount", + "filter": { + "typeIds": [ + "tt_order" + ], + "closed": false + } + }, + "size": "third" + }, + { + "tenantId": "tnt_automia", + "ownerId": null, + "createdAt": "2026-09-09T17:35:53.503Z", + "updatedAt": "2026-09-09T17:35:53.503Z", + "id": "cw_by_assignee", + "name": "Nevyřešené podle lidí", + "description": "Kdo co má u sebe. Ukázka seskupení.", + "render": "table", + "source": { + "kind": "ticketCount", + "filter": { + "closed": false + }, + "groupBy": "assignee" + }, + "size": "half" + }, + { + "name": "GRAF", + "description": "", + "render": "chart", + "source": { + "kind": "ticketCount", + "filter": { + "closed": false + }, + "groupBy": "status" + }, + "size": "half", + "ownerId": null, + "id": "cw_3d9c657c", + "tenantId": "tnt_automia", + "createdAt": "2026-09-09T17:36:33.683Z", + "updatedAt": "2026-09-09T17:36:33.683Z" + } + ], + "personGroup": [ + { + "id": "grp_servicedesk", + "tenantId": "tnt_automia", + "name": "Servicedesk", + "members": [ + { + "personId": "usr_2", + "seesAll": true + }, + { + "personId": "usr_3", + "seesAll": false + }, + { + "personId": "usr_1", + "seesAll": true + } + ], + "createdAt": "2026-09-09T17:35:53.502Z", + "updatedAt": "2026-09-09T17:39:29.830Z" + }, + { + "name": "IT", + "members": [ + { + "personId": "usr_3", + "seesAll": false + } + ], + "id": "grp_9c12684a", + "tenantId": "tnt_automia", + "createdAt": "2026-09-09T17:56:38.176Z", + "updatedAt": "2026-09-09T17:56:50.164Z" + }, + { + "name": "Vedení", + "members": [ + { + "personId": "usr_1", + "seesAll": false + } + ], + "id": "grp_6a005b53", + "tenantId": "tnt_automia", + "createdAt": "2026-09-09T17:56:44.268Z", + "updatedAt": "2026-09-09T17:56:45.740Z" + } + ], + "dashboardLayout": [ + { + "id": "usr_1:tnt_automia", + "tenantId": "tnt_automia", + "userId": "usr_1", + "items": [ + { + "id": "w1", + "widgetId": "list.myTickets", + "size": "half" + }, + { + "id": "w2", + "widgetId": "list.unassigned", + "size": "half" + }, + { + "id": "w6", + "widgetId": "list.tickets", + "size": "half" + }, + { + "id": "w7", + "widgetId": "list.incidents", + "size": "half" + }, + { + "id": "w_mtudrpj3_4", + "widgetId": "cw_3d9c657c", + "size": "full" + } + ], + "createdAt": "2026-09-09T18:11:26.935Z", + "updatedAt": "2026-09-09T18:11:26.935Z" + } + ] + } +} diff --git a/src/config.ts b/src/config.ts index c1838c1..3d049ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,13 @@ function normalizeRootPath(value: string | undefined): string { return withSlash.replace(/\/+$/, ''); } +/** Nenastavena promenna = vychozi cesta, prazdna = vypnuto (`''`). */ +function resolveSeedFile(value: string | undefined): string { + if (value === undefined) return path.join(process.cwd(), 'seed', 'records.json'); + const trimmed = value.trim(); + return trimmed === '' ? '' : path.resolve(trimmed); +} + export const config = { isProduction, /** Port urcuje AppFactory sablona, vychozi 3000. Nemenit bez upravy metadat. */ @@ -113,6 +120,19 @@ export const config = { * Prazdna hodnota vypne i tohle a jede se v ciste pameti. */ dataDir: (process.env.DATA_DIR ?? path.join(process.cwd(), 'data')).trim(), + /** + * Nastaveni z repozitare pro prazdne uloziste. + * + * Nasazeni bez databaze a bez svazku prijde pri kazdem redeployi o data + * a spravce platformy zadava firmy, ucty a widgety znovu. Soubor v repu + * (vystup `npm run seed:export`) drzi konfiguraci a pri startu se pouzije + * misto vychozi sady z kodu - **jen do prazdneho uloziste**, nikdy pres + * ziva data. Chybejici soubor je bezny stav (lokalni vyvoj). Spravne reseni + * je `DATABASE_URL`, tohle je nahrada do te doby. Prazdna hodnota soubor + * vypne uplne (stejne jako u `DATA_DIR`), testy tak nezavisi na obsahu repa. + * Viz documentation/14-databaze.md. + */ + seedFile: resolveSeedFile(process.env.SEED_FILE), // --------------------------------------------------------------- znacka diff --git a/src/data/seedExport.ts b/src/data/seedExport.ts new file mode 100644 index 0000000..5770b23 --- /dev/null +++ b/src/data/seedExport.ts @@ -0,0 +1,26 @@ +/** + * Export nastaveni do tvaru `seed/records.json`. + * + * Opacny smer k `store/seedFile.ts`: precte vsechny zaznamy konfiguracnich + * druhu primo z ulozist a slozi z nich soubor, ktery se ulozi do repozitare + * (`npm run seed:export`). Bere se `listAll`, tedy vcetne `passwordHash` + * uctu - bez nej by se po nasazeni nikdo neprihlasil. Proto musi repozitar + * zustat soukromy, viz documentation/14-databaze.md. + */ + +import { nowIso, storeByKind } from './store/index.js'; +import { SEED_KINDS, type SeedExport } from './store/seedFile.js'; + +export async function exportSeed(): Promise { + const kinds: SeedExport['kinds'] = {}; + for (const kind of SEED_KINDS) { + const store = storeByKind(kind); + if (!store) { + // Druh bez uloziste je chyba v seznamu SEED_KINDS, ne stav dat. + console.warn(`[seed] export: druh ${kind} nema uloziste, preskakuje se`); + continue; + } + kinds[kind] = await store.listAll(); + } + return { exportedAt: nowIso(), kinds }; +} diff --git a/src/data/store/index.ts b/src/data/store/index.ts index 437d689..b9c2b30 100644 --- a/src/data/store/index.ts +++ b/src/data/store/index.ts @@ -21,6 +21,7 @@ import { isDatabaseEnabled } from '../../db/pool.js'; import { canSealSecrets, initSecrets } from '../../db/secretBox.js'; import { createLocalStore } from './local.js'; import { createPostgresStore } from './postgres.js'; +import { seedFromFile } from './seedFile.js'; import type { EntityStore, TenantEntity } from './types.js'; export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js'; @@ -88,7 +89,9 @@ export function defineStore(kind: string): EntityStore = { kind, - init: (seed) => resolve().init(seed), + // Vychozi sada ze souboru v repu ma prednost pred kodem. Uplatni se + // stejne jako seed z kodu, tedy jen do prazdneho uloziste. + init: (seed) => resolve().init(seedFromFile(kind, seed)), list: (options) => resolve().list(options), listAll: () => resolve().listAll(), get: (id, options) => resolve().get(id, options), @@ -111,6 +114,11 @@ export async function flushStores(): Promise { await Promise.all([...registry.values()].map((store) => store.flush())); } +/** Uloziste podle druhu. Pro export nastaveni, jinde se bere primo z modulu entity. */ +export function storeByKind(kind: string): EntityStore | undefined { + return registry.get(kind); +} + /** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */ export function storeKinds(): string[] { return [...registry.keys()].sort(); diff --git a/src/data/store/seedFile.ts b/src/data/store/seedFile.ts new file mode 100644 index 0000000..97a9269 --- /dev/null +++ b/src/data/store/seedFile.ts @@ -0,0 +1,153 @@ +/** + * Nastaveni z repozitare (`seed/records.json`) pro prazdne uloziste. + * + * Nasazeni bez databaze a bez svazku prijde pri kazdem redeployi o vsechno, + * co spravce platformy nastavil: firmy, ucty, role, typy, akce, widgety. + * Dokud neni `DATABASE_URL` nebo `DATA_DIR` na svazku, drzi se konfigurace + * v repu a pri startu se nasype misto vychozi sady z kodu. + * + * Dve pravidla, ktera to drzi bezpecne: + * - **Jen do prazdneho uloziste.** Seed se pouzije presne tam, kde by se + * pouzil seed z kodu (`store.init(seed)`), tedy nikdy pres ziva data. + * - **Druh v souboru vyhrava cely.** Kdyz soubor druh obsahuje, plati jeho + * zaznamy, i kdyby jich bylo nula - vyexportovany prazdny seznam znamena + * "spravce je smazal", ne "doplnte vychozi". + * + * Soubor je vystup `GET /api/admin/export` (`npm run seed:export`), takze tvar + * zaznamu je tvar uloziste. Kontroluje se jen obalka a `id`; hlubsi kontrola + * je vec modulu entity, stejne jako u zaznamu nactenych ze souboru + * v `DATA_DIR`. Chybejici soubor je bezny stav, rozbity soubor se zaloguje + * a jede se ze seedu v kodu. + */ + +import fs from 'node:fs'; +import { z } from 'zod'; +import { config } from '../../config.js'; +import type { TenantEntity } from './types.js'; + +/** + * Druhy, ktere jsou konfigurace a patri do souboru. Provozni data (tickety, + * incidenty, audit, upozorneni, prilohy, fronta) ne: ta vznikaji provozem + * a v repu nemaji co delat. Konektory take ne: jejich udaje jsou sifrovane + * klicem, ktery se bez `SECRETS_KEY` meni s kazdym nasazenim. Pozvanky ne: + * maji platnost a kod, po nasazeni jsou stejne prosle. + */ +export const SEED_KINDS = [ + 'tenant', + 'user', + 'role', + 'personGroup', + 'tenantFeatures', + 'ticketType', + 'ticketAction', + 'customWidget', + 'dashboardLayout', + 'automation', + 'tenantScript', +] as const; + +export type SeedKind = (typeof SEED_KINDS)[number]; + +export function isSeedKind(kind: string): kind is SeedKind { + return (SEED_KINDS as readonly string[]).includes(kind); +} + +/** Tvar souboru i odpovedi `GET /api/admin/export`. */ +export interface SeedExport { + exportedAt: string; + kinds: Partial>; +} + +/** Kontroluje se jen obalka a `id`; zbytek zaznamu prochazi tak, jak je. */ +const recordSchema = z.object({ id: z.string().min(1) }).passthrough(); + +const seedFileSchema = z.object({ + exportedAt: z.string().optional(), + kinds: z.record(z.string(), z.array(recordSchema)), +}); + +type LoadedSeed = Map; + +/** Jednou nacteny soubor za cestu. `null` = neni, nebo je rozbity. */ +const cache = new Map(); + +/** + * Nacte a zkontroluje soubor. Vola se pri startu za kazdy druh, proto se + * vysledek drzi v pameti. Chybejici soubor je ticho, ostatni chyby se loguji. + */ +export function loadSeedFile(file: string): LoadedSeed | null { + const cached = cache.get(file); + if (cached !== undefined) return cached; + + const loaded = readSeedFile(file); + cache.set(file, loaded); + return loaded; +} + +function readSeedFile(file: string): LoadedSeed | null { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (err) { + // Bez souboru se jede ze seedu v kodu. Bezny stav pri vyvoji. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + console.error(`[seed] ${file} nelze precist:`, err instanceof Error ? err.message : err); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + console.error(`[seed] ${file} neni platny JSON:`, err instanceof Error ? err.message : err); + return null; + } + + const checked = seedFileSchema.safeParse(parsed); + if (!checked.success) { + const problems = checked.error.issues + .map((issue) => `${issue.path.join('.') || '(koren)'}: ${issue.message}`) + .join('; '); + console.error(`[seed] ${file} ma spatny tvar, jede se ze seedu v kodu: ${problems}`); + return null; + } + + const kinds: LoadedSeed = new Map(); + for (const [kind, records] of Object.entries(checked.data.kinds)) { + if (!isSeedKind(kind)) { + console.warn(`[seed] ${file}: druh ${kind} se nenacita, neni to konfigurace`); + continue; + } + kinds.set(kind, records as unknown as TenantEntity[]); + } + return kinds; +} + +/** + * Seed pro jeden druh: ze souboru, kdyz ho ma, jinak z kodu. + * + * Vraci funkci ve stejnem tvaru, jaky bere `store.init(seed)`, takze + * rozhodnuti "soubor, nebo kod" je na jednom miste a uloziste o souboru + * nevi. Funkce se zavola jen pro prazdne uloziste, proto se loguje az v ni. + * Cesta je parametr kvuli testum, jinak se bere z konfigurace. + */ +export function seedFromFile( + kind: string, + fallback?: () => T[], + file: string = config.seedFile, +): (() => T[]) | undefined { + // Prazdna cesta = soubor vypnuty konfiguraci, cte se jen kod. + const records = file !== '' && isSeedKind(kind) ? loadSeedFile(file)?.get(kind) : undefined; + if (records) { + return () => { + console.info(`[seed] ${kind}: ${records.length} zaznamu ze souboru`); + // Kopie, aby uloziste nemenilo to, co je v kesi. + return structuredClone(records) as T[]; + }; + } + if (!fallback) return undefined; + return () => { + console.info(`[seed] ${kind}: kod`); + return fallback(); + }; +} diff --git a/src/events/bus.ts b/src/events/bus.ts index 4ef61a8..894719f 100644 --- a/src/events/bus.ts +++ b/src/events/bus.ts @@ -22,9 +22,16 @@ export type { DashboardEvent, DashboardEventType, EntityEventKind, EntityEventTy const CHANNEL = 'dashboard'; const MAX_RECENT = 50; +/** + * Kazdy pripojeny klient streamu je jeden listener. Vychozich 10 je malo + * a 200 hlasilo varovani uz pri zatezovem testu s 500 klienty; varovani + * neni chyba, ale zbytecne plni log. Stovky uzivatelu s otevrenym portalem + * jsou bezny provoz, proto strop nad nimi. + */ +const MAX_STREAM_LISTENERS = 2_000; + const emitter = new EventEmitter(); -// Kazdy pripojeny klient je jeden listener, vychozich 10 je malo. -emitter.setMaxListeners(200); +emitter.setMaxListeners(MAX_STREAM_LISTENERS); const recent: DashboardEvent[] = []; diff --git a/src/openapi/components/admin.ts b/src/openapi/components/admin.ts new file mode 100644 index 0000000..9b48334 --- /dev/null +++ b/src/openapi/components/admin.ts @@ -0,0 +1,38 @@ +/** Sprava platformy: tvar exportu nastaveni. */ + +export const adminSchemas = { + SeedExport: { + type: 'object', + description: + 'Tvar `seed/records.json`. Klic v `kinds` je druh uloziste (`store.kind`), ' + + 'hodnota jsou zaznamy tak, jak lezi v ulozisti. Druhy mimo konfiguraci se ' + + 'pri nacteni ignoruji.', + required: ['exportedAt', 'kinds'], + properties: { + exportedAt: { type: 'string', format: 'date-time' }, + kinds: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + tenantId: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + additionalProperties: true, + }, + }, + example: { + tenant: [{ id: 'tnt_automia', tenantId: null, name: 'Automia s.r.o.' }], + user: [ + { id: 'usr_1', tenantId: null, email: 'admin@automia.cz', passwordHash: '$2a$10$...' }, + ], + }, + }, + }, + }, +}; diff --git a/src/openapi/components/index.ts b/src/openapi/components/index.ts index 48a38ff..2f1c6dc 100644 --- a/src/openapi/components/index.ts +++ b/src/openapi/components/index.ts @@ -11,6 +11,7 @@ import { connectorSchemas } from './connectors.js'; import { scriptSchemas } from './scripts.js'; import { ticketSchemas } from './tickets.js'; import { automationSchemas } from './automations.js'; +import { adminSchemas } from './admin.js'; export const components = { securitySchemes: { @@ -31,5 +32,6 @@ export const components = { ...loginSchemas, ...ticketSchemas, ...automationSchemas, + ...adminSchemas, }, }; diff --git a/src/openapi/paths/admin.ts b/src/openapi/paths/admin.ts index 4785f3b..fef2bef 100644 --- a/src/openapi/paths/admin.ts +++ b/src/openapi/paths/admin.ts @@ -1,6 +1,27 @@ -/** Sprava platformy: audit a prepnuti na jiny ucet. */ +/** Sprava platformy: audit, prepnuti na jiny ucet, export nastaveni. */ export const adminPaths: Record = { + '/api/admin/export': { + get: { + tags: ['Sprava platformy'], + summary: 'Export nastaveni pro seed/records.json', + description: + 'Vsechny zaznamy konfiguracnich druhu (firmy, ucty, role, skupiny, moduly, ' + + 'typy ticketu, akce, widgety, rozlozeni, automatizace, skripty firmy) tak, jak ' + + 'lezi v ulozisti, **vcetne hashu hesel**. Konektory, tickety, incidenty, audit ' + + 'a upozorneni v exportu nejsou. Vystup uklada `npm run seed:export` do ' + + '`seed/records.json`, ktery se pri startu nasype do prazdneho uloziste. ' + + 'Kazdy export je v auditu jako `admin.export`.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Nastaveni po druzich', + content: { 'application/json': { schema: { $ref: '#/components/schemas/SeedExport' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + }, + }, + }, '/api/admin/impersonate': { post: { tags: ['Sprava platformy'], diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a295baa..e5d5297 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { config } from '../config.js'; import { listAudit, recordAudit } from '../data/audit.js'; import { hasPermission } from '../data/permissions.js'; +import { exportSeed } from '../data/seedExport.js'; import { listAllUsers, findUserById } from '../data/users.js'; import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js'; @@ -174,3 +175,35 @@ adminRouter.get( }); }, ); + +// ------------------------------------------------------------------ export + +/** + * Nastaveni pro `seed/records.json`, viz `data/seedExport.ts`. + * + * Vraci i `passwordHash` uctu, proto jen spravce platformy s pravem na audit: + * je to stejna kategorie citlivosti jako zaznam o lidech. Kazdy export je + * v auditu, aby bylo videt, kdo si data odnesl. + */ +adminRouter.get( + '/export', + requirePlatformAdmin, + requirePlatformPermission('audit.view'), + async (req, res) => { + const data = await exportSeed(); + const counts = Object.fromEntries( + Object.entries(data.kinds).map(([kind, records]) => [kind, records.length]), + ); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + actedBy: req.impersonation?.actorId ?? null, + tenantId: null, + action: 'admin.export', + detail: counts, + }); + + return res.json(data); + }, +); diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts index 8add345..3ed089e 100644 --- a/src/runtime/worker.ts +++ b/src/runtime/worker.ts @@ -33,7 +33,14 @@ import { /** Kolik behu naraz. Kroky cekaji na cizi sluzby, procesor se skoro nepouzije. */ const CONCURRENCY = 4; -/** Jak casto se fronta kontroluje, kdyz je prazdna. */ +/** + * Jak casto se fronta kontroluje, kdyz je prazdna. + * + * Jen pri prazdne fronte. Kdyz jsou obsazena vsechna mista, ceka se na prvni + * uvolnene, ne na casovac: se spanim i pri plnem bazenu byl strop + * CONCURRENCY behu za sekundu (zatezovy test: 2 000 udalosti webhooku se + * odbavovalo presne 4 za sekundu, i kdyz fronta sama zvlada tisice). + */ const IDLE_MS = 1_000; /** Jak casto se uklizi hotove behy. */ @@ -55,6 +62,9 @@ let rounds = 0; /** Behy, ktere prave bezi v tomto procesu. Fronta je nesmi povazovat za zaseknute. */ const active = new Set(); +/** Tytez behy jako promisy, aby slo pockat na prvni dokonceny. */ +const inFlight = new Map>(); + /** * Spusti workera. * @@ -81,14 +91,19 @@ async function loop(): Promise { * ulohou MCP by drzel vsechna ctyri mista a fronta by stala. */ const free = CONCURRENCY - active.size; + if (free === 0) { + // Plny bazen: dalsi kolo hned, jak prvni beh skonci. Casovac je jen + // pojistka, kdyby se nejaky promise nikdy nevyridil. + await Promise.race([...inFlight.values(), sleep(IDLE_MS)]); + continue; + } + let claimed: QueueItem[] = []; - if (free > 0) { - try { - claimed = await claimBatch(free, active); - } catch (err) { - // Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu. - console.error('[worker] frontu se nepodarilo precist:', err); - } + try { + claimed = await claimBatch(free, active); + } catch (err) { + // Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu. + console.error('[worker] frontu se nepodarilo precist:', err); } if (claimed.length === 0) { @@ -98,9 +113,13 @@ async function loop(): Promise { for (const item of claimed) { active.add(item.id); - void runOne(item) + const task = runOne(item) .catch((err: unknown) => console.error(`[worker] beh ${item.id} spadl mimo pojistku:`, err)) - .finally(() => active.delete(item.id)); + .finally(() => { + active.delete(item.id); + inFlight.delete(item.id); + }); + inFlight.set(item.id, task); } rounds += 1; diff --git a/tests/data/seedFile.test.ts b/tests/data/seedFile.test.ts new file mode 100644 index 0000000..8279356 --- /dev/null +++ b/tests/data/seedFile.test.ts @@ -0,0 +1,184 @@ +/** + * Nastaveni z repozitare (src/data/store/seedFile.ts). + * + * Bezi v rezimu souboru nad docasnou slozkou, aby slo pred startem podstrcit + * neprazdne uloziste (snapshot `ticketType.json`) a vedle nej seed soubor. + * Pak se spusti stejny bootstrap jako v `src/index.ts` a overi se, co ktere + * uloziste dostalo. `DATA_DIR` a `SEED_FILE` se ctou pri importu konfigurace, + * proto dynamicke importy. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'csbot-seed-')); +const dataDir = path.join(dir, 'data'); +const seedPath = path.join(dir, 'records.json'); +process.env.DATA_DIR = dataDir; +process.env.SEED_FILE = seedPath; + +type Tenants = typeof import('../../src/data/tenants.js'); +type TicketTypes = typeof import('../../src/data/ticketTypes.js'); +type Widgets = typeof import('../../src/data/customWidgets.js'); +type Stores = typeof import('../../src/data/store/index.js'); +type SeedFile = typeof import('../../src/data/store/seedFile.js'); + +let tenants: Tenants; +let ticketTypes: TicketTypes; +let widgets: Widgets; +let stores: Stores; +let seedFile: SeedFile; +/** Co start zalogoval pres `console.warn`. */ +let warnings: string[] = []; + +const now = '2026-09-09T08:00:00.000Z'; + +function snapshot(kind: string, items: unknown[]): void { + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync( + path.join(dataDir, `${kind}.json`), + JSON.stringify({ version: 1, savedAt: now, items }, null, 2), + 'utf8', + ); +} + +function ticketType(id: string, key: string) { + return { + id, + tenantId: 'tnt_seed', + key, + name: key, + icon: 'Tag', + statuses: [], + fields: [], + createdAt: now, + updatedAt: now, + }; +} + +beforeAll(async () => { + // Typy ticketu uz v ulozisti jsou: soubor je nesmi prepsat. + snapshot('ticketType', [ticketType('tt_existing', 'existing')]); + + fs.writeFileSync( + seedPath, + JSON.stringify({ + exportedAt: now, + kinds: { + tenant: [ + { + id: 'tnt_seed', + tenantId: null, + name: 'Firma ze souboru', + note: '', + enabled: true, + intakeToken: 'seed-intake-token-0123456789', + helpdeskProviderId: null, + createdAt: now, + updatedAt: now, + }, + ], + ticketType: [ticketType('tt_fromfile', 'fromfile')], + // Prazdny seznam znamena "zadne", ne "doplnte vychozi z kodu". + customWidget: [], + // Provozni data se ze souboru nenacitaji. + ticket: [{ id: 'TK-1', tenantId: 'tnt_seed', subject: 'x' }], + neznamy: [{ id: 'x' }], + }, + }), + 'utf8', + ); + + const bootstrap = await import('../../src/data/bootstrap.js'); + tenants = await import('../../src/data/tenants.js'); + ticketTypes = await import('../../src/data/ticketTypes.js'); + widgets = await import('../../src/data/customWidgets.js'); + stores = await import('../../src/data/store/index.js'); + seedFile = await import('../../src/data/store/seedFile.js'); + + const mode = await bootstrap.bootstrapData({ databaseReady: false }); + // Volani se berou hned po startu: vitest mock mezi testy cisti. + warnings = vi.mocked(console.warn).mock.calls.map((call) => String(call[0])); + expect(mode).toBe('file'); + await stores.flushStores(); +}); + +afterAll(async () => { + await stores?.flushStores(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('seed ze souboru pri startu', () => { + test('prazdne uloziste dostane zaznamy ze souboru misto seedu z kodu', () => { + const ids = tenants.listTenants().map((tenant) => tenant.id); + expect(ids).toEqual(['tnt_seed']); + expect(tenants.findByIntakeToken('seed-intake-token-0123456789')?.name).toBe( + 'Firma ze souboru', + ); + }); + + test('neprazdne uloziste si necha svoje zaznamy', () => { + expect(ticketTypes.findTicketType('tt_existing')).toBeDefined(); + expect(ticketTypes.findTicketType('tt_fromfile')).toBeUndefined(); + }); + + test('druh v souboru vyhrava, i kdyz je prazdny', async () => { + expect(widgets.seedCustomWidgets().length).toBeGreaterThan(0); + expect(await widgets.customWidgetStore.listAll()).toEqual([]); + }); + + test('druh mimo konfiguraci se ignoruje a zaloguje', async () => { + expect(warnings.some((line) => line.includes('druh ticket se nenacita'))).toBe(true); + expect(warnings.some((line) => line.includes('druh neznamy se nenacita'))).toBe(true); + expect(await stores.storeByKind('ticket')?.listAll()).toEqual([]); + }); + + test('soubor se cte jednou a uloziste dostava kopii', () => { + const first = seedFile.seedFromFile<{ id: string; tenantId: null; createdAt: string; updatedAt: string }>( + 'tenant', + )?.(); + const second = seedFile.seedFromFile<{ id: string; tenantId: null; createdAt: string; updatedAt: string }>( + 'tenant', + )?.(); + expect(first).toEqual(second); + expect(first?.[0]).not.toBe(second?.[0]); + }); +}); + +describe('seedFromFile bez pouzitelneho souboru', () => { + const fallback = () => [{ id: 'tnt_code', tenantId: null, createdAt: now, updatedAt: now }]; + + test('chybejici soubor je ticho a jede se z kodu', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const seed = seedFile.seedFromFile('tenant', fallback, path.join(dir, 'neni.json')); + expect(seed?.()).toEqual(fallback()); + expect(errors).not.toHaveBeenCalled(); + errors.mockRestore(); + }); + + test('rozbity JSON se zaloguje a jede se z kodu', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const file = path.join(dir, 'rozbity.json'); + fs.writeFileSync(file, '{ "kinds": ', 'utf8'); + const seed = seedFile.seedFromFile('tenant', fallback, file); + expect(seed?.()).toEqual(fallback()); + expect(errors).toHaveBeenCalledWith(expect.stringContaining('neni platny JSON'), expect.anything()); + errors.mockRestore(); + }); + + test('zaznam bez id je spatny tvar a jede se z kodu', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const file = path.join(dir, 'bez-id.json'); + fs.writeFileSync(file, JSON.stringify({ kinds: { tenant: [{ name: 'x' }] } }), 'utf8'); + expect(seedFile.loadSeedFile(file)).toBeNull(); + expect(seedFile.seedFromFile('tenant', fallback, file)?.()).toEqual(fallback()); + expect(errors).toHaveBeenCalledWith(expect.stringContaining('kinds.tenant.0.id')); + errors.mockRestore(); + }); + + test('bez souboru a bez seedu z kodu neni co nasypat', () => { + expect(seedFile.seedFromFile('tenantScript', undefined, path.join(dir, 'neni.json'))).toBeUndefined(); + }); +}); diff --git a/tests/perf/executor.perf.test.ts b/tests/perf/executor.perf.test.ts new file mode 100644 index 0000000..7dd6e11 --- /dev/null +++ b/tests/perf/executor.perf.test.ts @@ -0,0 +1,256 @@ +/** + * Runtime pod zatezi: vykonavani stromu (src/runtime/executor.ts) a fronta + * behu (src/runtime/queue.ts). + * + * Strom: podminka a smycka pres 200 polozek se ctyrmi vnitrnimi kroky, tedy + * 802 vykonanych kroku na beh, 100 behu. Vnitrni kroky, skripty a konektory + * jsou atrapy jako v tests/runtime/executor.test.ts - meri se executor, + * ne cizi sluzba. + * + * Fronta: 5 000 behu ve 200 firmach, worker bez prace je bere po ctyrech + * (`CONCURRENCY` v worker.ts) a hned oznacuje za hotove. Meri se propustnost + * `claimBatch` + `markDone`, tedy strop fronty bez cizi sluzby. + */ + +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { StepContext, StepOutcome } from '../../src/runtime/builtinSteps.js'; +import type { FlowStep } from '../../src/data/automationStore.js'; + +vi.mock('../../src/runtime/builtinSteps.js', () => ({ findBuiltinStep: vi.fn() })); +vi.mock('../../src/runtime/scripts/lookup.js', () => ({ scriptIdFor: vi.fn() })); +vi.mock('../../src/runtime/scripts/runner.js', () => ({ runScript: vi.fn() })); +vi.mock('../../src/data/connectorStore.js', () => ({ + getConnector: vi.fn(), + defaultConnectorFor: vi.fn(), +})); + +import { findBuiltinStep } from '../../src/runtime/builtinSteps.js'; +import { initStores } from '../../src/data/store/index.js'; +import { runFlow, type RunOptions } from '../../src/runtime/executor.js'; +import { + claimBatch, + enqueue, + initQueue, + markDone, + queueStats, + trimQueue, + type QueueItem, +} from '../../src/runtime/queue.js'; +import { perfTenantId, TENANT_COUNT } from './fixtures.js'; +import { measure, perSecond, report } from './measure.js'; + +type Handler = ( + inputs: Record, + context: StepContext, +) => Promise | StepOutcome; + +const LOOP_ITEMS = 200; +const STEPS_PER_ITEM = 4; +const FLOW_RUNS = 100; +/** Radek podminky, radek smycky a ctyri kroky za kazdou polozku. */ +const STEPS_PER_RUN = 2 + LOOP_ITEMS * STEPS_PER_ITEM; + +const QUEUE_RUNS = 5_000; +/** Kolik behu si worker bere naraz, viz `CONCURRENCY` v src/runtime/worker.ts. */ +const WORKER_CONCURRENCY = 4; +/** Kolik zarazeni s klicem proti dvojimu zarazeni se meri zvlast. */ +const DEDUPE_ENQUEUES = 1_000; + +/** + * Rozpocty v ms (median ze tri behu), 4x az 5x nad merenim z 2026-09-09: + * strom 242, zarazeni 217, vyprazdneni 474, klic proti dvojimu zarazeni 30. + */ +const BUDGET_FLOW_MS = 1_000; +const BUDGET_ENQUEUE_MS = 1_000; +const BUDGET_DRAIN_MS = 2_000; +const BUDGET_DEDUPE_MS = 150; + +const handlers: Record = { + ok: (inputs) => ({ ok: true, summary: 'hotovo', outputs: { echo: inputs.value ?? '' } }), +}; + +function options(): RunOptions { + return { tenantId: perfTenantId(0), ticketId: null, idempotencyKey: null }; +} + +function action(id: string): FlowStep { + return { + id, + kind: 'action', + serviceId: 'test', + operationId: 'ok', + // Dve sablony na krok, aby se meril i prevod {{parametru}}. + inputs: { value: '{{item.n}}', note: 'polozka {{index}} z {{count}}' }, + }; +} + +function tree(): FlowStep[] { + return [ + { + id: 'guard', + kind: 'condition', + rules: [{ fieldId: 'items', operator: 'isNotEmpty' }], + yes: [ + { + id: 'loop', + kind: 'foreach', + path: 'items', + steps: Array.from({ length: STEPS_PER_ITEM }, (_, index) => action(`step${index}`)), + }, + ], + no: [], + }, + ]; +} + +beforeEach(() => { + vi.mocked(findBuiltinStep).mockReset(); + vi.mocked(findBuiltinStep).mockImplementation((serviceId, operationId) => + serviceId === 'test' ? handlers[operationId] : undefined, + ); +}); + +describe('runFlow', () => { + test('100 behu stromu s podminkou a smyckou pres 200 polozek', async () => { + const steps = tree(); + const items = Array.from({ length: LOOP_ITEMS }, (_, n) => ({ n })); + let executed = 0; + const ms = await measure(async () => { + executed = 0; + for (let run = 0; run < FLOW_RUNS; run += 1) { + const result = await runFlow(steps, { items, count: LOOP_ITEMS }, options()); + if (!result.ok) throw new Error(result.error ?? 'beh selhal'); + executed += result.steps.length; + } + }); + report( + `runFlow ${FLOW_RUNS}x (${STEPS_PER_RUN} kroku na beh)`, + ms, + BUDGET_FLOW_MS, + `${perSecond(FLOW_RUNS * STEPS_PER_RUN, ms)} kroku/s`, + ); + expect(executed).toBe(FLOW_RUNS * STEPS_PER_RUN); + expect(ms).toBeLessThan(BUDGET_FLOW_MS); + }); +}); + +describe('fronta behu', () => { + beforeAll(async () => { + initStores({ databaseReady: false }); + await initQueue(); + }); + + /** Vyprazdni frontu od hotovych behu, aby dalsi beh mereni zacinal od nuly. */ + async function clearDone(): Promise { + await trimQueue(0, 0); + } + + async function fill(count: number): Promise { + let queued = 0; + for (let index = 0; index < count; index += 1) { + const item = await enqueue({ + tenantId: perfTenantId(index % TENANT_COUNT), + automationId: `auto_${index % 10}`, + trigger: 'webhook', + payload: { sequence: index, body: { orderId: `ORD-${index}` } }, + }); + if (item) queued += 1; + } + return queued; + } + + /** Worker bez prace: bere po ctyrech a hned oznacuje za hotove. */ + async function drain(): Promise { + const active = new Set(); + let done = 0; + for (;;) { + const claimed: QueueItem[] = await claimBatch(WORKER_CONCURRENCY, active); + if (claimed.length === 0) break; + for (const item of claimed) { + active.add(item.id); + await markDone(item); + active.delete(item.id); + done += 1; + } + } + return done; + } + + test('zarazeni 5 000 behu', async () => { + let queued = 0; + const ms = await measure( + async () => { + queued = await fill(QUEUE_RUNS); + }, + { + // Kazdy beh zacina s prazdnou frontou: predchozi se vyprazdni a uklidi. + beforeEach: async () => { + await drain(); + await clearDone(); + }, + }, + ); + report(`enqueue ${QUEUE_RUNS}x`, ms, BUDGET_ENQUEUE_MS, `${perSecond(QUEUE_RUNS, ms)} behu/s`); + expect(queued).toBe(QUEUE_RUNS); + expect(ms).toBeLessThan(BUDGET_ENQUEUE_MS); + // Po mereni fronta ceka: dalsi test ji vyprazdni. + expect(queueStats().pending).toBe(QUEUE_RUNS); + }); + + test('vyprazdneni 5 000 behu workerem bez prace', async () => { + let done = 0; + const ms = await measure( + async () => { + done = await drain(); + }, + { + beforeEach: async () => { + await clearDone(); + if (queueStats().pending < QUEUE_RUNS) await fill(QUEUE_RUNS - queueStats().pending); + }, + }, + ); + report( + `claimBatch+markDone ${QUEUE_RUNS}x po ${WORKER_CONCURRENCY}`, + ms, + BUDGET_DRAIN_MS, + `${perSecond(QUEUE_RUNS, ms)} behu/s`, + ); + expect(done).toBe(QUEUE_RUNS); + expect(queueStats().pending).toBe(0); + expect(ms).toBeLessThan(BUDGET_DRAIN_MS); + }); + + test('klic proti dvojimu zarazeni nad plnou frontou', async () => { + await clearDone(); + await fill(QUEUE_RUNS); + let queued = 0; + let skipped = 0; + const ms = await measure(async () => { + queued = 0; + skipped = 0; + for (let index = 0; index < DEDUPE_ENQUEUES; index += 1) { + const item = await enqueue({ + tenantId: perfTenantId(index % TENANT_COUNT), + automationId: 'auto_dedupe', + trigger: 'ticket.updated', + ticketId: `TK-${index % 100}`, + // Kazdy desaty klic je novy, ostatni uz ve fronte cekaji. + dedupeKey: `auto_dedupe:TK-${index % 100}`, + }); + if (item) queued += 1; + else skipped += 1; + } + }); + report( + `enqueue s dedupeKey ${DEDUPE_ENQUEUES}x nad ${QUEUE_RUNS} cekajicimi`, + ms, + BUDGET_DEDUPE_MS, + `${queued} zarazeno, ${skipped} prevzato`, + ); + expect(queued + skipped).toBe(DEDUPE_ENQUEUES); + expect(ms).toBeLessThan(BUDGET_DEDUPE_MS); + await drain(); + await clearDone(); + }); +}); diff --git a/tests/perf/fixtures.ts b/tests/perf/fixtures.ts new file mode 100644 index 0000000..652ee13 --- /dev/null +++ b/tests/perf/fixtures.ts @@ -0,0 +1,280 @@ +/** + * Synteticka data pro vykonove testy: 200 firem, 1 000 uctu, 50 vlastnich + * roli a 10 000 ticketu. + * + * Data jsou **deterministicka** (vlastni generator nahodnych cisel se + * semenem), aby dva behy merily totez a rozdil v case byl rozdil v kodu, + * ne v datech. Vsechno jde pres verejne funkce ulozist, stejne jako + * v provozu - zadne sahani do vnitrniho stavu. + * + * Firmy a ucty se **vytvareji po jednom** (`create`), ne pres `init(seed)`: + * seed ze souboru v repu (`seed/records.json`) ma pred seedem z kodu + * prednost a firmy z nej by tise nahradily tech dve ste testovacich. + */ + +import { listPeople, groupStore, refreshGroups, type Person } from '../../src/data/people.js'; +import { refreshRoles, roleStore, systemRoles, type Role } from '../../src/data/permissions.js'; +import { initStores, nowIso } from '../../src/data/store/index.js'; +import { featuresStore, refreshFeatures, seedFeatures } from '../../src/data/tenantFeatures.js'; +import { refreshTenants, tenantStore, type Tenant } from '../../src/data/tenants.js'; +import { + createTicket, + defaultStatuses, + intakeEvent, + updateTicketStatus, + type TicketChannel, + type TicketPriority, +} from '../../src/data/ticketStore.js'; +import { refreshUsers, userStore, type StoredUser } from '../../src/data/users.js'; +import type { Membership } from '../../src/types.js'; + +/** Cilovy provoz z documentation/19-kapacita-200-firem.md. */ +export const TENANT_COUNT = 200; +/** Pet uctu na firmu; kazdy ma 1 az 3 clenstvi, takze lidi na firmu vychazi vic. */ +export const USER_COUNT = 1_000; +export const CUSTOM_ROLE_COUNT = 50; +export const TICKET_COUNT = 10_000; +/** Nejvic clenstvi jednoho uctu. Externista ve trech firmach je bezny pripad. */ +export const MAX_MEMBERSHIPS = 3; +/** Kolik ticketu vznika prijmem udalosti (s externim ID), zbytek rucne. */ +const INTAKE_SHARE = 0.3; +/** Kolik ticketu je vyrizenych, aby statistiky resitelu mely co pocitat. */ +const CLOSED_SHARE = 0.3; +/** Kolik ticketu nikdo nema u sebe. */ +const UNASSIGNED_SHARE = 0.25; +/** Kazdy paty ucet je spravce firmy, kazdy treti ma vlastni roli (kdyz firma nejakou ma). */ +const ADMIN_EVERY = 5; +const CUSTOM_ROLE_EVERY = 3; + +const CHANNELS: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal']; +const PRIORITIES: TicketPriority[] = ['low', 'normal', 'high', 'critical']; +const TAGS = ['vip', 'faktura', 'reklamace', 'expres']; +/** Stav vyrizeneho ticketu: posledni z vychozi nabidky. */ +const CLOSED_STATUS = defaultStatuses[defaultStatuses.length - 1] ?? 'Vyřešeno'; + +/** + * Generator nahodnych cisel se semenem (mulberry32). `Math.random` se nastavit + * neda a test s jinymi daty pri kazdem behu by nemel s cim srovnavat. + */ +export function rng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = Math.imul(state ^ (state >>> 15), 1 | state); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +function pick(random: () => number, items: readonly T[]): T { + const item = items[Math.floor(random() * items.length)]; + if (item === undefined) throw new Error('vyber z prazdneho seznamu'); + return item; +} + +export function perfTenantId(index: number): string { + return `tnt_perf_${index}`; +} + +export function perfUserId(index: number): string { + return `usr_perf_${index}`; +} + +export function perfRoleId(tenantIndex: number): string { + return `role_perf_${tenantIndex}`; +} + +export function buildTenant(index: number): Tenant { + const timestamp = nowIso(); + return { + id: perfTenantId(index), + tenantId: null, + name: `Firma ${index}`, + note: '', + enabled: true, + intakeToken: `perf-intake-token-${index}-0123456789abcdef`, + helpdeskProviderId: null, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +/** Vlastni role firmy: cte tickety a komentuje, nic vic. */ +export function buildRole(tenantIndex: number): Role { + const timestamp = nowIso(); + return { + id: perfRoleId(tenantIndex), + tenantId: perfTenantId(tenantIndex), + key: perfRoleId(tenantIndex), + name: `Vlastni role ${tenantIndex}`, + description: '', + permissions: ['ticket.view', 'ticket.comment', 'ticket.assign.self'], + system: false, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +/** + * Ucet s 1 az 3 clenstvimi v ruznych firmach. + * + * Prvni firma je `index % TENANT_COUNT`, aby mela kazda firma aspon pet lidi. + * Dalsi clenstvi jsou nahodna, jako u externistu. + */ +export function buildUser(index: number, random: () => number): StoredUser { + const timestamp = nowIso(); + const home = index % TENANT_COUNT; + const tenantIndexes = new Set([home]); + const wanted = 1 + Math.floor(random() * MAX_MEMBERSHIPS); + while (tenantIndexes.size < wanted) tenantIndexes.add(Math.floor(random() * TENANT_COUNT)); + + const memberships: Membership[] = [...tenantIndexes].map((tenantIndex) => { + let roleIds = ['agent']; + if (index % ADMIN_EVERY === 0) roleIds = ['admin']; + else if (index % CUSTOM_ROLE_EVERY === 0 && tenantIndex < CUSTOM_ROLE_COUNT) { + roleIds = [perfRoleId(tenantIndex)]; + } + return { tenantId: perfTenantId(tenantIndex), roleIds, capacity: 8 }; + }); + + return { + id: perfUserId(index), + tenantId: null, + email: `user${index}@perf.example`, + // Heslo se v techto testech nikdy neoveruje; hashovat tisic hesel by trvalo minuty. + passwordHash: '', + name: `Ucet ${index}`, + platformAdmin: false, + memberships, + enabled: true, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +export interface Directory { + tenants: Tenant[]; + users: StoredUser[]; + roles: Role[]; +} + +/** + * Naplni firmy, ucty, role, nastaveni firem a skupiny a obnovi kopie v pameti. + * + * `initialised: true` znamena, ze uloziste uz nekdo nastartoval (typicky + * `bootstrapData`) a `init` se volat nesmi - v rezimu pameti by druhy `init` + * zahodil, co tam je. + */ +export async function seedDirectory(options: { initialised?: boolean } = {}): Promise { + const random = rng(7); + + if (!options.initialised) { + initStores({ databaseReady: false }); + await tenantStore.init(); + await userStore.init(); + await roleStore.init(systemRoles); + await featuresStore.init(seedFeatures); + await groupStore.init(); + } + + const tenants = Array.from({ length: TENANT_COUNT }, (_, index) => buildTenant(index)); + for (const tenant of tenants) await tenantStore.create(tenant); + + const roles = Array.from({ length: CUSTOM_ROLE_COUNT }, (_, index) => buildRole(index)); + for (const role of roles) await roleStore.create(role); + + const users = Array.from({ length: USER_COUNT }, (_, index) => buildUser(index, random)); + for (const user of users) await userStore.create(user); + + await Promise.all([ + refreshTenants(), + refreshUsers(), + refreshRoles(), + refreshFeatures(), + refreshGroups(), + ]); + + return { tenants, users, roles }; +} + +export interface TicketDataset { + /** Kolik ticketu ma ktera firma. Pro kontrolu, ze seznam vraci vsechno. */ + countByTenant: Map; + /** Externi ID ticketu vzniklych prijmem udalosti, s firmou. */ + external: Array<{ tenantId: string; externalId: string }>; +} + +/** + * Zalozi `count` ticketu rovnomerne pres firmy. + * + * Cast vznika prijmem udalosti (`intakeEvent`, s externim ID), zbytek rucne + * (`createTicket`). Cast se rovnou vyridi, aby mely statistiky resitelu + * co pocitat. Resitel se bere z lidi te firmy, aby `toTicket` nehlasil + * neexistujiciho resitele. + */ +export function seedTickets( + random: () => number, + count = TICKET_COUNT, + tenantCount = TENANT_COUNT, +): TicketDataset { + const peopleByTenant = new Map(); + const countByTenant = new Map(); + const external: TicketDataset['external'] = []; + + for (let index = 0; index < count; index += 1) { + const tenantId = perfTenantId(index % tenantCount); + let people = peopleByTenant.get(tenantId); + if (!people) { + people = listPeople([tenantId]); + peopleByTenant.set(tenantId, people); + } + + const assigneeId = + random() < UNASSIGNED_SHARE || people.length === 0 ? null : pick(random, people).id; + const tags = TAGS.filter(() => random() < 0.3); + const priority = pick(random, PRIORITIES); + const channel = pick(random, CHANNELS); + const status = pick(random, defaultStatuses); + + let ticketId: string; + if (random() < INTAKE_SHARE) { + const externalId = `ext-${index}`; + const result = intakeEvent({ + tenantId, + externalId, + externalSource: 'eshop', + type: 'order.created', + label: `Objednávka ${index}`, + payload: { orderNumber: index, total: Math.round(random() * 10_000) }, + apply: { + channel, + priority, + company: `Zákazník ${index % 97}`, + assigneeId: assigneeId ?? undefined, + }, + create: { status, tags }, + }); + ticketId = result.ticket.id; + external.push({ tenantId, externalId }); + } else { + const ticket = createTicket({ + tenantId, + subject: `Požadavek ${index}`, + body: `Text požadavku ${index}`, + channel, + priority, + status, + assigneeId, + tags, + customer: { id: null, company: `Zákazník ${index % 97}`, contact: '', reply: '' }, + trace: [{ kind: 'note', label: 'Založeno v testu', status: 'info' }], + }); + ticketId = ticket.id; + } + + if (random() < CLOSED_SHARE) updateTicketStatus(ticketId, CLOSED_STATUS, [tenantId], true); + countByTenant.set(tenantId, (countByTenant.get(tenantId) ?? 0) + 1); + } + + return { countByTenant, external }; +} diff --git a/tests/perf/measure.ts b/tests/perf/measure.ts new file mode 100644 index 0000000..c612292 --- /dev/null +++ b/tests/perf/measure.ts @@ -0,0 +1,79 @@ +/** + * Mereni pro vykonove testy (tests/perf). + * + * Kazdy scenar se meri vickrat a bere se **median**: prvni beh ohreje JIT + * a jeden odlehly beh (GC, jiny proces na stroji) median nezkazi. Rozpocet + * kazdeho scenare je 3x az 5x nad casem namerenym na vyvojovem stroji, + * aby test nepadal na pomalejsim CI, ale porad chytil radovy propad - + * treba kdyz se z O(n) stane O(n^2). + * + * `console.info` je v testech umlceny (tests/setup.ts), proto se namerena + * cisla pisou primo na stdout. Bez nich by test jen rekl "proslo", a to + * na otazku "jak rychle" neodpovida. + */ + +import { performance } from 'node:perf_hooks'; + +/** Kolikrat se scenar opakuje. Tri behy staci na median, vic uz jen prodluzuje suite. */ +export const RUNS = 3; + +/** Prevod milisekund na sekundy pro pocitani propustnosti. */ +const MS_PER_SECOND = 1_000; + +export function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + const upper = sorted[middle]; + const lower = sorted[middle - 1]; + if (upper === undefined) return 0; + if (sorted.length % 2 === 1 || lower === undefined) return upper; + return (lower + upper) / 2; +} + +/** + * Zmeri praci `runs`-krat a vrati median v milisekundach. + * + * `beforeEach` bezi pred kazdym behem a **nemeri se**: priprava dat (napr. + * vraceni fronty do vychoziho stavu) nema do vysledku co mluvit. + */ +export async function measure( + work: () => unknown | Promise, + options: { runs?: number; beforeEach?: () => unknown | Promise } = {}, +): Promise { + const runs = options.runs ?? RUNS; + const times: number[] = []; + for (let index = 0; index < runs; index += 1) { + if (options.beforeEach) await options.beforeEach(); + const start = performance.now(); + await work(); + times.push(performance.now() - start); + } + return median(times); +} + +/** Kolik operaci za sekundu to je. Pro propustnost (udalosti/s, behy/s). */ +export function perSecond(count: number, ms: number): number { + if (ms <= 0) return 0; + return Math.round((count / ms) * MS_PER_SECOND); +} + +/** Zapise vysledek scenare na stdout. Jeden radek, aby se dal grepovat. */ +export function report(scenario: string, medianMs: number, budgetMs: number, note = ''): void { + const suffix = note === '' ? '' : `, ${note}`; + process.stdout.write( + `[perf] ${scenario}: ${medianMs.toFixed(1)} ms (rozpocet ${budgetMs} ms)${suffix}\n`, + ); +} + +/** + * Necha dobehnout mikroulohy a I/O callbacky. + * + * Uloziste ticketu zapisuje **jednou za tik** (`queueMicrotask` v persist.ts). + * Kdyby se merilo hned po priprave dat, zapis 10 000 ticketu by se pricetl + * k prvnimu scenari. Proto se pred merenim pocka na dalsi kolo smycky. + */ +export function settle(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} diff --git a/tests/perf/permissions.perf.test.ts b/tests/perf/permissions.perf.test.ts new file mode 100644 index 0000000..209b1ab --- /dev/null +++ b/tests/perf/permissions.perf.test.ts @@ -0,0 +1,173 @@ +/** + * Prava a pristup pod zatezi (src/data/permissions.ts, src/data/access.ts): + * 200 firem, 1 000 uctu s 1 az 3 clenstvimi, 50 vlastnich roli. + * + * `permissionsOf` ma cache na 5 sekund za dvojici ucet a firma, proto se + * meri zvlast studeny pruchod (po `invalidatePermissions`) a tepla cesta + * (100 000 volani, jak to vypada v provozu). `accessFor` se pocita jednou + * za request a cache nema. + */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { accessFor } from '../../src/data/access.js'; +import { hasPermission, invalidatePermissions, permissionsOf } from '../../src/data/permissions.js'; +import type { User } from '../../src/types.js'; +import { + CUSTOM_ROLE_COUNT, + perfRoleId, + seedDirectory, + TENANT_COUNT, + USER_COUNT, +} from './fixtures.js'; +import { measure, perSecond, report } from './measure.js'; + +const PERMISSION_CALLS = 100_000; +const ACCESS_CALLS = 10_000; +/** Spravce platformy: vidi vsechny firmy, `accessFor` je pro nej nejdrazsi. */ +const ADMIN_ACCESS_CALLS = 1_000; + +/** + * Rozpocty v ms (median ze tri behu), 4x az 5x nad merenim z 2026-09-09: + * studene 12, tepla cesta 27, accessFor 49, spravce platformy 92. + */ +const BUDGET_COLD_MS = 60; +const BUDGET_WARM_MS = 120; +const BUDGET_ACCESS_MS = 250; +const BUDGET_ADMIN_ACCESS_MS = 400; + +let users: User[] = []; +let membershipCount = 0; + +beforeAll(async () => { + const directory = await seedDirectory(); + users = directory.users.map((stored) => ({ + id: stored.id, + email: stored.email, + passwordHash: stored.passwordHash, + name: stored.name, + platformAdmin: stored.platformAdmin, + memberships: stored.memberships, + })); + membershipCount = users.reduce((sum, user) => sum + user.memberships.length, 0); + expect(users).toHaveLength(USER_COUNT); + expect(directory.tenants).toHaveLength(TENANT_COUNT); + expect(directory.roles).toHaveLength(CUSTOM_ROLE_COUNT); +}); + +function userAt(index: number): User { + const user = users[index % users.length]; + if (!user) throw new Error('chybi ucet'); + return user; +} + +function tenantOf(user: User, index: number): string { + const membership = user.memberships[index % user.memberships.length]; + if (!membership) throw new Error('ucet bez clenstvi'); + return membership.tenantId; +} + +describe('permissionsOf', () => { + test('studeny pruchod: kazde clenstvi jednou po zahozeni cache', async () => { + let granted = 0; + const ms = await measure( + () => { + granted = 0; + for (const user of users) { + for (const membership of user.memberships) { + granted += permissionsOf(user, membership.tenantId).size; + } + } + }, + { beforeEach: invalidatePermissions }, + ); + report( + `permissionsOf studene ${membershipCount}x`, + ms, + BUDGET_COLD_MS, + `${perSecond(membershipCount, ms)} vypoctu/s`, + ); + expect(granted).toBeGreaterThan(0); + expect(ms).toBeLessThan(BUDGET_COLD_MS); + }); + + test('tepla cesta: 100 000 volani hasPermission', async () => { + invalidatePermissions(); + let allowed = 0; + const ms = await measure(() => { + allowed = 0; + for (let index = 0; index < PERMISSION_CALLS; index += 1) { + const user = userAt(index); + if (hasPermission(user, 'ticket.view', tenantOf(user, index))) allowed += 1; + } + }); + report( + `hasPermission ${PERMISSION_CALLS}x`, + ms, + BUDGET_WARM_MS, + `${perSecond(PERMISSION_CALLS, ms)} volani/s`, + ); + // Kazda role v datech ma ticket.view, takze plati vsude. + expect(allowed).toBe(PERMISSION_CALLS); + expect(ms).toBeLessThan(BUDGET_WARM_MS); + }); + + test('role cizi firmy v clenstvi nic nepridava', () => { + const user: User = { + id: 'usr_perf_cizi', + email: 'cizi@perf.example', + passwordHash: '', + name: 'Cizi', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_perf_60', roleIds: [perfRoleId(0)] }], + }; + expect(permissionsOf(user, 'tnt_perf_60').size).toBe(0); + }); +}); + +describe('accessFor', () => { + test('10 000 vypoctu pristupu beznych uctu', async () => { + let tenants = 0; + const ms = await measure(() => { + tenants = 0; + for (let index = 0; index < ACCESS_CALLS; index += 1) { + const user = userAt(index); + tenants += accessFor(user, tenantOf(user, index)).tenants.length; + } + }); + report( + `accessFor ${ACCESS_CALLS}x`, + ms, + BUDGET_ACCESS_MS, + `${perSecond(ACCESS_CALLS, ms)} volani/s`, + ); + expect(tenants).toBeGreaterThanOrEqual(ACCESS_CALLS); + expect(ms).toBeLessThan(BUDGET_ACCESS_MS); + }); + + test('spravce platformy: pristup pres 200 firem', async () => { + const admin: User = { + id: 'usr_perf_admin', + email: 'admin@perf.example', + passwordHash: '', + name: 'Admin', + platformAdmin: true, + memberships: [], + }; + let tenants = 0; + const ms = await measure(() => { + tenants = 0; + for (let index = 0; index < ADMIN_ACCESS_CALLS; index += 1) { + tenants += accessFor(admin, `tnt_perf_${index % TENANT_COUNT}`).tenants.length; + } + }); + report( + `accessFor spravce platformy ${ADMIN_ACCESS_CALLS}x`, + ms, + BUDGET_ADMIN_ACCESS_MS, + `${(ms / ADMIN_ACCESS_CALLS).toFixed(2)} ms na volani`, + ); + // Aspon 200 firem na volani; seed ze souboru v repu muze pridat dalsi. + expect(tenants).toBeGreaterThanOrEqual(ADMIN_ACCESS_CALLS * TENANT_COUNT); + expect(ms).toBeLessThan(BUDGET_ADMIN_ACCESS_MS); + }); +}); diff --git a/tests/perf/stream.perf.test.ts b/tests/perf/stream.perf.test.ts new file mode 100644 index 0000000..b8d3325 --- /dev/null +++ b/tests/perf/stream.perf.test.ts @@ -0,0 +1,117 @@ +/** + * Sbernice udalosti pod zatezi (src/events/bus.ts): 500 pripojenych + * posluchacu (simulovani klienti SSE), 1 000 udalosti. + * + * Kazdy posluchac filtruje po firme stejne jako `routes/stream.ts`: udalost + * dostane kazdy, ale zapise si jen svoji. Cena za klienta tak musi rust + * s poctem udalosti, ne s poctem klientu - to se overuje srovnanim 100 + * a 500 posluchacu. + * + * `emitter.setMaxListeners(200)` v bus.ts znamena, ze Node pri 500 + * posluchacich vypise varovani o moznem uniku pameti. Je to jen varovani, + * doruceni funguje; viz documentation/19-kapacita-200-firem.md. + */ + +import { describe, expect, test } from 'vitest'; +import { listenerCount, publish, subscribe, type DashboardEvent } from '../../src/events/bus.js'; +import { perfTenantId, TENANT_COUNT } from './fixtures.js'; +import { measure, perSecond, report } from './measure.js'; + +const CLIENTS = 500; +const CLIENTS_SMALL = 100; +const EVENTS = 1_000; +/** Kazda firma dostane stejne udalosti, aby slo pocet doruceni overit presne. */ +const EVENTS_PER_TENANT = EVENTS / TENANT_COUNT; + +/** Rozpocty, 5x nad merenim z 2026-09-09: 12 ms, 0,02 us na doruceni. */ +const BUDGET_PUBLISH_MS = 60; +/** Kolik mikrosekund smi stat jedno doruceni jednomu klientovi. */ +const BUDGET_PER_DELIVERY_US = 0.1; +/** + * O kolik smi cena na klienta vyrust mezi 100 a 500 klienty. Pri O(udalosti) + * je pomer kolem 1, tolerance kryje sum mereni. + */ +const MAX_PER_CLIENT_GROWTH = 3; + +const MICROSECONDS_PER_MS = 1_000; + +interface Client { + tenantId: string; + received: number; + unsubscribe: () => void; +} + +/** Klient SSE: firma z pripojeni, filtr jako ve `stream.ts`, pocitadlo doruceni. */ +function connect(index: number): Client { + const tenantId = perfTenantId(index % TENANT_COUNT); + const allowed = new Set([tenantId]); + const client: Client = { tenantId, received: 0, unsubscribe: () => undefined }; + const visible = (event: DashboardEvent): boolean => { + const forUser = event.payload?.userId; + if (typeof forUser === 'string') return false; + if (event.tenantId === null) return true; + return allowed.has(event.tenantId); + }; + client.unsubscribe = subscribe((event) => { + if (!visible(event)) return; + // Serializace jako pri zapisu do odpovedi, jinak by klient byl zadarmo. + JSON.stringify(event); + client.received += 1; + }); + return client; +} + +function publishAll(): void { + for (let index = 0; index < EVENTS; index += 1) { + publish( + 'ticket.updated', + `Ticket TK-${index} se zmenil`, + { ticketId: `TK-${index}`, status: 'V řešení' }, + perfTenantId(index % TENANT_COUNT), + ); + } +} + +async function measureWith(clientCount: number): Promise<{ ms: number; clients: Client[] }> { + const clients = Array.from({ length: clientCount }, (_, index) => connect(index)); + expect(listenerCount()).toBe(clientCount); + const ms = await measure(publishAll, { + beforeEach: () => { + for (const client of clients) client.received = 0; + }, + }); + for (const client of clients) client.unsubscribe(); + expect(listenerCount()).toBe(0); + return { ms, clients }; +} + +describe('publish na sbernici', () => { + test(`${CLIENTS} posluchacu, ${EVENTS} udalosti`, async () => { + const { ms, clients } = await measureWith(CLIENTS); + const deliveries = CLIENTS * EVENTS; + const perDeliveryUs = (ms / deliveries) * MICROSECONDS_PER_MS; + report( + `publish ${EVENTS}x pro ${CLIENTS} klientu`, + ms, + BUDGET_PUBLISH_MS, + `${perSecond(EVENTS, ms)} udalosti/s, ${perDeliveryUs.toFixed(2)} us na doruceni`, + ); + + // Kazdy klient dostal presne udalosti sve firmy, nic navic. + expect(clients.every((client) => client.received === EVENTS_PER_TENANT)).toBe(true); + expect(ms).toBeLessThan(BUDGET_PUBLISH_MS); + expect(perDeliveryUs).toBeLessThan(BUDGET_PER_DELIVERY_US); + }); + + test('cena na klienta neroste s poctem klientu', async () => { + const small = await measureWith(CLIENTS_SMALL); + const large = await measureWith(CLIENTS); + const perClientSmallUs = (small.ms / CLIENTS_SMALL) * MICROSECONDS_PER_MS; + const perClientLargeUs = (large.ms / CLIENTS) * MICROSECONDS_PER_MS; + process.stdout.write( + `[perf] publish ${EVENTS}x na klienta: ${perClientSmallUs.toFixed(1)} us pri ${CLIENTS_SMALL} ` + + `klientech, ${perClientLargeUs.toFixed(1)} us pri ${CLIENTS} (smi nejvys ${MAX_PER_CLIENT_GROWTH}x vic)\n`, + ); + expect(perClientLargeUs).toBeLessThan(perClientSmallUs * MAX_PER_CLIENT_GROWTH); + }); +}); diff --git a/tests/perf/tickets.perf.test.ts b/tests/perf/tickets.perf.test.ts new file mode 100644 index 0000000..85d8b46 --- /dev/null +++ b/tests/perf/tickets.perf.test.ts @@ -0,0 +1,203 @@ +/** + * Uloziste ticketu pod zatezi (src/data/tickets): 10 000 ticketu ve 200 + * firmach, seznam s filtrem a strankovanim, vytizeni, vykon resitelu, + * prijem opakovanych udalosti a hledani podle externiho ID. + * + * Rezim pameti, viz tests/setup.ts. Rozpocty jsou 3x az 5x nad casem + * namerenym na vyvojovem stroji, viz tests/perf/measure.ts. + */ + +import { performance } from 'node:perf_hooks'; +import { beforeAll, describe, expect, test } from 'vitest'; +import type { Visibility } from '../../src/data/access.js'; +import { listPeople } from '../../src/data/people.js'; +import { + findByExternalId, + getAgentStats, + getWorkload, + intakeEvent, + listTickets, +} from '../../src/data/ticketStore.js'; +import { paginate } from '../../src/routes/dashboard/shared.js'; +import { + perfTenantId, + rng, + seedDirectory, + seedTickets, + TICKET_COUNT, + type TicketDataset, +} from './fixtures.js'; +import { measure, perSecond, report, settle } from './measure.js'; + +const ALL = { kind: 'all' } as const; +/** Firma, nad kterou se meri. Ma 50 ticketu jako kazda jina. */ +const TENANT = perfTenantId(0); +/** Kolik volani se meri naraz, aby cas nebyl v desetinach milisekundy. */ +const CALLS = 100; +/** Stranka seznamu jako v portalu. */ +const PAGE = { limit: 50, offset: 0 }; +/** Okno statistiky resitelu, stejne jako u widgetu vykonu. */ +const STATS_WINDOW_MS = 30 * 86_400_000; +/** Kolik udalosti se posle na existujici tickety. */ +const INTAKE_EVENTS = 1_000; +const LOOKUPS = 10_000; + +/** + * Rozpocty v ms (median ze tri behu), 5x nad merenim z 2026-09-09: + * seznam 18, s filtrem 15, vytizeni 21, vykon 24, prijem 13, hledani 13. + */ +const BUDGET_LIST_MS = 100; +const BUDGET_LIST_FILTERED_MS = 100; +const BUDGET_WORKLOAD_MS = 100; +const BUDGET_AGENT_STATS_MS = 120; +const BUDGET_INTAKE_MS = 80; +const BUDGET_LOOKUP_MS = 60; + +let dataset: TicketDataset; +let seedMs = 0; + +beforeAll(async () => { + await seedDirectory(); + const start = performance.now(); + dataset = seedTickets(rng(11)); + await settle(); + seedMs = performance.now() - start; + process.stdout.write( + `[perf] priprava: ${TICKET_COUNT} ticketu za ${seedMs.toFixed(0)} ms ` + + `(${perSecond(TICKET_COUNT, seedMs)} ticketu/s vcetne zapisu do uloziste)\n`, + ); +}); + +describe('listTickets nad 10 000 tickety', () => { + test('cely seznam jedne firmy', async () => { + const expected = dataset.countByTenant.get(TENANT) ?? 0; + let size = 0; + const ms = await measure(() => { + for (let index = 0; index < CALLS; index += 1) { + size = listTickets({ tenantIds: [TENANT], visibility: ALL }).length; + } + }); + report(`listTickets ${CALLS}x`, ms, BUDGET_LIST_MS, `${(ms / CALLS).toFixed(2)} ms na volani`); + expect(size).toBe(expected); + expect(ms).toBeLessThan(BUDGET_LIST_MS); + }); + + test('filtr na stav, kanal a resitele plus strankovani', async () => { + const person = listPeople([TENANT])[0]; + const visibility: Visibility = { + kind: 'scoped', + personIds: person ? [person.id] : [], + groupIds: [], + }; + let page: unknown[] = []; + const ms = await measure(() => { + for (let index = 0; index < CALLS; index += 1) { + const items = listTickets({ + tenantIds: [TENANT], + visibility, + status: 'Nový', + channel: 'email', + assignee: 'unassigned', + }); + page = paginate(items, PAGE); + } + }); + report(`listTickets s filtrem ${CALLS}x`, ms, BUDGET_LIST_FILTERED_MS); + expect(page.length).toBeLessThanOrEqual(PAGE.limit); + expect(ms).toBeLessThan(BUDGET_LIST_FILTERED_MS); + }); +}); + +describe('statistiky', () => { + test('getWorkload', async () => { + const people = listPeople([TENANT]); + let rows = 0; + const ms = await measure(() => { + for (let index = 0; index < CALLS; index += 1) { + rows = getWorkload(people, [TENANT], ALL).rows.length; + } + }); + report(`getWorkload ${CALLS}x`, ms, BUDGET_WORKLOAD_MS); + expect(rows).toBe(people.length); + expect(ms).toBeLessThan(BUDGET_WORKLOAD_MS); + }); + + test('getAgentStats za 30 dni', async () => { + const people = listPeople([TENANT]); + const since = Date.now() - STATS_WINDOW_MS; + let resolved = 0; + const ms = await measure(() => { + for (let index = 0; index < CALLS; index += 1) { + resolved = getAgentStats(people, [TENANT], since, ALL).reduce( + (sum, row) => sum + row.resolved, + 0, + ); + } + }); + report(`getAgentStats ${CALLS}x`, ms, BUDGET_AGENT_STATS_MS, `${resolved} vyresenych`); + expect(ms).toBeLessThan(BUDGET_AGENT_STATS_MS); + }); +}); + +describe('prijem udalosti', () => { + test('1 000 udalosti na existujici tickety, polovina je opakovani', async () => { + let repeated = 0; + let appended = 0; + let round = 0; + const ms = await measure(() => { + round += 1; + repeated = 0; + appended = 0; + // Kazda udalost se posle dvakrat za sebou: poprve se navesi, podruhe se + // pozna jako opakovani a jen se pricte k pocitadlu. + for (let index = 0; index < INTAKE_EVENTS; index += 1) { + const target = dataset.external[Math.floor(index / 2) % dataset.external.length]; + if (!target) throw new Error('chybi ticket s externim ID'); + const result = intakeEvent({ + tenantId: target.tenantId, + externalId: target.externalId, + type: 'order.paid', + payload: { round, sequence: Math.floor(index / 2) }, + apply: { contact: `Kontakt ${index}` }, + }); + if (result.repeated) repeated += 1; + else appended += 1; + expect(result.created).toBe(false); + } + }); + report( + `intakeEvent ${INTAKE_EVENTS}x`, + ms, + BUDGET_INTAKE_MS, + `${perSecond(INTAKE_EVENTS, ms)} udalosti/s, ${repeated} opakovanych, ${appended} novych`, + ); + expect(repeated).toBe(INTAKE_EVENTS / 2); + expect(ms).toBeLessThan(BUDGET_INTAKE_MS); + }); + + test('findByExternalId je hledani v indexu, ne v poli', async () => { + let found = 0; + const ms = await measure(() => { + found = 0; + for (let index = 0; index < LOOKUPS; index += 1) { + const target = dataset.external[index % dataset.external.length]; + if (!target) throw new Error('chybi ticket s externim ID'); + if (findByExternalId(target.tenantId, target.externalId)) found += 1; + // Cizi firma se stejnym ID nesmi nic najit. + const foreign = target.tenantId === TENANT ? perfTenantId(1) : TENANT; + if (findByExternalId(foreign, target.externalId)) found += 1_000_000; + } + }); + report(`findByExternalId ${LOOKUPS}x`, ms, BUDGET_LOOKUP_MS); + expect(found).toBe(LOOKUPS); + expect(ms).toBeLessThan(BUDGET_LOOKUP_MS); + }); +}); + +describe('hranice firmy', () => { + test('seznam jine firmy neobsahuje tickety merene firmy', () => { + const other = listTickets({ tenantIds: [perfTenantId(1)], visibility: ALL }); + expect(other).toHaveLength(dataset.countByTenant.get(perfTenantId(1)) ?? 0); + expect(other.every((ticket) => ticket.tenantId === perfTenantId(1))).toBe(true); + }); +}); diff --git a/tests/perf/widgets.perf.test.ts b/tests/perf/widgets.perf.test.ts new file mode 100644 index 0000000..54c0f59 --- /dev/null +++ b/tests/perf/widgets.perf.test.ts @@ -0,0 +1,153 @@ +/** + * Data widgetu pod zatezi (src/routes/widgetData.ts): prehled s 24 dlazdicemi + * nad 10 000 tickety ve 200 firmach. + * + * Vypocet zdroju (`computeSource`) neni exportovany, meri se tedy cely + * request `POST /api/dashboard/widget-data` pres supertest, vcetne + * autorizace a prav - presne to, co dostane prohlizec. Pro srovnani se meri + * i vychozi rozlozeni, ktere si na serveru pocita jen dve dlazdice. + */ + +import request from 'supertest'; +import { beforeAll, describe, expect, test } from 'vitest'; +import type { Express } from 'express'; +import { createApp } from '../../src/app.js'; +import { bootstrapData } from '../../src/data/bootstrap.js'; +import { + customWidgetStore, + refreshCustomWidgets, + type CustomWidget, + type WidgetSource, +} from '../../src/data/customWidgets.js'; +import { nowIso } from '../../src/data/store/index.js'; +import type { WidgetResult } from '../../src/shared/widgets.js'; +import { perfTenantId, rng, seedDirectory, seedTickets, TICKET_COUNT } from './fixtures.js'; +import { measure, report, settle } from './measure.js'; + +/** Firma, jejiz prehled se meri. */ +const TENANT = perfTenantId(0); +/** Strop dlazdic na jeden request, viz `requestSchema` ve widgetData.ts. */ +const MAX_WIDGETS = 24; +/** Vestavene dlazdice, ktere si data pocitaji na serveru (Overview.tsx, COMPUTED_BUILTINS). */ +const COMPUTED_BUILTINS = ['panel.agents', 'list.myTickets', 'list.unassigned']; +/** Co posila vychozi rozlozeni: jen dve dlazdice se zdrojem na serveru. */ +const DEFAULT_LAYOUT_IDS = ['list.myTickets', 'list.unassigned']; +const DEMO_ADMIN = { email: 'admin@automia.cz', password: 'demo1234' }; + +/** Rozpocty v ms (median ze tri behu), 5x nad merenim z 2026-09-09: 6 a 3 ms. */ +const BUDGET_DASHBOARD_MS = 30; +const BUDGET_DEFAULT_MS = 15; + +/** Zdroje vlastnich dlazdic: seskupene pocty, seznamy, rady, vytizeni a vykon. */ +function customSources(): WidgetSource[] { + return [ + { kind: 'ticketCount', filter: {} }, + { kind: 'ticketCount', filter: { closed: false } }, + { kind: 'ticketCount', filter: { period: 'month' }, groupBy: 'status' }, + { kind: 'ticketCount', filter: { closed: false }, groupBy: 'assignee' }, + { kind: 'ticketCount', filter: {}, groupBy: 'channel' }, + { kind: 'ticketCount', filter: {}, groupBy: 'tag' }, + { kind: 'ticketCount', filter: {}, groupBy: 'type' }, + { kind: 'ticketCount', filter: {}, groupBy: 'group' }, + { kind: 'ticketCount', filter: { tags: ['vip'], closed: false } }, + { kind: 'ticketCount', filter: { channel: ['email', 'voice'] }, groupBy: 'status' }, + { kind: 'ticketCount', filter: { period: '7d' }, groupBy: 'assignee' }, + { kind: 'ticketCount', filter: { assignee: ['unassigned'] } }, + { kind: 'ticketList', filter: { closed: false }, limit: 8 }, + { kind: 'ticketList', filter: { tags: ['reklamace'] }, limit: 8 }, + { kind: 'ticketList', filter: { channel: ['voice'], closed: false }, limit: 5 }, + { kind: 'ticketSeries', filter: {}, bucket: 'day' }, + { kind: 'ticketSeries', filter: { closed: true }, bucket: 'week' }, + { kind: 'ticketSeries', filter: { channel: ['email'] }, bucket: 'day' }, + { kind: 'workload' }, + { kind: 'agentStats', period: '7d' }, + { kind: 'agentStats', period: 'all' }, + ]; +} + +function widget(index: number, source: WidgetSource): CustomWidget { + const timestamp = nowIso(); + return { + id: `cw_perf_${index}`, + tenantId: TENANT, + ownerId: null, + name: `Dlazdice ${index}`, + description: '', + render: + source.kind === 'ticketList' ? 'list' : source.kind === 'ticketSeries' ? 'chart' : 'table', + source, + size: 'half', + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +let app: Express; +let token = ''; +let widgetIds: string[] = []; + +beforeAll(async () => { + const mode = await bootstrapData({ databaseReady: false }); + expect(mode).toBe('memory'); + + // Uloziste uz nastartoval bootstrap, tady se jen pridavaji zaznamy. + await seedDirectory({ initialised: true }); + seedTickets(rng(23)); + await settle(); + + const custom = customSources().map((source, index) => widget(index, source)); + for (const item of custom) await customWidgetStore.create(item); + await refreshCustomWidgets(); + + widgetIds = [...COMPUTED_BUILTINS, ...custom.map((item) => item.id)]; + expect(widgetIds).toHaveLength(MAX_WIDGETS); + + app = createApp(); + const login = await request(app).post('/api/auth/login').send(DEMO_ADMIN); + expect(login.status).toBe(200); + token = login.body.token as string; +}); + +async function widgetData(ids: string[]): Promise { + const response = await request(app) + .post(`/api/dashboard/widget-data?tenantId=${TENANT}`) + .set('Authorization', `Bearer ${token}`) + .send({ widgetIds: ids }); + expect(response.status).toBe(200); + return response.body.items as WidgetResult[]; +} + +describe('POST /api/dashboard/widget-data', () => { + test(`prehled s ${MAX_WIDGETS} dlazdicemi nad ${TICKET_COUNT} tickety`, async () => { + let items: WidgetResult[] = []; + const ms = await measure(async () => { + items = await widgetData(widgetIds); + }); + report(`widget-data ${MAX_WIDGETS} dlazdic`, ms, BUDGET_DASHBOARD_MS); + + expect(items).toHaveLength(MAX_WIDGETS); + const failed = items.filter((item) => !item.ok); + expect(failed).toEqual([]); + expect(ms).toBeLessThan(BUDGET_DASHBOARD_MS); + }); + + test('vychozi rozlozeni: dve dlazdice se zdrojem na serveru', async () => { + let items: WidgetResult[] = []; + const ms = await measure(async () => { + items = await widgetData(DEFAULT_LAYOUT_IDS); + }); + report('widget-data vychozi rozlozeni', ms, BUDGET_DEFAULT_MS); + expect(items.every((item) => item.ok)).toBe(true); + expect(ms).toBeLessThan(BUDGET_DEFAULT_MS); + }); + + test('dlazdice cizi firmy se nevrati', async () => { + const response = await request(app) + .post(`/api/dashboard/widget-data?tenantId=${perfTenantId(1)}`) + .set('Authorization', `Bearer ${token}`) + .send({ widgetIds: ['cw_perf_0'] }); + expect(response.status).toBe(200); + const [item] = response.body.items as WidgetResult[]; + expect(item?.ok).toBe(false); + }); +}); diff --git a/tests/routes/adminExport.test.ts b/tests/routes/adminExport.test.ts new file mode 100644 index 0000000..791fcb3 --- /dev/null +++ b/tests/routes/adminExport.test.ts @@ -0,0 +1,90 @@ +/** + * Export nastaveni pres HTTP (src/routes/admin.ts, `GET /api/admin/export`): + * jen spravce platformy, vraci konfiguracni druhy vcetne hashu hesel, + * bez konektoru a provoznich dat, a zapisuje audit. + */ + +import request from 'supertest'; +import { beforeAll, describe, expect, test } from 'vitest'; +import type { Express } from 'express'; +import { createApp } from '../../src/app.js'; +import { listAudit } from '../../src/data/audit.js'; +import { bootstrapData } from '../../src/data/bootstrap.js'; +import { SEED_KINDS } from '../../src/data/store/seedFile.js'; + +let app: Express; + +async function login(email: string): Promise { + const response = await request(app).post('/api/auth/login').send({ email, password: 'demo1234' }); + expect(response.status).toBe(200); + return response.body.token as string; +} + +beforeAll(async () => { + await bootstrapData({ databaseReady: false }); + app = createApp(); +}); + +describe('GET /api/admin/export', () => { + test('bez prihlaseni je 401', async () => { + expect((await request(app).get('/api/admin/export')).status).toBe(401); + }); + + test('spravce firmy bez platformnich prav dostane 403', async () => { + // Karel je admin u Nordisu, ale ne spravce platformy. + const token = await login('karel.vomacka@automia.cz'); + const response = await request(app) + .get('/api/admin/export') + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(403); + }); + + test('spravce platformy dostane vsechny konfiguracni druhy vcetne hashu hesel', async () => { + const token = await login('admin@automia.cz'); + const response = await request(app) + .get('/api/admin/export') + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(typeof response.body.exportedAt).toBe('string'); + + const kinds = response.body.kinds as Record>>; + expect(Object.keys(kinds).sort()).toEqual([...SEED_KINDS].sort()); + expect(kinds.tenant?.map((tenant) => tenant.id)).toContain('tnt_automia'); + expect(kinds.role?.length).toBeGreaterThan(0); + + const admin = kinds.user?.find((user) => user.email === 'admin@automia.cz'); + expect(admin).toBeDefined(); + expect(admin?.passwordHash).toMatch(/^\$2[aby]\$/); + }); + + test('konektory ani provozni data v exportu nejsou', async () => { + const token = await login('admin@automia.cz'); + const response = await request(app) + .get('/api/admin/export') + .set('Authorization', `Bearer ${token}`); + const kinds = response.body.kinds as Record; + for (const kind of [ + 'connector', + 'ticket', + 'incident', + 'audit', + 'notification', + 'attachment', + 'runQueue', + 'invite', + ]) { + expect(kinds[kind]).toBeUndefined(); + } + }); + + test('export je v auditu jako admin.export', async () => { + const token = await login('admin@automia.cz'); + await request(app).get('/api/admin/export').set('Authorization', `Bearer ${token}`); + // Audit se zapisuje bez cekani, jeden tik staci. + await new Promise((resolve) => setImmediate(resolve)); + const entries = await listAudit({ tenantIds: [], includeGlobal: true, action: 'admin.export' }); + expect(entries.length).toBeGreaterThan(0); + expect(entries[0]?.userEmail).toBe('admin@automia.cz'); + expect(entries[0]?.detail).toMatchObject({ tenant: 3 }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 8567c67..36a555f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,7 +5,9 @@ * proto se promenne nastavuji tady a ne az v testu. Prazdny `DATA_DIR` * znamena rezim pameti (`storesMode() === 'memory'`), prazdny `DATABASE_URL` * vypne Postgres. `SECRETS_KEY` je pevny, aby `initSecrets` nezkousel - * zakladat soubor s klicem. + * zakladat soubor s klicem. Prazdny `SEED_FILE` vypne nastaveni z repozitare + * (`seed/records.json`), aby testy nezavisely na jeho obsahu; test, ktery ho + * chce, si cestu nastavi sam pred importem. */ import { vi } from 'vitest'; @@ -16,6 +18,7 @@ process.env.DATABASE_URL = ''; process.env.SECRETS_KEY = 'vitest-secrets-key'; process.env.JWT_SECRET = 'vitest-jwt-secret'; process.env.SEED_DEMO = '0'; +process.env.SEED_FILE = ''; process.env.WEBHOOK_TOKEN_TEST = 'vitest-webhook-token-0123456789'; delete process.env.ALLOW_PRIVATE_TARGETS; diff --git a/vitest.config.ts b/vitest.config.ts index fe8ce71..37fce8c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; /** * Testy serveru lezi v `tests/` se stejnou cestou jako testovany modul @@ -9,6 +9,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.ts'], + // Vykonove testy maji vlastni beh (`npm run test:perf`, vitest.perf.config.ts): + // stavi desetitisice zaznamu a jejich rozpocty zavisi na stroji. + exclude: [...configDefaults.exclude, 'tests/perf/**'], environment: 'node', // Promenne prostredi pro rezim pameti, viz tests/setup.ts. setupFiles: ['tests/setup.ts'], diff --git a/vitest.perf.config.ts b/vitest.perf.config.ts new file mode 100644 index 0000000..587bae6 --- /dev/null +++ b/vitest.perf.config.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +/** + * Vykonove testy (`npm run test:perf`). Bezi zvlast od `npm test`: + * stavi si desetitisice zaznamu, trvaji desitky sekund a jejich rozpocty + * zavisi na stroji. V beznem behu by jen zdrzovaly a obcas padaly. + * + * Soubory jdou **po jednom** (`fileParallelism: false`): dva merici testy + * vedle sebe by si braly procesor a cisla by nesla srovnat. + */ + +/** Priprava dat trva dele nez bezny test, proto vyssi stropy. */ +const PERF_TEST_TIMEOUT_MS = 120_000; +const PERF_HOOK_TIMEOUT_MS = 120_000; + +export default defineConfig({ + test: { + include: ['tests/perf/**/*.test.ts'], + environment: 'node', + setupFiles: ['tests/setup.ts'], + isolate: true, + fileParallelism: false, + testTimeout: PERF_TEST_TIMEOUT_MS, + hookTimeout: PERF_HOOK_TIMEOUT_MS, + }, + resolve: { + alias: { + '@shared': path.resolve(import.meta.dirname, 'src/shared'), + }, + }, +}); diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index fb2e806..efa1c3d 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -280,7 +280,7 @@ function DashboardShell() { type="button" aria-label="Zavřít menu" onClick={() => setSidebarPath(null)} - className="fixed inset-0 z-40 bg-ink-950/70 backdrop-blur-sm lg:hidden" + className="fixed inset-0 z-40 bg-ink-950/80 lg:hidden" /> )} diff --git a/web/src/components/dashboard/EventToasts.tsx b/web/src/components/dashboard/EventToasts.tsx index fe859fb..b7c7ea1 100644 --- a/web/src/components/dashboard/EventToasts.tsx +++ b/web/src/components/dashboard/EventToasts.tsx @@ -14,8 +14,14 @@ import { useEventLog } from '@/components/dashboard/EventStreamProvider'; import { cn } from '@/lib/cn'; import type { DashboardEvent, DashboardEventType } from '@/types/events'; -/** Jak dlouho bublina zustane, nez sama zmizi. */ -const LIFETIME_MS = 6_000; +/** + * Jak dlouho bublina zustane, nez sama zmizi. + * + * Pul sekundy je rozhodnuti zadavatele (2026-09-09): bublina je zablesk + * "neco se stalo", ne text ke cteni. Podrobnosti jsou v seznamu, na ktery + * udalost ukazuje, a cislo u zalozky zustava. + */ +const LIFETIME_MS = 500; const MAX_VISIBLE = 4; // Partial: zmeny ciselniku (`person.updated` apod.) maji obecny vzhled nize. diff --git a/web/src/components/dashboard/LiveIndicator.tsx b/web/src/components/dashboard/LiveIndicator.tsx index 99b3b30..c41cd58 100644 --- a/web/src/components/dashboard/LiveIndicator.tsx +++ b/web/src/components/dashboard/LiveIndicator.tsx @@ -1,9 +1,15 @@ import { useEventStream } from '@/components/dashboard/EventStreamProvider'; import { cn } from '@/lib/cn'; +/* + * Pulz jen kdyz se neco deje (pripojuji, obnovuji). Stav "Zive" je vychozi + * a trva hodiny; nekonecna animace v hlavicce s `backdrop-blur` nutila + * prohlizec prepocitavat rozmazani porad dokola, a s otevrenym dialogem + * (dalsi vrstva pres celou obrazovku) to sekalo video vedle portalu. + */ const meta = { connecting: { label: 'Připojuji', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true }, - open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: true }, + open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: false }, reconnecting: { label: 'Obnovuji spojení', dot: 'bg-warn-400', diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index 13cbf00..e5fe091 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -74,7 +74,14 @@ export function Modal({ return createPortal(
-
+ {/* + Prekryv bez rozmazani, schvalne. `backdrop-blur` pres celou obrazovku + znamena, ze kazda animace pod dialogem (pulzujici tecka, spinner, + obnova seznamu) prepocita rozmazani cele plochy, a to jeste pres + rozmazanou hlavicku pod nim. Vedle portalu pak sekalo video. Tmavy + prekryv s vyssi krytim dela tutez praci zadarmo. + */} +