From 35d1e43307bbdf2d9862272f5e4c12e3d1179074 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:37:19 +0200 Subject: [PATCH] Pad vykreslovani uz nesmi shodit stranku a sam zaklada incident Ukazka tela webhooku shazovala builder pri psani cesty parametru. Chyba sama byla na jednom radku, ale to podstatne je, ze jedna vyjimka pri vykreslovani odstranila celou stranku. Uzivateli zustala bila plocha a rozdelana prace byla pryc. Aplikace na to nemela zadnou pojistku, takze totez mohlo prijit odkudkoli. Pojistka: - nova web/src/components/ErrorBoundary.tsx obaluje obsah portalu. Mistni chyba ted shodi svoji cast obrazovky, ne aplikaci: navigace, prepinac firmy i odhlaseni zustanou funkcni. Odchod jinam pojistku srovna zpatky - je to jedina trida v celem klientovi, React to jinak zachytit neumi Incident: - POST /api/dashboard/client-crash zaklada incident z padu. Bez toho je jedina stopa v konzoli prohlizece uzivatele, kam se nikdo nedostane - title a impact cte zakaznik, detail cte spravce platformy: hlaska, misto v kodu, strom komponent, adresa stranky, ucet, prohlizec a verze buildu - verze buildu je tam schvalne. U tohohle padu se ukazalo, ze bez ni se neda poznat, jestli uzivatel vidi chybu, ktera uz je opravena, nebo novou - tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut. Pad pri vykreslovani se opakuje pri kazdem prekresleni Sama ukazka: - parametr, kterym vede cesta jineho parametru, uz nedostane zastupnou hodnotu podle typu. Kdyz je jeden parametr `data` a druhy `data.result`, `data` musi byt objekt a zastupna hodnota by ho prepsala - cela ukazka je v try. Je to napoveda a nesmi shodit ani ten kus obrazovky Co-Authored-By: Claude Opus 5 (1M context) --- documentation/00-pro-programatory.md | 6 + documentation/01-prehled-a-stav.md | 1 + documentation/99-zmeny.md | 47 +++++++ src/openapi.ts | 51 +++++++ src/routes/dashboard.ts | 103 +++++++++++++- web/src/components/ErrorBoundary.tsx | 128 ++++++++++++++++++ .../components/dashboard/DashboardLayout.tsx | 15 +- .../dashboard/flow/TriggerConfig.tsx | 15 ++ 8 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 web/src/components/ErrorBoundary.tsx diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index 87a52a7..062d926 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -60,6 +60,12 @@ Chyba cizi sluzby neni chyba API: vraci se 200 s `ok: false` a **celym telem odpovedi**, protoze prave tam je napsane, co jí vadilo. "HTTP 403" samo o sobe nikoho nikam nedovede. +**Vykreslovani nesmi shodit stranku.** Jedna vyjimka pri vykreslovani odstrani +cely strom a zustane bila plocha. Obsah portalu je proto v pojistce +(`ErrorBoundary`) a pad z ni **zaklada incident** - bez toho je jedina stopa +v konzoli prohlizece uzivatele. Pomocna funkce, ktera jen neco vykresluje, +si navic ma chybu osetrit sama a degradovat, ne spadnout. + **Katalog je zdroj pravdy.** Co neni v `src/data/services.ts`, to nejde ulozit do stromu. Validace pri ukladani se pta katalogu, ne klienta. diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index d838668..371c50a 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -39,6 +39,7 @@ React aplikaci ze slozky `dist/public`. | Odesilani souboru ze skriptu | hotovo | `ctx.http.postForm`, obsah jako Base64 | | OpenAI pod vlastnim klicem | hotovo | dotaz, soubor, prepis zvuku, seznam modelu | | Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni | +| Pojistka proti padu portalu | hotovo | pad shodi jen svoji cast a sam zalozi incident | | MCP servery firmy | hotovo | obecna sluzba a MCP EasyWebu vcetne klice zarizeni | | Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury | | Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index fa57053..14c10e2 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,53 @@ Nejnovejsi nahore. +## 2026-08-28 - pad portalu uz nesmi shodit stranku a zaklada incident + +Ukazka tela webhooku shazovala cely builder pri psani cesty parametru. Chyba +sama byla na jednom radku, ale to podstatne je, **ze jedna vyjimka pri +vykreslovani odstranila celou stranku**. Uzivateli zustala bila plocha a +rozdelana prace byla pryc. Aplikace na to nemela zadnou pojistku. + +### Pojistka proti padu vykreslovani + +Nova `web/src/components/ErrorBoundary.tsx` obaluje obsah portalu. Mistni chyba +ted shodi svoji cast obrazovky, ne aplikaci: navigace, prepinac firmy +i odhlaseni zustanou funkcni a uzivatel ma kam odejit. Odchod na jinou stranku +pojistku srovna zpatky. + +Je to jedina trida v celem klientovi. Hook na tohle neni a nebude, React to +umi zachytit jen takhle. + +### Pad zaklada incident + +`POST /api/dashboard/client-crash`. Bez toho je jedina stopa v konzoli +prohlizece uzivatele, kam se nikdo nedostane - takze bychom o padu vedeli jen +tehdy, kdyby ho nekdo nahlasil. To znamena o vetsine padu nevedet. + +Incident nese dve casti, stejne jako ostatni incidenty: + +- `title` a `impact` cte zakaznik, tedy zadne stack trace, +- `detail` cte spravce platformy: hlaska, misto v kodu, strom komponent, adresa + stranky, ucet, prohlizec a **verze buildu**. + +Verze buildu je tam schvalne. U tohohle padu se ukazalo, ze bez ni se neda +poznat, jestli uzivatel vidi chybu, ktera uz je opravena, nebo novou. + +Tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut. Pad +pri vykreslovani se opakuje pri kazdem prekresleni a jinak by z jedne chyby +vzniklo padesat incidentu a ten pravy by v nich zapadl. + +### Sama ukazka + +- Do cesty parametru se da zanorit vzdy. Kdyz na miste je skalar, nahradi se + schrankou - driv se zapisovalo do retezce a to shodilo stranku. +- Parametr, kterym vede cesta jineho parametru, uz nedostane zastupnou hodnotu + podle typu. Plati vnitrni struktura: kdyz je jeden parametr `data` a druhy + `data.result`, **`data` musi byt objekt**. Zastupna hodnota by ho prepsala. +- Cela ukazka je navic v `try`. Je to napoveda a nesmi shodit ani ten kus + obrazovky, at uz do ni prijde cokoliv - parametry se pisou znak po znaku + a rozdelany stav je normalni. + ## 2026-08-28 - MCP EasyWeb podle skutecne specifikace (auth v2) Predchozi verze posilala na `/login` jen jmeno, heslo a nazev zarizeni. Server diff --git a/src/openapi.ts b/src/openapi.ts index e73558a..677de03 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1785,6 +1785,57 @@ export function buildOpenApiDocument() { }, }, }, + '/api/dashboard/client-crash': { + post: { + tags: ['Portal'], + summary: 'Nahlasit pad vykreslovani', + description: + 'Zaklada incident z padu portalu v prohlizeci. Bez toho je jedina stopa v konzoli ' + + 'uzivatele, kam se nikdo nedostane, takze bychom o padu vedeli jen tehdy, kdyby ho ' + + 'nekdo nahlasil. Incident nese title a impact pro zakaznika a detail pro spravce ' + + 'platformy: hlaska, misto v kodu, strom komponent, adresa stranky a verze buildu. ' + + 'Tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut - pad pri ' + + 'vykreslovani se opakuje pri kazdem prekresleni a jinak by z jedne chyby vzniklo ' + + 'padesat incidentu. Volá to pojistka v klientovi, ne clovek.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['message'], + properties: { + message: { type: 'string' }, + stack: { type: 'string' }, + componentStack: { type: 'string' }, + path: { type: 'string', description: 'Kde v portalu se to stalo.' }, + build: { type: 'string', description: 'Verze nasazeneho klienta.' }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Incident zalozen', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + created: { type: 'boolean' }, + incidentId: { type: 'string' }, + }, + }, + }, + }, + }, + '202': { description: 'Stejny pad uz je hlaseny, incident se nezaklada' }, + '400': { description: 'Neplatny vstup' }, + }, + }, + }, '/api/dashboard/connectors/{id}/mcp/tools': { post: { tags: ['Konektory'], diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index b466515..22dd327 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -41,7 +41,7 @@ import { } from '../data/dashboardLayouts.js'; import { collectScopes } from '../data/flowScope.js'; import { widgetCatalog } from '../data/widgets.js'; -import { listIncidents } from '../data/incidentStore.js'; +import { createIncident, listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; import { findPersonByEmail, groupsOfPerson, listGroups, listPeople } from '../data/people.js'; import { hasPermission } from '../data/permissions.js'; @@ -139,6 +139,107 @@ dashboardRouter.get('/incidents', (req, res) => { }); }); +/** + * Hlaseni padu portalu. + * + * Kdyz v prohlizeci spadne vykreslovani, uzivatel to vidi, ale my ne. Bez + * tohohle endpointu je jedina stopa v jeho konzoli, kam se nikdo nedostane, + * takze o padu vime jen tehdy, kdyz ho nekdo nahlasi. To znamena, ze o vetsine + * padu nevime vubec. + * + * Incident nese dve casti a je to zamerne: + * - `title` a `impact` cte zakaznik, takze zadne stack trace, + * - `detail` cte spravce platformy a je v nem vsechno: hlaska, misto v kodu, + * strom komponent, adresa stranky a verze buildu. + * + * Cely `detail` je to, **jak se to stalo**. Bez adresy a stromu komponent je + * hlaska "Cannot create property" k nepouziti. + */ + +/** + * Kolik nejvys znaku se z jednoho pole prevezme. + * + * Strom komponent umi byt velmi dlouhy a do incidentu patri jeho zacatek, + * protoze prave nahore je komponenta, ktera spadla. + */ +const FIELD_LIMIT = 4_000; + +/** + * Jak dlouho se tentyz pad povazuje za jeden. + * + * Pad pri vykreslovani se opakuje pri kazdem stisku klavesy. Bez tohohle by + * z jedne chyby vzniklo padesat incidentu a ten pravy by v nich zapadl. + */ +const DEDUPE_MS = 600_000; + +/** Otisk padu a kdy naposled zalozil incident. */ +const recent = new Map(); + +const crashSchema = z.object({ + message: z.string().trim().min(1).max(FIELD_LIMIT), + stack: z.string().max(FIELD_LIMIT).optional(), + componentStack: z.string().max(FIELD_LIMIT).optional(), + /** Kde v portalu se to stalo. Bez toho se to nema kde hledat. */ + path: z.string().max(500).optional(), + /** Verze nasazeneho klienta. Rika, jestli uz je v tom oprava. */ + build: z.string().max(200).optional(), +}); + +dashboardRouter.post('/client-crash', (req, res) => { + const access = accessFor(req.user!); + const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; + const tenantId = requested ?? access.defaultTenantId; + + const parsed = crashSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + const crash = parsed.data; + const where = crash.path ?? 'neznámá stránka'; + + /* + * Otisk je hlaska a misto, ne cas. Tatáz chyba na tomtez miste je porad + * jeden problem, i kdyz na ni clovek narazi desetkrat za sebou. + */ + const fingerprint = `${crash.message}|${where}`; + const last = recent.get(fingerprint) ?? 0; + const now = Date.now(); + if (now - last < DEDUPE_MS) { + return res.status(202).json({ created: false, reason: 'stejný pád už je hlášený' }); + } + recent.set(fingerprint, now); + + const detail = [ + `Stránka: ${where}`, + `Uživatel: ${req.user!.email}`, + `Verze klienta: ${crash.build ?? 'neznámá'}`, + `Prohlížeč: ${String(req.headers['user-agent'] ?? 'neznámý')}`, + '', + `Hláška: ${crash.message}`, + ...(crash.componentStack ? ['', 'Strom komponent:', crash.componentStack] : []), + ...(crash.stack ? ['', 'Zásobník volání:', crash.stack] : []), + ].join('\n'); + + const incident = createIncident({ + tenantId, + title: 'Část portálu se nepodařilo vykreslit', + service: 'Portál', + // Pad vykreslovani neni vypadek sluzby, ale uzivatel u toho nemuze + // pokracovat v praci. Prostredni zavaznost, ne nejvyssi. + severity: 'sev2', + impact: `Stránka ${where} se části uživatelů nezobrazila správně. Ostatní části portálu fungují.`, + detail, + source: 'portál', + }); + + console.error(`[ui] pad portalu na ${where}: ${crash.message} (incident ${incident.id})`); + return res.status(201).json({ created: true, incidentId: incident.id }); +}); + // ------------------------------------------------------- rozlozeni dashboardu /** diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2376401 --- /dev/null +++ b/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,128 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { buildInfo } from '@/config/version'; +import { apiFetch } from '@/lib/api'; + +/** + * Pojistka proti pádu vykreslovani. + * + * React nema mezistav: **jedna vyjimka pri vykreslovani odstrani cely strom** + * a uzivateli zustane bila stranka bez jakekoliv hlasky. Nezachrani ho ani + * obnoveni, protoze se vykresli totez. U rozdelane prace v builderu to znamena, + * ze o ni prisel. + * + * Zachytit se to da jedine takhle, tridni komponentou - hook na to neni + * a nebude. Proto je tenhle soubor jediny, kde je v celem klientovi trida. + * + * Neni to nahrada za osetrene chyby. Je to posledni zabrana pro to, na co se + * neprislo: mistni chyba ma shodit svoji cast obrazovky, ne aplikaci. + */ +interface Props { + children: ReactNode; + /** Co se ukaze misto spadle casti. Chybi = vychozi hlaska nize. */ + fallback?: ReactNode; + /** + * Zmena teto hodnoty pojistku shodi zpatky do poradku. + * + * Pouziva se adresa stranky: kdyz uzivatel odejde jinam, nema tam na nej + * cekat chyba z mista, kde uz neni. + */ + resetKey?: string; +} + +interface State { + error: Error | null; +} + +/** + * Co uz bylo nahlaseno. + * + * Pad pri vykreslovani se opakuje pri kazdem prekresleni, takze bez tohohle by + * z jedne chyby bylo padesat volani na server. Server si stejnou chybu + * odfiltruje taky, tohle je jen proto, aby se zbytecne neposilala. + */ +const reported = new Set(); + +/** + * Zalozi incident z padu vykreslovani. + * + * Bez toho je jedina stopa v konzoli prohlizece uzivatele, kam se nikdo + * nedostane - takze bychom o padu vedeli jen tehdy, kdyby ho nekdo nahlasil. + * To znamena o vetsine padu nevedet. + * + * **Nesmi vyhodit.** Chyba pri hlaseni chyby je to posledni, co ma uzivatel + * videt, takze se jen zapise do konzole. + */ +function report(error: Error, info: ErrorInfo): void { + const path = `${window.location.pathname}${window.location.search}`; + const key = `${error.message}|${path}`; + if (reported.has(key)) return; + reported.add(key); + + void apiFetch('/api/dashboard/client-crash', { + method: 'POST', + body: { + message: error.message, + stack: error.stack?.slice(0, 4_000), + componentStack: info.componentStack?.slice(0, 4_000) ?? undefined, + path, + build: `${buildInfo.version} ${buildInfo.builtAt}${buildInfo.commit ? ` ${buildInfo.commit}` : ''}`, + }, + }).catch((err: unknown) => { + console.error('[ui] pad se nepodarilo nahlasit:', err); + }); +} + +export class ErrorBoundary extends Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + override componentDidUpdate(previous: Props): void { + // Jina stranka, cista tabule. Bez toho by chyba drzela i po odchodu jinam. + if (previous.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null }); + } + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + /* + * Do konzole cele, vcetne toho, ktera komponenta to byla. Bez toho zbyde + * jen zmensene jmeno funkce a hledani zacina od nuly. + */ + console.error('[ui] vykreslovani spadlo:', error, info.componentStack); + report(error, info); + } + + override render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback !== undefined) return this.props.fallback; + + return ( +
+

+ + Tuhle část se nepodařilo vykreslit +

+

+ Zbytek portálu funguje dál. Zkuste to znovu, a když to bude přetrvávat, + pošlete nám prosím tuhle hlášku. +

+

{error.message}

+ +
+ ); + } +} diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 7769b7a..de48bd0 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -21,6 +21,7 @@ import { } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import { useAuth, type AuthUser } from '@/auth/AuthContext'; import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider'; import { EventToasts } from '@/components/dashboard/EventToasts'; @@ -323,8 +324,20 @@ function DashboardShell() { Ztrata rozepsaneho stavu je pritom spravne. Filtr na cloveka z firmy A nema ve firme B co znamenat. */} + {/* + Pojistka kolem obsahu stranky. + + Vyjimka pri vykreslovani jinak odstrani cely strom a zustane bila + stranka. Takhle spadne jen obsah, kdezto navigace, prepinac firmy + i odhlaseni zustanou funkcni - uzivatel ma kam odejit. + + `resetKey` je adresa: po odchodu jinam uz na nej nema cekat chyba + z mista, kde neni. + */}
- + + +
diff --git a/web/src/components/dashboard/flow/TriggerConfig.tsx b/web/src/components/dashboard/flow/TriggerConfig.tsx index 189df39..435ca96 100644 --- a/web/src/components/dashboard/flow/TriggerConfig.tsx +++ b/web/src/components/dashboard/flow/TriggerConfig.tsx @@ -283,6 +283,21 @@ function ProvidedFields({ * ukaze zanoreny presne tam. Jinak by odesilatel poslal plocho a nic by nesedelo. */ function exampleBody(fields: TriggerField[]): string { + try { + return buildExample(fields); + } catch (err) { + /* + * Ukazka je napoveda. **Nesmi shodit stranku**, at uz do ni prijde + * cokoliv - parametry se pisou znak po znaku a rozdelany stav je normalni. + * Pojistka kolem obsahu portalu to zachyti taky, ale tam uz zmizi cely + * builder i s rozdelanou praci. + */ + console.error('[builder] ukazku tela se nepodarilo sestavit:', err); + return '{\n "cokoliv": "ukázku nejde sestavit, zkontrolujte cesty parametrů"\n}'; + } +} + +function buildExample(fields: TriggerField[]): string { if (fields.length === 0) return '{\n "cokoliv": "co pošle odesílatel"\n}'; const body: Record = {};