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
+741
View File
@@ -0,0 +1,741 @@
// @ts-check
/**
* Zatezovy test portalu pres HTTP.
*
* Virtualni uzivatele se prihlasi a v smycce delaji to, co dela prohlizec
* po otevreni prehledu: prava, seznam ticketu, detail, souhrn, data widgetu
* a lide. Kazdy desaty uzivatel drzi otevreny zivy stream (SSE). Na konci
* se vypise tabulka s p50/p95/p99 za endpoint, propustnost a chybovost
* a totez se zapise do `load-report.json` v pracovnim adresari (je v .gitignore).
*
* Obycejny Node bez zavislosti (`fetch`, `perf_hooks`), aby sel spustit
* i bez `npm ci`. Cisla a jak je cist jsou v documentation/19-kapacita-200-firem.md.
*
* Promenne prostredi:
* LOAD_URL adresa serveru, vychozi http://127.0.0.1:3000
* LOAD_EMAIL, LOAD_PASSWORD ucet, vychozi demo spravce platformy
* LOAD_USERS pocet virtualnich uzivatelu, vychozi 100
* LOAD_DURATION_SEC delka behu, vychozi 30
* LOAD_WEBHOOK_TOKEN kdyz je nastaven, posle se navic davka udalosti na
* /webhook/<token> a ceka se, az fronta dobehne
* LOAD_WEBHOOK_EVENTS velikost davky, vychozi 2000
* LOAD_START_LOCAL=1 spusti `node dist/index.js` na volnem portu s docasnym
* DATA_DIR (rezim souboru) a na konci ho ukonci
*
* Prihlaseni je jedno pro vsechny uzivatele: server pousti z jedne adresy
* 20 prihlaseni za ctvrt hodiny a sto virtualnich uzivatelu by narazilo.
*/
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ------------------------------------------------------------------ nastaveni
const DEFAULT_URL = 'http://127.0.0.1:3000';
const DEFAULT_EMAIL = 'admin@automia.cz';
const DEFAULT_PASSWORD = 'demo1234';
const DEFAULT_USERS = 100;
const DEFAULT_DURATION_SEC = 30;
const DEFAULT_WEBHOOK_EVENTS = 2_000;
/** Jeden zivy stream na deset uzivatelu, jako kdyz ma kazdy desaty otevreny prehled. */
const USERS_PER_STREAM = 10;
/** Stranka seznamu ticketu jako v portalu. */
const TICKET_PAGE_LIMIT = 50;
/**
* Dlazdice, ktere si vychozi rozlozeni pocita na serveru. Ostatni dlazdice
* vychoziho rozlozeni berou data ze souhrnu a seznamu, viz
* src/data/dashboardLayouts.ts a COMPUTED_BUILTINS v web/src/pages/dashboard/Overview.tsx.
*/
const DEFAULT_LAYOUT_WIDGET_IDS = ['list.myTickets', 'list.unassigned'];
/** Kolik udalosti webhooku leti naraz. Odesilatel (e-shop) taky neceka na odpoved. */
const WEBHOOK_CONCURRENCY = 20;
/** Jak casto se pta na stav fronty a jak dlouho se na vyprazdneni ceka nejvys. */
const QUEUE_POLL_MS = 500;
const QUEUE_DRAIN_TIMEOUT_MS = 180_000;
/** Cekani na start lokalniho serveru. */
const HEALTH_WAIT_MS = 30_000;
const HEALTH_POLL_MS = 250;
/** Jak casto se odecita pamet lokalniho serveru. */
const MEMORY_SAMPLE_MS = 2_000;
/** Kolik se ceka na ukonceni lokalniho serveru, nez se zabije natvrdo. */
const STOP_TIMEOUT_MS = 10_000;
const REPORT_FILE = 'load-report.json';
/** System hlasi pamet v kB, tabulka ji ukazuje v MB. */
const KB_PER_MB = 1024;
const MS_PER_SECOND = 1_000;
/**
* @param {string} name
* @param {number} fallback
* @returns {number}
*/
function envNumber(name, fallback) {
const parsed = Number(process.env[name]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
const settings = {
url: (process.env.LOAD_URL || DEFAULT_URL).replace(/\/+$/, ''),
email: process.env.LOAD_EMAIL || DEFAULT_EMAIL,
password: process.env.LOAD_PASSWORD || DEFAULT_PASSWORD,
users: envNumber('LOAD_USERS', DEFAULT_USERS),
durationSec: envNumber('LOAD_DURATION_SEC', DEFAULT_DURATION_SEC),
webhookToken: (process.env.LOAD_WEBHOOK_TOKEN ?? '').trim(),
webhookEvents: envNumber('LOAD_WEBHOOK_EVENTS', DEFAULT_WEBHOOK_EVENTS),
startLocal: process.env.LOAD_START_LOCAL === '1',
};
// -------------------------------------------------------------------- mereni
/** @typedef {{ count: number, errors: number, latencies: number[] }} EndpointStats */
/** @type {Map<string, EndpointStats>} */
const endpoints = new Map();
/**
* @param {string} name
* @param {number} ms
* @param {boolean} ok
*/
function record(name, ms, ok) {
let stats = endpoints.get(name);
if (!stats) {
stats = { count: 0, errors: 0, latencies: [] };
endpoints.set(name, stats);
}
stats.count += 1;
if (ok) stats.latencies.push(ms);
else stats.errors += 1;
}
/**
* @param {number[]} sorted
* @param {number} share 0 az 1
* @returns {number}
*/
function percentile(sorted, share) {
if (sorted.length === 0) return 0;
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * share) - 1));
return sorted[index] ?? 0;
}
/**
* @param {EndpointStats} stats
*/
function summarise(stats) {
const sorted = [...stats.latencies].sort((a, b) => a - b);
return {
count: stats.count,
errors: stats.errors,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
p99: percentile(sorted, 0.99),
max: sorted[sorted.length - 1] ?? 0,
};
}
/**
* Jedno volani s merenim. Chyba site i odpoved mimo 2xx se pocita jako chyba
* a nevyhazuje se - jeden spadly request nesmi zastavit virtualniho uzivatele.
* @param {string} name popisek endpointu v tabulce
* @param {string} url
* @param {RequestInit} init
* @returns {Promise<unknown>} telo jako JSON, nebo null pri chybe
*/
async function call(name, url, init) {
const start = performance.now();
try {
const response = await fetch(url, init);
const body = await response.json().catch(() => null);
record(name, performance.now() - start, response.ok);
if (!response.ok) return null;
return body;
} catch (err) {
record(name, performance.now() - start, false);
noteError(name, err);
return null;
}
}
/** @type {Map<string, number>} */
const errorKinds = new Map();
/**
* @param {string} name
* @param {unknown} err
*/
function noteError(name, err) {
const key = `${name}: ${err instanceof Error ? err.message : String(err)}`;
errorKinds.set(key, (errorKinds.get(key) ?? 0) + 1);
}
// ------------------------------------------------------------ virtualni uzivatel
/**
* @param {string} token
* @returns {Record<string, string>}
*/
function authHeaders(token) {
return { Authorization: `Bearer ${token}` };
}
/**
* @param {string} base
* @param {string} token
* @returns {Promise<string>}
*/
async function login(base, token) {
if (token) return token;
const response = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: settings.email, password: settings.password }),
});
if (!response.ok) {
throw new Error(`prihlaseni selhalo: ${response.status} ${await response.text()}`);
}
const body = /** @type {{ token: string }} */ (await response.json());
return body.token;
}
/**
* Smycka jednoho uzivatele: stejna posloupnost, jakou dela prehled v prohlizeci.
* @param {string} base
* @param {string} token
* @param {number} deadline performance.now(), kdy skoncit
*/
async function virtualUser(base, token, deadline) {
const headers = authHeaders(token);
const jsonHeaders = { ...headers, 'Content-Type': 'application/json' };
/** @type {string[]} */
let ticketIds = [];
while (performance.now() < deadline) {
await call('GET /api/dashboard/access', `${base}/api/dashboard/access`, { headers });
const list = /** @type {{ items?: Array<{ id: string }> } | null} */ (
await call(
`GET /api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`,
`${base}/api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`,
{ headers },
)
);
if (list?.items) ticketIds = list.items.map((item) => item.id);
const ticketId = ticketIds[Math.floor(Math.random() * ticketIds.length)];
if (ticketId) {
await call('GET /api/dashboard/tickets/:id', `${base}/api/dashboard/tickets/${ticketId}`, {
headers,
});
}
await call('GET /api/dashboard/summary', `${base}/api/dashboard/summary`, { headers });
await call('POST /api/dashboard/widget-data', `${base}/api/dashboard/widget-data`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ widgetIds: DEFAULT_LAYOUT_WIDGET_IDS }),
});
await call('GET /api/dashboard/people', `${base}/api/dashboard/people`, { headers });
}
}
// ------------------------------------------------------------------ zivy stream
/** @typedef {{ opened: number, events: number, errors: number }} StreamStats */
/**
* Drzi jedno spojeni SSE az do zruseni a pocita prijate udalosti.
* @param {string} base
* @param {string} token
* @param {AbortSignal} signal
* @param {StreamStats} stats
*/
async function holdStream(base, token, signal, stats) {
try {
const response = await fetch(`${base}/api/dashboard/stream`, {
headers: authHeaders(token),
signal,
});
if (!response.ok || !response.body) {
stats.errors += 1;
return;
}
stats.opened += 1;
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
stats.events += text.split('\n').filter((line) => line.startsWith('event:')).length;
}
} catch (err) {
// Zruseni na konci behu je ocekavane, cokoliv jineho je chyba spojeni.
if (!signal.aborted) {
stats.errors += 1;
noteError('SSE /api/dashboard/stream', err);
}
}
}
// ---------------------------------------------------------------- davka webhooku
/**
* Stav fronty za vsechny firmy (ucet je spravce platformy, jinak jen za jeho firmy).
* @param {string} base
* @param {string} token
* @returns {Promise<{ pending: number, running: number, failed: number, done: number }>}
*/
async function queueStats(base, token) {
const response = await fetch(`${base}/api/dashboard/runs?scope=all&limit=1`, {
headers: authHeaders(token),
});
if (!response.ok) throw new Error(`stav fronty: ${response.status}`);
const body =
/** @type {{ stats: { pending: number, running: number, failed: number, done: number } }} */ (
await response.json()
);
return body.stats;
}
/**
* Telo udalosti podle kontraktu webhooku (`GET /webhook/<token>` vraci ukazku).
* Textova pole, ktera vypadaji jako identifikator, dostanou unikatni hodnotu,
* aby kazda udalost byla nova a nesla na jeden ticket.
* @param {unknown} example
* @param {Array<{ name: string, path: string, type: string }>} expects
* @param {number} index
* @returns {Record<string, unknown>}
*/
function eventBody(example, expects, index) {
const body = /** @type {Record<string, unknown>} */ (
JSON.parse(JSON.stringify(example && typeof example === 'object' ? example : {}))
);
for (const field of expects) {
if (field.type !== 'string' || !/id$/i.test(field.name)) continue;
const segments = field.path.split('.');
const last = segments.pop();
if (last === undefined) continue;
let target = body;
for (const key of segments) {
const next = target[key];
if (typeof next !== 'object' || next === null) {
target[key] = {};
}
target = /** @type {Record<string, unknown>} */ (target[key]);
}
target[last] = `load-${index}`;
}
return body;
}
/**
* @param {string} base
* @param {string} token webhooku
* @param {string} auth token prihlaseni pro cteni fronty
* @param {number} count
*/
async function webhookBurst(base, token, auth, count) {
const contractResponse = await fetch(`${base}/webhook/${token}`);
if (!contractResponse.ok) {
throw new Error(`webhook ${token.slice(0, 6)}...: ${contractResponse.status}`);
}
const contract =
/** @type {{ example: unknown, expects: Array<{ name: string, path: string, type: string }> }} */ (
await contractResponse.json()
);
const before = await queueStats(base, auth);
const start = performance.now();
let accepted = 0;
let rejected = 0;
let next = 0;
/** Jeden "odesilatel": posila dalsi a dalsi, dokud je co. */
async function sender() {
while (next < count) {
const index = next;
next += 1;
const body = await call('POST /webhook/:token', `${base}/webhook/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventBody(contract.example, contract.expects, index)),
});
if (body) accepted += 1;
else rejected += 1;
}
}
await Promise.all(Array.from({ length: WEBHOOK_CONCURRENCY }, sender));
const sentAt = performance.now();
// Cekani, az worker frontu vyprazdni.
let stats = await queueStats(base, auth);
while (stats.pending + stats.running > 0) {
if (performance.now() - start > QUEUE_DRAIN_TIMEOUT_MS) {
console.warn(`[load] fronta se nevyprazdnila do ${QUEUE_DRAIN_TIMEOUT_MS / MS_PER_SECOND} s`);
break;
}
await sleep(QUEUE_POLL_MS);
stats = await queueStats(base, auth);
}
const drainedAt = performance.now();
return {
events: count,
accepted,
rejected,
sendMs: Math.round(sentAt - start),
drainMs: Math.round(drainedAt - start),
runsPerSec: Math.round(accepted / ((drainedAt - start) / MS_PER_SECOND)),
done: stats.done - before.done,
failed: stats.failed - before.failed,
pendingAtEnd: stats.pending + stats.running,
};
}
// --------------------------------------------------------------- lokalni server
/** @returns {Promise<number>} */
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
/**
* true = na portu nikdo neposloucha.
* @param {number} port
* @returns {Promise<boolean>}
*/
function portFree(port) {
return new Promise((resolve) => {
const socket = net.connect({ port, host: '127.0.0.1' });
socket.once('connect', () => {
socket.destroy();
resolve(false);
});
socket.once('error', () => resolve(true));
});
}
/**
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* @param {string} base
*/
async function waitForHealth(base) {
const deadline = performance.now() + HEALTH_WAIT_MS;
while (performance.now() < deadline) {
try {
const response = await fetch(`${base}/health`);
if (response.ok) return;
} catch {
// Server jeste neposloucha, zkusi se znovu.
}
await sleep(HEALTH_POLL_MS);
}
throw new Error(`server na ${base} nenabehl do ${HEALTH_WAIT_MS / MS_PER_SECOND} s`);
}
/**
* Pamet procesu v MB (RSS). Server ji sam nevystavuje, tak se cte ze systemu.
* @param {number} pid
* @returns {Promise<number | null>}
*/
async function residentMb(pid) {
try {
if (process.platform === 'win32') {
const { stdout } = await execFileAsync('tasklist', [
'/FI',
`PID eq ${pid}`,
'/FO',
'CSV',
'/NH',
]);
const cells = stdout.trim().split(',');
const last = cells[cells.length - 1] ?? '';
const kb = Number(last.replace(/[^0-9]/g, ''));
return Number.isFinite(kb) && kb > 0 ? Math.round(kb / KB_PER_MB) : null;
}
const status = await fs.readFile(`/proc/${pid}/status`, 'utf8');
const match = /VmRSS:\s+(\d+)\s+kB/.exec(status);
return match ? Math.round(Number(match[1]) / KB_PER_MB) : null;
} catch (err) {
console.warn(
'[load] pamet serveru se nepodarila precist:',
err instanceof Error ? err.message : err,
);
return null;
}
}
/**
* Spusti sestaveny server na volnem portu s docasnym DATA_DIR (rezim souboru).
* @param {string} webhookToken
*/
async function startLocal(webhookToken) {
const port = await freePort();
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'csbot-load-'));
const entry = path.join(root, 'dist', 'index.js');
await fs.access(entry).catch(() => {
throw new Error(`chybi ${entry}, spustte npm run build`);
});
/** @type {string[]} */
const stderr = [];
const child = spawn(process.execPath, [entry], {
cwd: root,
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
DATABASE_URL: '',
SEED_DEMO: '1',
JWT_SECRET: 'load-test-jwt-secret',
SECRETS_KEY: 'load-test-secrets-key',
...(webhookToken ? { WEBHOOK_TOKEN_TEST: webhookToken } : {}),
},
// Log serveru se zahazuje: kazdy request pise radek a stovky radku za
// sekundu by v terminalu prekryly vysledek. Chyby se drzi pro hlaseni.
stdio: ['ignore', 'ignore', 'pipe'],
});
child.stderr?.on('data', (chunk) => {
stderr.push(String(chunk));
if (stderr.length > 50) stderr.shift();
});
child.on('exit', (code) => {
if (code !== null && code !== 0) {
console.error(`[load] server skoncil s kodem ${code}\n${stderr.join('')}`);
}
});
const base = `http://127.0.0.1:${port}`;
try {
await waitForHealth(base);
} catch (err) {
child.kill();
await fs.rm(dataDir, { recursive: true, force: true });
throw err;
}
console.log(`[load] lokalni server bezi na ${base}, data v ${dataDir}`);
return {
base,
port,
pid: child.pid ?? 0,
async stop() {
if (child.exitCode === null) {
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
const timer = sleep(STOP_TIMEOUT_MS).then(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
await Promise.race([exited, timer]);
}
await fs.rm(dataDir, { recursive: true, force: true });
const free = await portFree(port);
console.log(`[load] server ukoncen, port ${port} ${free ? 'je volny' : 'JE STALE OBSAZENY'}`);
return free;
},
};
}
// ---------------------------------------------------------------------- vystup
/**
* @param {Array<Array<string>>} rows
*/
function printTable(rows) {
const widths = rows.reduce(
(acc, row) => row.map((cell, index) => Math.max(acc[index] ?? 0, cell.length)),
/** @type {number[]} */ ([]),
);
for (const row of rows) {
console.log(row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' '));
}
}
/**
* @param {number} value
*/
function ms(value) {
return value.toFixed(1);
}
// ------------------------------------------------------------------------ beh
async function main() {
/** @type {Awaited<ReturnType<typeof startLocal>> | null} */
let local = null;
let portFreed = true;
try {
if (settings.startLocal) local = await startLocal(settings.webhookToken);
const base = local ? local.base : settings.url;
const token = await login(base, '');
console.log(
`[load] ${settings.users} uzivatelu, ${settings.durationSec} s, ${Math.ceil(settings.users / USERS_PER_STREAM)} streamu, cil ${base}`,
);
/** @type {number[]} */
const memorySamples = [];
const memoryTimer = local
? setInterval(() => {
void residentMb(local.pid).then((value) => {
if (value !== null) memorySamples.push(value);
});
}, MEMORY_SAMPLE_MS)
: null;
const streamAbort = new AbortController();
/** @type {StreamStats} */
const streams = { opened: 0, events: 0, errors: 0 };
const streamCount = Math.ceil(settings.users / USERS_PER_STREAM);
const streamTasks = Array.from({ length: streamCount }, () =>
holdStream(base, token, streamAbort.signal, streams),
);
const startedAt = performance.now();
const deadline = startedAt + settings.durationSec * MS_PER_SECOND;
await Promise.all(
Array.from({ length: settings.users }, () => virtualUser(base, token, deadline)),
);
const elapsedMs = performance.now() - startedAt;
let webhook = null;
if (settings.webhookToken) {
console.log(`[load] davka ${settings.webhookEvents} udalosti na webhook`);
webhook = await webhookBurst(base, settings.webhookToken, token, settings.webhookEvents);
}
streamAbort.abort();
await Promise.all(streamTasks);
if (memoryTimer) clearInterval(memoryTimer);
if (local) memorySamples.push((await residentMb(local.pid)) ?? 0);
// ---------------------------------------------------------------- souhrn
/** @type {Record<string, ReturnType<typeof summarise>>} */
const perEndpoint = {};
let requests = 0;
let errors = 0;
for (const [name, stats] of endpoints) {
perEndpoint[name] = summarise(stats);
// Davka webhooku se do propustnosti smycky uzivatelu nepocita, bezela az po ni.
if (name.startsWith('POST /webhook')) continue;
requests += stats.count;
errors += stats.errors;
}
const seconds = elapsedMs / MS_PER_SECOND;
const total = {
requests,
errors,
errorRate: requests === 0 ? 0 : Math.round((errors / requests) * 10_000) / 100,
seconds: Math.round(seconds * 10) / 10,
requestsPerSec: Math.round(requests / seconds),
};
const memory =
memorySamples.length > 0
? {
rssMaxMb: Math.max(...memorySamples),
rssLastMb: memorySamples[memorySamples.length - 1] ?? 0,
}
: null;
console.log('');
printTable([
['Endpoint', 'pozadavku', 'chyb', 'p50 ms', 'p95 ms', 'p99 ms', 'max ms'],
...Object.entries(perEndpoint).map(([name, row]) => [
name,
String(row.count),
String(row.errors),
ms(row.p50),
ms(row.p95),
ms(row.p99),
ms(row.max),
]),
]);
console.log('');
console.log(
`Celkem ${total.requests} pozadavku za ${total.seconds} s = ${total.requestsPerSec} req/s, ` +
`chyb ${total.errors} (${total.errorRate} %)`,
);
console.log(
`SSE: ${streams.opened} spojeni z ${streamCount}, prijato ${streams.events} udalosti, chyb ${streams.errors}`,
);
if (memory)
console.log(
`Pamet serveru (RSS): max ${memory.rssMaxMb} MB, na konci ${memory.rssLastMb} MB`,
);
else console.log('Pamet serveru: /health ji nevystavuje, jen cisla z klienta');
if (webhook) {
console.log(
`Webhook: ${webhook.accepted} prijato, ${webhook.rejected} odmitnuto, odeslano za ${webhook.sendMs} ms, ` +
`fronta prazdna za ${webhook.drainMs} ms = ${webhook.runsPerSec} behu/s ` +
`(hotovo ${webhook.done}, selhalo ${webhook.failed}, zbyva ${webhook.pendingAtEnd})`,
);
}
if (errorKinds.size > 0) {
console.log('Chyby:');
for (const [kind, count] of errorKinds) console.log(` ${count}x ${kind}`);
}
const reportPath = path.join(process.cwd(), REPORT_FILE);
await fs.writeFile(
reportPath,
JSON.stringify(
{
at: new Date().toISOString(),
url: base,
local: local !== null,
users: settings.users,
durationSec: settings.durationSec,
endpoints: perEndpoint,
total,
sse: { ...streams, wanted: streamCount },
server: { memory: memory ?? null, health: 'bez pameti, jen uptime' },
webhook,
errors: Object.fromEntries(errorKinds),
},
null,
2,
),
'utf8',
);
console.log(`[load] zprava zapsana do ${reportPath}`);
} finally {
if (local) portFreed = await local.stop();
}
if (!portFreed) process.exitCode = 1;
}
main().catch((err) => {
console.error('[load] selhalo:', err instanceof Error ? (err.stack ?? err.message) : err);
process.exit(1);
});