Seed z repozitare, zatezove testy, worker bez spanku, dialogy bez rozmazani

Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti
nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor
nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu,
akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript
npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje.

Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety
a zatezovy skript (npm run load) proti bezici instanci vcetne davky
udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech
mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted
ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop
posluchacu streamu zvednut na 2 000.

Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive
pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou
animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s.

Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 20:41:42 +02:00
co-authored by Claude Fable 5.1
parent c25e826766
commit ea9387bea9
44 changed files with 3512 additions and 28 deletions
+12 -1
View File
@@ -82,7 +82,8 @@ React aplikaci ze slozky `dist/public`.
| Struktura podle zasad | hotovo | `index.ts` a `app.ts`, routy a data po slozkach, `connectors/` |
| Lint a formatovani v repu | hotovo | eslint a prettier, `npm run lint` cisty bez vyjimek |
| Prisny TypeScript | hotovo | `noUncheckedIndexedAccess` v obou tsconfig, zadne `!` |
| Testy | castecne | vitest v `tests/`, 12 souboru a 130 testu: prava, tickety, prilohy, kontakt, executor, sit, health |
| Nastaveni prezije nasazeni | hotovo | `seed/records.json` do prazdneho uloziste, `npm run seed:export` |
| Testy | castecne | vitest v `tests/`, 15 souboru a 149 testu: prava, tickety, prilohy, kontakt, executor, sit, health, seed, export |
## Znama omezeni
@@ -99,6 +100,16 @@ v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz
Rezim je jeden pro vsechna uloziste vcetne konektoru. Driv se mohlo stat, ze
konektory jely z databaze a tickety ze souboru.
Nasazeni jede v rezimu `file` bez svazku, takze redeploy maze data. Nastaveni
(firmy, ucty, role, typy, akce, widgety, rozlozeni, automatizace) to prezije
pres `seed/records.json` v repu: pri startu se nasype do prazdneho uloziste
a obnovuje se prikazem `npm run seed:export`. Je to nahrada, ne reseni:
provozni data (tickety, incidenty, audit) se ztraceji dal, konektory se musi
zadat znovu (jejich klic se meni s nasazenim), zmena v portalu bez exportu
se pri dalsim nasazeni ztrati a soubor nese hashe hesel, takze repozitar
musi zustat soukromy. Reseni je `DATABASE_URL` nebo `DATA_DIR` na svazku.
Viz [14-databaze.md](14-databaze.md).
Beh automatizaci jde pres frontu a worker: webhook odpovi 202 a strom se
vykona na pozadi, pri chybe se opakuje jen to, co muze pominout. Fronta je ale
**v pameti jednoho procesu**: vic instanci by si vzalo tentyz beh, nad
+8 -4
View File
@@ -46,14 +46,16 @@ viz [99-zmeny.md](99-zmeny.md).
| Soubor | K cemu |
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu; zadne `any`, zadny prazdny `catch` |
| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu, `scripts/**/*.mjs` jako Node ESM; zadne `any`, zadny prazdny `catch` |
| `.prettierrc`, `.prettierignore` | jednotne formatovani (jednoduche uvozovky, sirka 100) |
| `.editorconfig` | odsazeni, konce radku a kodovani pro editor |
| `.nvmrc` | Node 20, stejne jako `engines` v `package.json` |
| `.env.example` | vsechny promenne prostredi s popisem; `.env` neni v gitu |
| `vitest.config.ts` | testy z `tests/**/*.test.ts`, alias `@shared`, `tests/setup.ts` pred kazdym souborem |
| `tests/tsconfig.json` | typecheck testu nad `src/` bez emitu |
| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist` a `connectors` |
| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist`, `connectors` a `seed` |
| `seed/records.json` | nastaveni z repozitare pro prazdne uloziste, vystup `npm run seed:export`; viz [14-databaze.md](14-databaze.md) |
| `scripts/export-seed.mjs` | pomocny skript vyvoje (`npm run seed:export`): prihlasi se, stahne `GET /api/admin/export` a zapise `seed/records.json` |
## Mapa kodu - server
@@ -64,7 +66,7 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `<SLUZBA>_BASE_URL` |
| `src/openapi/index.ts` | `buildOpenApiDocument()`: sklada dokument, `servers` s prefixem proxy |
| `src/openapi/helpers.ts` | `crudPaths` a opakujici se parametry, tela a odpovedi |
| `src/openapi/components/` | schemata a zabezpeceni po domenach (`common`, `auth`, `tickets`, `attachments`, `automations`, `connectors`, `scripts`, `settings`), `index.ts` je sklada |
| `src/openapi/components/` | schemata a zabezpeceni po domenach (`common`, `auth`, `tickets`, `attachments`, `automations`, `connectors`, `scripts`, `settings`, `admin`), `index.ts` je sklada |
| `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `attachments`, `public`, `webhook` |
| `src/types.ts` | typy uzivatele a JWT payloadu |
| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web |
@@ -96,6 +98,7 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/routes/settings/catalog.ts` | co jde v nastaveni zvolit: prava, moduly, limity, widgety |
| `src/routes/settings/shared.ts` | `memberOf` pro ucty a resitele |
| `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy |
| `src/routes/admin.ts` | prepnuti na jiny ucet, audit, export nastaveni (`/api/admin/export`) |
| `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP |
| `src/routes/scripts.ts`, `tenantScripts.ts` | skripty konektoru a skripty firmy |
| `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele |
@@ -104,7 +107,8 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/routes/public.ts` | verejne udaje bez prihlaseni: `GET /api/public/brand` z provozovatele |
| `src/routes/bodyLimit.ts` | `jsonLimitFor`, `hasOwnBodyLimit`: strop tela pro routy se soubory v base64 |
| `src/ares/client.ts` | klient verejneho API ARES |
| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` |
| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores`; `seedFile.ts` (nastaveni z repa do prazdneho uloziste, `SEED_KINDS`) |
| `src/data/seedExport.ts` | `exportSeed`: vsechny zaznamy konfiguracnich druhu z ulozist pro `seed/records.json` |
| `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` |
| `src/data/ticketStore.ts` | fasada nad `src/data/tickets/`, importy zustavaji |
| `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar, poznamka o priloze), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` |
+16
View File
@@ -99,6 +99,7 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/admin/impersonate/stop` |
| GET | `/api/admin/impersonate/candidates` |
| GET | `/api/admin/audit` |
| GET | `/api/admin/export` |
Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni,
uprava, mazani) na `/api/dashboard/settings/<entita>`, protoze ji dela jedna
@@ -478,6 +479,7 @@ Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je
| prilohy ticketu (POST, DELETE) | `ticket.comment` za firmu ticketu plus strop viditelnosti |
| `/api/admin/impersonate*` | `impersonate` |
| `/api/admin/audit` | `audit.view` |
| `/api/admin/export` | `audit.view` (vraci i hashe hesel, stejna citlivost jako audit) |
| `/storage`, `/scripts` s cestami na serveru | cesty jen spravci platformy, ostatni dostanou odpoved bez nich |
Spravce firmy s `user.manage` **nenastavi `platformAdmin`**, neprida clenstvi
@@ -496,6 +498,20 @@ s 403. Kazde prepnuti i ukonceni je v auditu vcetne toho, kdo to byl doopravdy.
Svuj puvodni token si klient odklada do `sessionStorage`, server o nem nic nevi.
## Export nastaveni
`GET /api/admin/export` vraci `{ exportedAt, kinds: { <druh>: [zaznam] } }`
se vsemi zaznamy konfiguracnich druhu tak, jak lezi v ulozisti: firmy, ucty
(**vcetne `passwordHash`**), role, skupiny, moduly firem, typy ticketu,
akce, widgety, rozlozeni, automatizace, skripty firmy. Konektory, tickety,
incidenty, audit, upozorneni, prilohy ani pozvanky v nem nejsou. Kazdy
export je v auditu jako `admin.export` s pocty po druzich.
Je to zdroj pro `seed/records.json`, ktery se pri startu nasype do prazdneho
uloziste; stahuje ho `npm run seed:export` (`scripts/export-seed.mjs`).
Proc a co to obnasi je v [14-databaze.md](14-databaze.md), sekce Nastaveni
v repozitari.
## Sluzby a konektory
Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API.
+67
View File
@@ -49,6 +49,7 @@ Psat do rozbiteho schematu je horsi nez psat do souboru.
| `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 |
| `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS |
| `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor |
| `SEED_FILE` | nastaveni z repozitare pro prazdne uloziste, vychozi `./seed/records.json`. Prazdna hodnota vypne |
`SECRETS_KEY` ma byt nahodny retezec, ne heslo:
@@ -100,6 +101,72 @@ a nic se neztrati. Chyba cteni ale neznamena, ze data neexistuji - a start
s prazdnem, ktery by je pri prvnim zapisu prepsal, je jedina cesta, jak
o ne v rezimu `file` opravdu prijit.
## Nastaveni v repozitari (seed/records.json)
Nasazeni bezi v rezimu `file` bez svazku, takze kazdy redeploy smaze data
a spravce platformy zadaval znovu firmu provozovatele, ucty, widgety
a rozlozeni. Spravne reseni je `DATABASE_URL` nebo `DATA_DIR` na svazku,
obe jsou vec infrastruktury mimo tento repozitar. Do te doby se konfigurace
drzi v repu a pri startu se nasype sama.
**Proc soubor v repu a ne seed v kodu.** Seed v kodu je ukazka pro prazdnou
instalaci a meni se jen s kodem. Nastaveni provozovatele se meni v portalu
a nikdo ho nema prepisovat do TypeScriptu. Export je jeden prikaz a vysledek
je data, ne kod.
| Co | Jak |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| soubor | `seed/records.json`, cesta z `SEED_FILE` (`src/config.ts`), v gitu |
| tvar | `{ "exportedAt": "...", "kinds": { "<druh>": [zaznam, ...] } }`, druh je `store.kind` |
| kdy se pouzije | **jen do prazdneho uloziste**, presne tam, kde by se pouzil seed z kodu (`store.init`) |
| co vyhrava | druh v souboru ma prednost pred kodem, i kdyz je prazdny (`[]` = spravce je smazal) |
| chybejici soubor | ticho, jede se ze seedu v kodu (bezny stav pri vyvoji) |
| rozbity soubor | `console.error` a seed z kodu; nikdy nepada start |
| druh mimo konfiguraci | ignoruje se s varovanim |
| log | `[seed] <druh>: N zaznamu ze souboru` nebo `[seed] <druh>: kod`, jen u prazdneho uloziste |
Rozhodnuti "soubor, nebo kod" je na jednom miste: `seedFromFile(kind, seed)`
v `src/data/store/seedFile.ts`, ktere vola `defineStore` v `init`. Uloziste
(`local.ts`, `postgres.ts`) o souboru nevi, `bootstrap.ts` se nemeni
a plati to pro vsechny tri rezimy vcetne `withMirror` (automatizace,
rozlozeni). Automatizace ze souboru prochazi stejnym `withDerived` jako ty
z kodu, nedodelky a pocet kroku se pri startu prepocitaji.
**Co v souboru je**: `tenant`, `user`, `role`, `personGroup`,
`tenantFeatures`, `ticketType`, `ticketAction`, `customWidget`,
`dashboardLayout`, `automation`, `tenantScript` (seznam `SEED_KINDS`).
**Co v nem neni a proc**:
| Druh | Proc ne |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| tickety, incidenty, audit, upozorneni, prilohy, fronta | provozni data, vznikaji provozem; v repu nemaji co delat |
| konektory | udaje jsou sifrovane klicem, ktery se bez `SECRETS_KEY` meni s nasazenim |
| pozvanky | maji platnost a kod, po nasazeni jsou stejne prosle |
**Hash hesla.** Export bere ucty tak, jak lezi v ulozisti, tedy vcetne
`passwordHash` (bcrypt). Bez nej by se po nasazeni nikdo neprihlasil.
Bcrypt neni plaintext, ale hash v gitu je hash v gitu: **repozitar musi
zustat soukromy** a tokeny firem (`intakeToken`, webhooky automatizaci)
jsou v nem take. Kdo to nechce, nastavi `DATABASE_URL` a soubor smaze.
**Export**: `GET /api/admin/export` (spravce platformy, pravo `audit.view`,
audit `admin.export`) vraci stejny tvar primo z ulozist (`listAll`).
Skript `scripts/export-seed.mjs` se prihlasi a soubor zapise:
```bash
SEED_EXPORT_URL=https://services.csbot.cz/apps/csbot-prototype SEED_EXPORT_EMAIL=admin@... SEED_EXPORT_PASSWORD=... npm run seed:export
```
Vypise pocty po druzich, zapisuje UTF-8 bez BOM s LF. Po exportu se soubor
commitne a dalsi nasazeni z nej vyjde. Zmena v portalu, ktera se
nevyexportuje, se pri dalsim nasazeni ztrati - to je cena za chybejici
databazi, ne vlastnost seedu.
Testy: `tests/data/seedFile.test.ts` (rezim souboru nad docasnou slozkou),
`tests/routes/adminExport.test.ts`. V testech je `SEED_FILE` prazdny, aby
nezavisely na obsahu repa.
## Vrstvy nad ulozistem
Dve obalky, kazda pro jiny druh dat (podrobne
+4
View File
@@ -21,6 +21,10 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
| `nowIso`, `minutesAgo`, `highestNumber`, `writableOrWarn` | `src/data/store/types.ts` | Drobnosti pro moduly úložišť: časová značka, čas před N minutami, nejvyšší číslo ID pro čítač, varování při zápisu do úložiště jen pro čtení. |
| `mergeValues(current, patch)` | `src/data/connectors/types.ts` | Sloučení hodnot konektoru při `PATCH`: prázdný řetězec maže, chybějící klíč nechává. Jedna implementace pro soubor i Postgres. |
| `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. |
| `storeByKind(kind)` | `src/data/store/index.ts` | Uloziste podle druhu z registru. Jen pro export nastaveni; modul entity si bere svoje uloziste primo. |
| `seedFromFile(kind, seed?, file?)` | `src/data/store/seedFile.ts` | Seed pro prazdne uloziste: ze `seed/records.json`, kdyz druh obsahuje, jinak z kodu. Vola `defineStore` v `init`, jinde se nevola. |
| `loadSeedFile(file)`, `SEED_KINDS`, `isSeedKind` | `src/data/store/seedFile.ts` | Nacteni a kontrola souboru (jednou za cestu, zod na obalku a `id`), seznam konfiguracnich druhu. `SEED_KINDS` je jediny seznam pro import i export. |
| `exportSeed()` | `src/data/seedExport.ts` | Vsechny zaznamy konfiguracnich druhu z ulozist ve tvaru `seed/records.json`. Pouziva `GET /api/admin/export`. |
| `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. |
| `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). |
| `isVisible(entity, options)` | `src/data/store/types.ts` | Vidí volající tenhle záznam? Prázdný seznam firem znamená "nic", ne "vše". |
+135
View File
@@ -158,3 +158,138 @@ akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví
a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila.
Na jednu firmu s desítkami ticketů denně je současný stav použitelný.
Na 200 firem ne.
## Měření 2026-09-09
Dvě sady měření, obě spustitelné znovu:
- **`npm run test:perf`** - výkonové testy v procesu (`tests/perf/`, konfigurace
`vitest.perf.config.ts`). Postaví si v paměti 200 firem, 1 000 účtů,
50 vlastních rolí a 10 000 ticketů a měří jednotlivé funkce bez HTTP.
Každý scénář se měří třikrát a bere se medián; rozpočet je 4x až 5x nad
hodnotou z tohoto měření, aby test nepadal na pomalejším stroji, ale
chytil řádový propad. Do `npm test` tyto testy nepatří (vitest.config.ts
je vylučuje), trvají zhruba 12 s.
- **`npm run load`** - zátěžový test přes HTTP (`scripts/load-test.mjs`).
Virtuální uživatelé dělají to, co prohlížeč po otevření přehledu: práva,
seznam ticketů, detail, souhrn, data widgetů výchozího rozložení, lidé.
Každý desátý drží otevřený živý stream (SSE). Proměnné `LOAD_URL`,
`LOAD_USERS`, `LOAD_DURATION_SEC`, `LOAD_EMAIL`, `LOAD_PASSWORD`;
`LOAD_WEBHOOK_TOKEN` (a `LOAD_WEBHOOK_EVENTS`, výchozí 2 000) přidá dávku
na `/webhook/<token>` a čeká, až fronta doběhne; `LOAD_START_LOCAL=1`
spustí `node dist/index.js` na volném portu s dočasným `DATA_DIR`
(režim souboru) a na konci ho ukončí. Výsledek jde na terminál a do
`load-report.json` v pracovním adresáři (je v `.gitignore`). Přihlášení
je jedno pro všechny uživatele, protože server pouští z jedné adresy
20 přihlášení za čtvrt hodiny.
Měřeno na vývojovém stroji (Windows, Node 22), tedy horní hranice latence.
### V procesu (`npm run test:perf`)
| Scénář | Medián | Rozpočet | Poznámka |
| ---------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------- |
| Založení 10 000 ticketů (`createTicket` + `intakeEvent`) | 337 ms | - | 29 700 ticketů/s včetně zápisu do úložiště |
| `listTickets` jedné firmy nad 10 000 tickety, 100x | 17 ms | 100 ms | 0,17 ms na volání, filtr jde přes celé pole |
| `listTickets` s filtrem stav+kanál+řešitel a stránkou, 100x | 14 ms | 100 ms | |
| `getWorkload`, 100x | 20 ms | 100 ms | |
| `getAgentStats` za 30 dní, 100x | 24 ms | 120 ms | |
| `intakeEvent` 1 000 událostí na existující tickety | 13 ms | 80 ms | 78 500 událostí/s, polovina jako opakování |
| `findByExternalId`, 10 000x | 12 ms | 60 ms | index, ne pole |
| `permissionsOf` studené, 1 997 členství | 11 ms | 60 ms | po zahození cache |
| `hasPermission`, 100 000x | 24 ms | 120 ms | 4,2 mil. volání/s z cache |
| `accessFor`, 10 000x | 49 ms | 250 ms | 205 000 volání/s |
| `accessFor` správce platformy přes 200 firem, 1 000x | 91 ms | 400 ms | 0,09 ms na volání, řadí 200 firem `localeCompare` |
| `runFlow` 100 běhů (podmínka + smyčka 200 x 4 kroky) | 229 ms | 1 000 ms | 350 000 kroků/s, kroky jsou atrapy |
| `enqueue` 5 000 běhů | 84 ms | 1 000 ms | 59 000 běhů/s |
| `claimBatch` + `markDone` 5 000 běhů po 4 | 486 ms | 2 000 ms | 10 300 běhů/s, worker bez práce |
| `enqueue` s klíčem proti dvojímu zařazení nad 5 000 čekajícími, 1 000x | 32 ms | 150 ms | lineární hledání klíče |
| `POST /widget-data` s 24 dlaždicemi nad 10 000 tickety | 6,6 ms | 30 ms | přes supertest, včetně autorizace |
| `POST /widget-data` výchozí rozložení (2 dlaždice) | 2,7 ms | 15 ms | |
| `publish` 1 000 událostí pro 500 posluchačů | 11 ms | 60 ms | 0,02 us na doručení, cena na klienta neroste |
Co z toho plyne:
- Datová vrstva v paměti je při 10 000 ticketech **rychlá**: výpis 0,17 ms,
přehled s 24 dlaždicemi 7 ms. Lineární průchod polem je při tomto objemu
levný, protože filtr na firmu je jedno porovnání řetězce na ticket.
Problém z kapitoly výše (miliony ticketů) tím nezmizel, jen začíná
o dva řády dál, než se odhadovalo.
- Práva jsou zadarmo: cache za dvojici účet a firma drží 5 s a studený
výpočet je 6 us. `accessFor` se počítá jednou za request (`attachAccess`)
a stojí 5 us, u správce platformy 90 us kvůli řazení 200 firem.
- Fronta a executor stíhají řádově tisíce běhů za sekundu, když kroky
nečekají na cizí službu. Skutečný strop je jinde, viz zátěž níže.
- Sběrnice událostí doručí 1 000 událostí 500 klientům za 11 ms. Při 500
posluchačích ale Node vypíše `MaxListenersExceededWarning`, protože
`bus.ts` nastavuje strop 200. Je to jen varování, doručení funguje;
při větším počtu spojení je třeba strop zvednout nebo klienty sdružit.
### Zátěž přes HTTP (`npm run load`)
`LOAD_START_LOCAL=1 LOAD_USERS=100 LOAD_DURATION_SEC=20 LOAD_WEBHOOK_TOKEN=... npm run load`,
sestavená aplikace v režimu souboru s ukázkovými daty (`SEED_DEMO=1`, tedy
desítky ticketů, ne tisíce). Sto virtuálních uživatelů bez prodlevy mezi
požadavky, 10 otevřených streamů, bez databáze, jeden proces.
| Endpoint | Požadavků | Chyb | p50 ms | p95 ms | p99 ms | max ms |
| -------------------------------------- | --------- | ---- | ------ | ------ | ------ | ------ |
| `GET /api/dashboard/access` | 9 500 | 0 | 31,7 | 35,0 | 42,1 | 69,1 |
| `GET /api/dashboard/tickets?limit=50` | 9 500 | 0 | 34,1 | 38,3 | 41,9 | 76,4 |
| `GET /api/dashboard/tickets/:id` | 9 500 | 0 | 35,4 | 38,8 | 42,5 | 59,5 |
| `GET /api/dashboard/summary` | 9 500 | 0 | 36,6 | 40,0 | 43,1 | 49,2 |
| `POST /api/dashboard/widget-data` | 9 500 | 0 | 39,1 | 42,1 | 44,9 | 49,5 |
| `GET /api/dashboard/people` | 9 500 | 0 | 35,5 | 39,7 | 41,3 | 45,1 |
| `POST /webhook/:token` (dávka po běhu) | 2 000 | 0 | 16,0 | 20,3 | 24,9 | 28,8 |
Celkem **57 000 požadavků za 20 s = 2 830 req/s, 0 chyb**. SSE: 10 spojení,
41 730 přijatých událostí, 0 chyb. Paměť serveru (RSS): nejvýš 198 MB,
na konci 116 MB.
Co ta čísla znamenají:
- Latence 32 až 39 ms při 100 souběžných uživatelích je **čekání ve frontě
jednoho vlákna**, ne práce: 100 uživatelů / 2 830 req/s dává 35 ms.
Server sám stráví na požadavku zhruba 0,35 ms včetně JWT, výpočtu práv
a logu na stdout. S 10 uživateli by p50 bylo pod 5 ms.
- Rozdíl mezi endpointy je malý, protože ukázková data jsou malá. Objemová
stránka je v tabulce výše: seznam nad 10 000 tickety přidá 0,17 ms,
přehled s 24 dlaždicemi 7 ms.
- `/health` paměť nevystavuje; skript ji čte ze systému jen u lokálně
spuštěného serveru. 198 MB při 100 uživatelích a 10 streamech je
v pořádku, špička byla během dávky webhooku (zápis ticketů do souboru).
**Dávka 2 000 událostí na webhook:** přijato 2 000 za 1,7 s (server odpoví
202 a zařadí do fronty), ale fronta se **nevyprázdnila ani za 180 s**:
hotovo 724, tedy **4 běhy za sekundu**. To není úložiště - v procesu zvládá
`claimBatch` + `markDone` 10 000 běhů/s - ale smyčka workeru
(`src/runtime/worker.ts`, funkce `loop`): když jsou všechna čtyři místa
(`CONCURRENCY`) obsazená, `claimBatch` vrátí prázdno a smyčka spí `IDLE_MS`
(1 s), i když se místo uvolní za pár milisekund. Strop je proto
`CONCURRENCY / IDLE_MS` = 4 běhy/s bez ohledu na to, jak rychlé kroky jsou.
Při 200 firmách a 100 bězích denně na automatizaci (7 za sekundu v průměru,
100 ve špičce) fronta poroste. Oprava je čekat na uvolnění místa
(`Promise.race` nad běžícími běhy), ne na časovač; spát jen když je fronta
opravdu prázdná.
**Po opravě (tentýž den):** worker při plném bazénu čeká na první dokončený
běh. Kontrolní měření s dávkou 500 událostí (`LOAD_USERS=20`,
`LOAD_DURATION_SEC=10`): přijato za 251 ms, **fronta prázdná za 1,3 s, tedy
395 běhů za sekundu** (hotovo 500, selhalo 0), při souběžných 2 825
dotazech za sekundu od dvaceti uživatelů. Strop už neurčuje smyčka, ale
délka kroků a `CONCURRENCY`.
### Co se změní na Postgresu
- Zápis ticketu je jeden řádek, ne přepis celého `ticket.json`. Špička
paměti při dávce webhooku (198 MB) tím spadne, protože `snapshot.ts`
kopíruje při každém `save` celé pole záznamů, i když zápis na disk je
sloučený.
- `listTickets` přestane být průchod polem v paměti a stane se dotazem
s indexem: 0,17 ms nad 10 000 tickety se vymění za jednotky milisekund
bez ohledu na objem, tedy u malých dat pomaleji, u milionů jedině možné.
- `claimBatch` musí jít přes `SELECT ... FOR UPDATE SKIP LOCKED` (víc
workerů). Strop 4 běhy/s ze smyčky workeru se tím **neopraví**, ten je
v kódu smyčky, ne v úložišti.
- Práva, přístup a sběrnice se nemění: kopie konfigurace v paměti zůstává,
jen se obnovuje přes `LISTEN/NOTIFY`.
+7
View File
@@ -92,6 +92,13 @@ adresa včetně domény**.
na ostatni. Prvni verze brala davku a cekala, az dobehne cela: jeden pomaly
krok MCP na deset minut blokoval tri prazdne sloty.
Kdyz jsou vsechna mista obsazena, worker **ceka na prvni dokonceny beh**
(`Promise.race` nad bezicimi promisy), ne na casovac. Spi (`IDLE_MS`, 1 s)
jen kdyz je fronta prazdna. Verze se spanim i pri plnem bazenu mela strop
presne `CONCURRENCY` behu za sekundu: zatezovy test odbavil z 2 000 udalosti
webhooku za tri minuty jen 724, i kdyz fronta sama zvlada tisice behu za
sekundu (`npm run test:perf`).
Bezici beh posila kazdou minutu **tlukot** (`touchClaim`). Za zaseknuty se
povazuje az 30 minut od posledniho tlukotu (`STUCK_AFTER_MS` v `queue.ts`),
ne od vzeti z fronty. Driv to bylo deset minut od vzeti, takze beh s dlouhou
+10 -2
View File
@@ -199,8 +199,16 @@ Rozmazani patri jen tam, kde pod nim neco opravdu projizdi:
| -------------------------- | ----------------- |
| lepici hlavicka dashboardu | `backdrop-blur-xl` |
| lepici hlavicka webu | `backdrop-blur-xl` |
| podklad modalu | `backdrop-blur-sm` |
| prekryv menu na mobilu | `backdrop-blur-sm` |
| prekryv menu na verejnem webu | `backdrop-blur-xl` |
Podklad modalu a prekryv menu v portalu rozmazani **nemaji**: je to vrstva
pres celou obrazovku a pod ni lezi hlavicka, ktera rozmazana je. Kazda
animace pod dialogem (pulz tecky "Zive", spinner, obnova seznamu) pak
nutila prohlizec prepocitat rozmazani cele plochy, jeste pres to druhe -
a vedle portalu sekalo video pri kazdem otevrenem dialogu. Tmavy prekryv
s vyssim krytim (`bg-ink-950/85`) dela tutez praci zadarmo. Ze stejneho
duvodu tecka "Zive" nepulzuje: pulz ma jen stav, kdy se spojeni teprve
navazuje nebo obnovuje.
**Zadna nekonecna animace nad velkou rozmazanou plochou.** Zare na prihlaseni
a na webu jsou staticke: animovat pruhlednost objektu s `blur(120px)` znamena
+81
View File
@@ -2,6 +2,87 @@
Nejnovejsi nahore.
## 2026-09-09 - Vykonnostni a zatezove testy, worker uz nespi pri plnem bazenu
Aplikace ma zvladat stovky uzivatelu a tisice kroku automatizaci, ale merit
to nebylo cim. Pribyly dve sady:
- `npm run test:perf` (`tests/perf/*`, `vitest.perf.config.ts`): horka
mista v procesu nad syntetickymi daty (200 firem, 1 000 uctu, 10 000
ticketu): seznamy a filtry ticketu, vytizeni a statistiky, prijem udalosti
s deduplikaci, prava a pristup, beh stromu se smyckou, fronta (zarazeni
a odbaveni 5 000 behu), data widgetu pro 24 dlazdic, sbernice udalosti pro
500 klientu. Kazdy test tiskne median ze tri behu a hlida rozpocet 3 az 5x
nad zmerenym casem, aby nekolisal. Mimo `npm test`, bezi na vyzadani.
- `npm run load` (`scripts/load-test.mjs`, bez zavislosti): zatez proti
bezici instanci (lokalni build i Postgres): virtualni uzivatele s realnou
smesi dotazu, otevrene SSE, volitelne davka udalosti webhooku a mereni,
jak rychle ji fronta odbavi. Vystup p50/p95/p99 na endpoint, propustnost,
chyby, `load-report.json`. Cisla z prvniho mereni jsou
v [19-kapacita-200-firem.md](19-kapacita-200-firem.md): 100 uzivatelu,
2 800 dotazu za sekundu, 0 chyb, p99 pod 45 ms v rezimu souboru.
Mereni naslo skutecnou brzdu: `worker.ts` po obsazeni vsech ctyr mist spal
sekundu (`IDLE_MS`) a pak teprve zkusil frontu znovu, takze strop byl presne
4 behy za sekundu bez ohledu na to, jak rychle behy konci. Davka 2 000
udalosti webhooku se za tri minuty odbavila jen z 724. Ted worker pri plnem
bazenu ceka na prvni dokonceny beh (`Promise.race` nad bezicimi) a spi jen
pri prazdne fronte. Strop posluchacu sbernice zvednut z 200 na 2 000
(`MAX_STREAM_LISTENERS`), 500 otevrenych portalu uz nehlasi varovani.
Sekani videa vedle portalu s tim nesouviselo: bylo to rozmazani cele
obrazovky pod dialogem, viz zaznam nize.
## 2026-09-09 - Nastaveni prezije nasazeni: seed z repozitare
Nasazeni bezi v rezimu `file` bez svazku a bez `DATABASE_URL`, takze kazdy
redeploy smazal data a spravce platformy zadaval znovu provozovatele, ucty,
widgety a rozlozeni. Spravne reseni je databaze nebo svazek (infrastruktura
mimo repo). Do te doby se konfigurace drzi v repu a pri startu se nasype sama.
| Co | Jak |
| ------- | -------------------------------------------------------------------------------------------- |
| soubor | `seed/records.json` (`SEED_FILE`, vychozi `./seed/records.json`, prazdne = vypnuto) |
| tvar | `{ exportedAt, kinds: { <druh>: [zaznam] } }`, druh je `store.kind` |
| druhy | `tenant`, `user`, `role`, `personGroup`, `tenantFeatures`, `ticketType`, `ticketAction`, `customWidget`, `dashboardLayout`, `automation`, `tenantScript` (`SEED_KINDS`) |
| kdy | jen do prazdneho uloziste, na stejnem miste jako seed z kodu; druh v souboru vyhrava, i prazdny |
| kde | `seedFromFile(kind, seed)` v `src/data/store/seedFile.ts`, vola ho `defineStore` v `init`; uloziste ani `bootstrap.ts` se nemeni |
| chyby | chybejici soubor ticho, rozbity soubor `console.error` a kod, cizi druh varovani |
| export | `GET /api/admin/export` (spravce platformy, `audit.view`, audit `admin.export`), `exportSeed` v `src/data/seedExport.ts` |
| skript | `npm run seed:export` (`scripts/export-seed.mjs`, cisty Node): `SEED_EXPORT_URL`, `SEED_EXPORT_EMAIL`, `SEED_EXPORT_PASSWORD` |
Co v souboru neni: konektory (udaje sifrovane klicem, ktery se meni
s nasazenim), pozvanky (prosle), tickety, incidenty, audit, upozorneni,
prilohy, fronta. Ucty jsou v nem **vcetne `passwordHash`**, jinak by se po
nasazeni nikdo neprihlasil; repozitar proto musi zustat soukromy.
Testy: `tests/data/seedFile.test.ts` (9, rezim souboru nad docasnou
slozkou: prazdne uloziste bere soubor, neprazdne si necha svoje, prazdny
druh vyhrava, cizi druh se ignoruje, rozbity a chybejici soubor),
`tests/routes/adminExport.test.ts` (5: 401, 403, druhy a hashe, bez
konektoru a provoznich dat, audit). V `tests/setup.ts` je `SEED_FILE`
prazdny, aby testy v pameti nezavisely na obsahu repa. Nove `scripts/**/*.mjs`
v eslintu jako Node ESM, `SEED_FILE` v `.env.example`.
## 2026-09-09 - Otevreny dialog sekal video (podruhe)
Po predchozi oprave (`.glass` bez rozmazani) zustalo rozmazani na podkladu
modalu a na prekryvu menu v portalu. Obe vrstvy jsou pres celou obrazovku
a pod nimi lezi lepici hlavicka, ktera je rozmazana taky. V hlavicce navic
trvale pulzovala tecka "Zive" (`animate-pulse`, nekonecna animace). Vysledek:
s kazdym otevrenym dialogem prohlizec 60x za sekundu prepocitaval rozmazani
cele plochy, vnorene dvakrat, a vedle portalu sekalo video.
Podklad modalu i prekryv menu jsou ted jen tmava plocha bez `backdrop-blur`
(`Modal.tsx`, `DashboardLayout.tsx`). Tecka "Zive" pulzuje jen pri
navazovani a obnove spojeni, ve stavu "Zive" stoji (`LiveIndicator.tsx`).
Rozmazani zustava jen na lepicich hlavickach a na menu verejneho webu,
viz [22-znacka-a-design.md](22-znacka-a-design.md).
Bublina o udalosti (`EventToasts.tsx`) drzi pul sekundy misto sesti
(rozhodnuti zadavatele): je to zablesk "neco se stalo", podrobnosti jsou
v seznamu, na ktery ukazuje, a cislo u zalozky zustava.
## 2026-09-09 - Kolacovy graf ve vlastnim widgetu
Vlastni widget s kreslenim "Graf" bral jen casovou radu; pokus o graf