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 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 20:41:42 +02:00
co-authored by Claude Fable 5.1
parent c25e826766
commit ea9387bea9
44 changed files with 3512 additions and 28 deletions
+3
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -5,3 +5,4 @@ design
package-lock.json
# Dokumentace ma vlastni styl tabulek, prettier by je prelamal.
*.md
seed
+4
View File
@@ -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
+12 -1
View File
@@ -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
+8 -4
View File
@@ -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 `<SLUZBA>_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` |
+16
View File
@@ -99,6 +99,7 @@ Vyzaduji `Authorization: Bearer <token>`:
| 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/<entita>`, 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: { <druh>: [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.
+67
View File
@@ -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": { "<druh>": [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] <druh>: N zaznamu ze souboru` nebo `[seed] <druh>: 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
+4
View File
@@ -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". |
+135
View File
@@ -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/<token>` 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`.
+7
View File
@@ -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
+10 -2
View File
@@ -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
+81
View File
@@ -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: { <druh>: [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
+5
View File
@@ -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',
+3
View File
@@ -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": {
+89
View File
@@ -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<any>}
*/
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);
});
+741
View File
@@ -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/<token> 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<string, EndpointStats>} */
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<unknown>} 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<string, number>} */
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<string, string>}
*/
function authHeaders(token) {
return { Authorization: `Bearer ${token}` };
}
/**
* @param {string} base
* @param {string} token
* @returns {Promise<string>}
*/
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/<token>` 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<string, unknown>}
*/
function eventBody(example, expects, index) {
const body = /** @type {Record<string, unknown>} */ (
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<string, unknown>} */ (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<number>} */
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<boolean>}
*/
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<void>}
*/
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<number | null>}
*/
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<Array<string>>} 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<ReturnType<typeof startLocal>> | 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<string, ReturnType<typeof summarise>>} */
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);
});
+383
View File
@@ -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"
}
]
}
}
+20
View File
@@ -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
+26
View File
@@ -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<SeedExport> {
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 };
}
+9 -1
View File
@@ -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<T extends TenantEntity>(kind: string): EntityStore<T
const store: EntityStore<T> = {
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<T>(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<void> {
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<TenantEntity> | undefined {
return registry.get(kind);
}
/** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */
export function storeKinds(): string[] {
return [...registry.keys()].sort();
+153
View File
@@ -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<Record<SeedKind, TenantEntity[]>>;
}
/** 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<SeedKind, TenantEntity[]>;
/** Jednou nacteny soubor za cestu. `null` = neni, nebo je rozbity. */
const cache = new Map<string, LoadedSeed | null>();
/**
* 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<T extends TenantEntity>(
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();
};
}
+9 -2
View File
@@ -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[] = [];
+38
View File
@@ -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$...' },
],
},
},
},
},
};
+2
View File
@@ -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,
},
};
+22 -1
View File
@@ -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<string, unknown> = {
'/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'],
+33
View File
@@ -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);
},
);
+29 -10
View File
@@ -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<string>();
/** Tytez behy jako promisy, aby slo pockat na prvni dokonceny. */
const inFlight = new Map<string, Promise<void>>();
/**
* Spusti workera.
*
@@ -81,14 +91,19 @@ async function loop(): Promise<void> {
* 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<void> {
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;
+184
View File
@@ -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();
});
});
+256
View File
@@ -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<string, string>,
context: StepContext,
) => Promise<StepOutcome> | 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<string, Handler> = {
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<void> {
await trimQueue(0, 0);
}
async function fill(count: number): Promise<number> {
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<number> {
const active = new Set<string>();
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();
});
});
+280
View File
@@ -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<T>(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<number>([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<Directory> {
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<string, number>;
/** 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<string, Person[]>();
const countByTenant = new Map<string, number>();
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 };
}
+79
View File
@@ -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<unknown>,
options: { runs?: number; beforeEach?: () => unknown | Promise<unknown> } = {},
): Promise<number> {
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<void> {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
+173
View File
@@ -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);
});
});
+117
View File
@@ -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);
});
});
+203
View File
@@ -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);
});
});
+153
View File
@@ -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<WidgetResult[]> {
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);
});
});
+90
View File
@@ -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<string> {
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<string, Array<Record<string, unknown>>>;
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<string, unknown>;
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 });
});
});
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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'],
+32
View File
@@ -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'),
},
},
});
@@ -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"
/>
)}
+8 -2
View File
@@ -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.
@@ -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',
+8 -1
View File
@@ -74,7 +74,14 @@ export function Modal({
return createPortal(
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
<div className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm" onClick={onClose} aria-hidden />
{/*
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.
*/}
<div className="fixed inset-0 bg-ink-950/85" onClick={onClose} aria-hidden />
<div
role="dialog"