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:
co-authored by
Claude Fable 5.1
parent
c25e826766
commit
ea9387bea9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,4 @@ design
|
||||
package-lock.json
|
||||
# Dokumentace ma vlastni styl tabulek, prettier by je prelamal.
|
||||
*.md
|
||||
seed
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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". |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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$...' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user