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 = {};