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) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-28 11:37:19 +02:00
co-authored by Claude Opus 5
parent 00d9d2d4b1
commit 35d1e43307
8 changed files with 364 additions and 2 deletions
+128
View File
@@ -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<string>();
/**
* 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<Props, State> {
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 (
<div className="glass rounded-card border border-danger-500/40 p-5">
<p className="flex items-center gap-2 font-semibold text-danger-400">
<AlertTriangle className="size-4 shrink-0" />
Tuhle část se nepodařilo vykreslit
</p>
<p className="mt-1.5 text-sm text-white/60">
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.
</p>
<p className="mt-2 font-mono text-xs break-all text-white/35">{error.message}</p>
<Button
className="mt-4"
size="sm"
variant="secondary"
onClick={() => this.setState({ error: null })}
>
<RotateCcw className="size-3.5" />
Zkusit znovu
</Button>
</div>
);
}
}
@@ -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.
*/}
<main key={activeTenant ?? 'bez-firmy'} className="px-5 py-7 sm:px-7">
<Outlet />
<ErrorBoundary resetKey={location.pathname}>
<Outlet />
</ErrorBoundary>
</main>
</div>
@@ -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<string, unknown> = {};