From 3279dd7dac32e12d5c0264cd9881919dae79eada Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:38:43 +0200 Subject: [PATCH] Cele chybove hlaseni u konektoru i skriptu U odpovedi 401 nebo 400 je duvod napsany v tele odpovedi sluzby, ne v tom, ze prislo 401. Dosud se telo zkracovalo na 400 znaku a u overeni konektoru se zahazovalo cele - zbyla veta "Pristup zamitnut", podle ktere se neda hledat. - ScriptError nese `request` (metoda a cesta) a `detail` s celou odpovedi sluzby, zkracenou az na SCRIPT_ERROR_DETAIL_BYTES (vychozi 8 kB). Chyby jsou vzacne, takze objem neroste jako u logu uspesnych kroku - do detailu jde surove telo, ne prochazene pres JSON.stringify. U chyby chceme presne to, co sluzba poslala, vcetne HTML nebo prosteho textu - u chyby spojeni se pridava i `cause`, u neocekavane vyjimky zasobnik volani (mimo produkci, stejne jako u centralniho error handleru) - overeni konektoru vraci `detail`, `status` i `request` - cely detail jde i do logu serveru, at je to dohledatelne bez portalu - do chyby se dava jen cesta, ne cela adresa: v query muze byt tajemstvi - nova komponenta ErrorDetail: rozbaleni cele odpovedi a tlacitko Kopirovat vse Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) --- src/config.ts | 8 ++ src/routes/connectors.ts | 29 ++++- src/scripts/http.ts | 81 +++++++++---- src/scripts/runner.ts | 49 +++++--- src/scripts/types.ts | 27 ++++- web/src/components/dashboard/ErrorDetail.tsx | 116 +++++++++++++++++++ web/src/pages/dashboard/Connectors.tsx | 34 +++--- web/src/pages/dashboard/Scripts.tsx | 24 ++-- web/src/types/dashboard.ts | 8 ++ 9 files changed, 306 insertions(+), 70 deletions(-) create mode 100644 web/src/components/dashboard/ErrorDetail.tsx diff --git a/src/config.ts b/src/config.ts index 5d83c46..dd41def 100644 --- a/src/config.ts +++ b/src/config.ts @@ -94,6 +94,14 @@ export const config = { scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000), /** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */ scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000), + /** + * Strop na text chybove odpovedi cizi sluzby. + * + * Zamerne velky. Odpoved na 401 nebo 400 obsahuje duvod a bez nej se chyba + * nedá dohledat. Chyby jsou navic vzacne, takze objem neroste jako u logu + * uspesnych kroku. + */ + errorDetailBytes: positiveNumber(process.env.SCRIPT_ERROR_DETAIL_BYTES, 8_000), /** * Strop na jednu strukturu (parametr typu object nebo list). * Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu, takze diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index a4c86b8..7e3b6cf 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -37,10 +37,11 @@ import { visibleServices, type Service, } from '../data/services.js'; +import { config } from '../config.js'; import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js'; import { createHttp } from '../scripts/http.js'; import { ScriptError } from '../scripts/types.js'; -import { createRedactor, describe } from '../scripts/util.js'; +import { createRedactor, describe, truncate } from '../scripts/util.js'; export const connectorsRouter = Router(); @@ -298,18 +299,36 @@ connectorsRouter.post('/:id/test', async (req, res) => { ok: true, checked, status: response.status, + request: { method: 'GET', path }, message: service.verifyPath ? 'Napojení funguje, přístupové údaje jsou platné.' : 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.', }); } catch (err) { - const message = - err instanceof ScriptError ? redact(err.message) : redact(describe(err, 200)); + // Cela odpoved sluzby, ne jen "HTTP 401". Duvod je napsany prave v ni. + const isScriptError = err instanceof ScriptError; + const message = isScriptError ? redact(err.message) : redact(describe(err, 400)); + const detail = isScriptError + ? err.detail + ? redact(err.detail) + : undefined + : redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes)); + setConnectorStatus(connector.id, 'error', message, [tenantId]); - console.warn(`[connectors] test ${connector.id} selhal: ${message}`); + console.warn( + `[connectors] test ${connector.id} selhal: ${message}` + (detail ? ` +${detail}` : ''), + ); // Neuspesne overeni neni chyba API, je to vysledek. Proto 200. - return res.json({ ok: false, checked, message }); + return res.json({ + ok: false, + checked, + message, + ...(isScriptError && err.status !== undefined ? { status: err.status } : {}), + request: isScriptError && err.request ? err.request : { method: 'GET', path }, + ...(detail ? { detail } : {}), + }); } finally { clearTimeout(timer); } diff --git a/src/scripts/http.ts b/src/scripts/http.ts index a11d45d..17ea617 100644 --- a/src/scripts/http.ts +++ b/src/scripts/http.ts @@ -10,8 +10,14 @@ import { config } from '../config.js'; import type { ResolvedTarget } from './connections.js'; -import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js'; -import { describe } from './util.js'; +import { + ScriptError, + type ScriptHttp, + type ScriptHttpOptions, + type ScriptHttpResponse, + type ScriptRequestInfo, +} from './types.js'; +import { truncate } from './util.js'; /** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */ const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]); @@ -80,41 +86,69 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti return url; } -function statusError(status: number, url: URL, detail: string): ScriptError { - const where = `${url.pathname} vrátilo HTTP ${status}`; +/** + * Chyba z HTTP kodu. + * + * `detail` je **cele telo odpovedi**, jen zredigovane a zkracene az na velkem + * stropu. Prave tam cizi sluzba pise, co ji vadilo - "HTTP 400" samo o sobe + * nikoho nikam nedovede. + */ +function statusError( + status: number, + request: ScriptRequestInfo, + detail: string, +): ScriptError { + const where = `${request.method} ${request.path} vrátilo HTTP ${status}`; + const options = { status, detail, request }; + if (retryableStatuses.has(status)) { - return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, { - status, - detail, - }); + return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, options); } if (status === 401 || status === 403) { - return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, { - status, - detail, - }); + return new ScriptError( + 'config', + `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, + options, + ); } if (status === 404) { - return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail }); + return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, options); } - return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail }); + return new ScriptError('terminal', `Volání selhalo: ${where}.`, options); } -function transportError(err: unknown, url: URL): ScriptError { +function transportError(err: unknown, request: ScriptRequestInfo): ScriptError { if (err instanceof ScriptError) return err; const code = err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : ''; const name = err instanceof Error ? err.name : ''; const message = err instanceof Error ? err.message : String(err); + // Puvodni chyba vcetne pricin. `cause` nese u fetch to podstatne. + const cause = + err instanceof Error && err.cause instanceof Error ? ` +Příčina: ${err.cause.message}` : ''; + const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes); if (name === 'AbortError' || name === 'TimeoutError') { - return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err }); + return new ScriptError('timeout', `Volání ${request.path} nedoběhlo v limitu.`, { + request, + detail, + cause: err, + }); } if (retryableCodes.has(code)) { - return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err }); + return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { + request, + detail, + cause: err, + }); } - return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err }); + return new ScriptError('retryable', `Volání ${request.path} selhalo: ${message}`, { + request, + detail, + cause: err, + }); } export interface CreateHttpOptions { @@ -137,6 +171,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { httpOptions?: ScriptHttpOptions, ): Promise> { const url = buildUrl(target, path, httpOptions); + // Do chyby jde jen cesta, ne cela adresa - v query muze byt tajemstvi. + const request: ScriptRequestInfo = { method, path: url.pathname }; const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE'; const startedAt = Date.now(); onCall(); @@ -157,7 +193,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { body: hasBody ? JSON.stringify(body) : undefined, }); } catch (err) { - throw transportError(err, url); + throw transportError(err, request); } const declaredSize = Number(response.headers.get('content-length') ?? 0); @@ -186,7 +222,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { } catch { throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, { status: response.status, - detail: redact(describe(raw, 300)), + request, + detail: redact(truncate(raw, config.errorDetailBytes)), }); } } @@ -195,7 +232,9 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { const allowed = httpOptions?.allowStatus ?? []; if (!response.ok && !allowed.includes(response.status)) { - throw statusError(response.status, url, redact(describe(parsed, 400))); + // Zamerne surove telo, ne prochazene pres JSON.stringify - u chyby chceme + // presne to, co sluzba poslala, vcetne pripadneho HTML nebo textu. + throw statusError(response.status, request, redact(truncate(raw, config.errorDetailBytes))); } return { status: response.status, body: parsed as T }; diff --git a/src/scripts/runner.ts b/src/scripts/runner.ts index 1fec37c..e5c7b3d 100644 --- a/src/scripts/runner.ts +++ b/src/scripts/runner.ts @@ -21,11 +21,11 @@ import { isRetryableKind, ScriptError, type ScriptContext, - type ScriptErrorKind, type ScriptLogEntry, + type ScriptRunError, type ScriptRunResult, } from './types.js'; -import { createRedactor, describe, scriptUtil } from './util.js'; +import { createRedactor, describe, scriptUtil, truncate } from './util.js'; import { validateValues } from './values.js'; export interface RunScriptOptions { @@ -51,22 +51,36 @@ function defaultIdempotencyKey(scriptId: string, inputs: unknown): string { return `${scriptId}:${hash.slice(0, 24)}`; } -function toRunError( - err: unknown, - redact: (value: string) => string, -): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } { +/** + * Prevede vyjimku na popis chyby pro klienta. + * + * Vraci **cely** detail, tedy telo odpovedi sluzby, a u neocekavane vyjimky + * i zasobnik volani. Bez toho uzivatel vidi jen "volani selhalo" a nema podle + * ceho hledat. Zasobnik jen mimo produkci, stejne jako u error handleru. + */ +function toRunError(err: unknown, redact: (value: string) => string): ScriptRunError { if (err instanceof ScriptError) { return { kind: err.kind, message: redact(err.message), + retryable: isRetryableKind(err.kind), ...(err.status !== undefined ? { status: err.status } : {}), ...(err.detail !== undefined ? { detail: redact(err.detail) } : {}), + ...(err.request !== undefined ? { request: err.request } : {}), }; } const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + // Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi. - return { kind: 'internal', message: redact(`Skript selhal: ${message}`) }; + return { + kind: 'internal', + message: redact(`Skript selhal: ${message}`), + retryable: false, + detail: redact(truncate(String(stack ?? message), config.errorDetailBytes)), + ...(config.isProduction || stack === undefined ? {} : { stack: redact(stack) }), + }; } export async function runScript( @@ -165,10 +179,14 @@ export async function runScript( config: Object.freeze({ ...target.serviceConfig }), idempotencyKey, fail(message, detail) { - throw new ScriptError('terminal', message, { detail: describe(detail) }); + throw new ScriptError('terminal', message, { + detail: describe(detail, config.errorDetailBytes), + }); }, retry(message, detail) { - throw new ScriptError('retryable', message, { detail: describe(detail) }); + throw new ScriptError('retryable', message, { + detail: describe(detail, config.errorDetailBytes), + }); }, }; @@ -177,12 +195,13 @@ export async function runScript( returned = await script.run(validatedInputs.values, ctx); } catch (err) { const error = toRunError(err, redact); - console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`); - return finish({ - ok: false, - outputs: {}, - error: { ...error, retryable: isRetryableKind(error.kind) }, - }); + // Do logu serveru cely detail, at je to dohledatelne i bez portalu. + console.warn( + `[scripts] ${scriptId} selhal (${error.kind}): ${error.message}` + + (error.detail ? ` +${error.detail}` : ''), + ); + return finish({ ok: false, outputs: {}, error }); } finally { clearTimeout(timer); } diff --git a/src/scripts/types.ts b/src/scripts/types.ts index c04db35..25a1cdd 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -158,23 +158,41 @@ export function isRetryableKind(kind: ScriptErrorKind): boolean { return retryableKinds.includes(kind); } +/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */ +export interface ScriptRequestInfo { + method: string; + /** Cesta bez domeny. Cela adresa muze nest tajemstvi v query. */ + path: string; +} + export class ScriptError extends Error { readonly kind: ScriptErrorKind; /** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */ readonly status?: number; - /** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */ + /** + * Cela odpoved sluzby. Uz zredigovana, bez tajemstvi. + * Zkracuje se az na `errorDetailBytes`, protoze prave tady je napsane, + * co sluzbe vadilo. + */ readonly detail?: string; + readonly request?: ScriptRequestInfo; constructor( kind: ScriptErrorKind, message: string, - options: { status?: number; detail?: string; cause?: unknown } = {}, + options: { + status?: number; + detail?: string; + request?: ScriptRequestInfo; + cause?: unknown; + } = {}, ) { super(message, options.cause !== undefined ? { cause: options.cause } : undefined); this.name = 'ScriptError'; this.kind = kind; this.status = options.status; this.detail = options.detail; + this.request = options.request; } } @@ -295,7 +313,12 @@ export interface ScriptRunError { message: string; retryable: boolean; status?: number; + /** Cela odpoved sluzby, uz zredigovana. Tady je napsane, co ji vadilo. */ detail?: string; + /** Ktere volani spadlo. */ + request?: ScriptRequestInfo; + /** Zasobnik volani. Jen mimo produkci, stejne jako u error handleru. */ + stack?: string; /** Vyplnene jen u chyb ve vstupu nebo vystupu. */ issues?: FieldIssue[]; } diff --git a/web/src/components/dashboard/ErrorDetail.tsx b/web/src/components/dashboard/ErrorDetail.tsx new file mode 100644 index 0000000..12fc202 --- /dev/null +++ b/web/src/components/dashboard/ErrorDetail.tsx @@ -0,0 +1,116 @@ +import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/cn'; + +/** + * Cele chybove hlaseni. + * + * Duvod, proc je to zvlast komponenta: zkracene hlaseni je k nicemu. U odpovedi + * 400 nebo 401 je duvod napsany v tele odpovedi sluzby, ne v tom, ze prislo 401. + * Proto se tady nic nezkraccuje, jde to rozbalit cele a zkopirovat. + * + * Server uz text zredigoval, takze pristupove udaje v nem nejsou. + */ +export function ErrorDetail({ + message, + detail, + status, + request, + retryable, + kind, + className, +}: { + message: string; + /** Cela odpoved sluzby nebo zasobnik volani. */ + detail?: string; + status?: number; + request?: { method: string; path: string }; + retryable?: boolean; + kind?: string; + className?: string; +}) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + /** Co se zkopiruje: vsechno, co o chybe vime, at to jde poslat dal. */ + const fullText = [ + message, + request ? `Volání: ${request.method} ${request.path}` : null, + status !== undefined ? `HTTP: ${status}` : null, + kind ? `Druh: ${kind}` : null, + retryable !== undefined + ? `Opakování: ${retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}` + : null, + detail ? `\nOdpověď služby:\n${detail}` : null, + ] + .filter(Boolean) + .join('\n'); + + async function copy() { + try { + await navigator.clipboard.writeText(fullText); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + // Bez pristupu ke schranke (starsi prohlizec, http) se nic nepredstira. + console.warn('[error] schranka neni dostupna:', err); + } + } + + const hasMore = Boolean(detail) || request !== undefined || status !== undefined; + + return ( +
+

{message}

+ +

+ {request && ( + + {request.method} {request.path} + + )} + {status !== undefined && HTTP {status}} + {kind && {kind}} + {retryable !== undefined && ( + + {retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'} + + )} +

+ + {hasMore && ( +
+ {detail && ( + + )} + +
+ )} + + {open && detail && ( +
+          {detail}
+        
+ )} +
+ ); +} diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 7de46ae..9b5467c 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -1,5 +1,4 @@ import { - AlertTriangle, CheckCircle2, Eye, EyeOff, @@ -13,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; import { DataState } from '@/components/dashboard/DataState'; +import { ErrorDetail } from '@/components/dashboard/ErrorDetail'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; @@ -224,21 +224,17 @@ function ConnectorCard({ )} {connector.lastError && !test && ( -

{connector.lastError}

+

+ {connector.lastError} + + Celé hlášení uvidíte po stisku Ověřit. + +

)} - {test && ( -

- {test.ok ? ( - - ) : ( - - )} + {test?.ok && ( +

+ {test.message} Ověřeno: {test.checked} @@ -246,6 +242,16 @@ function ConnectorCard({

)} + {test && !test.ok && ( + + )} + {error &&

{error}

}
diff --git a/web/src/pages/dashboard/Scripts.tsx b/web/src/pages/dashboard/Scripts.tsx index d633a09..20c9f3f 100644 --- a/web/src/pages/dashboard/Scripts.tsx +++ b/web/src/pages/dashboard/Scripts.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useAuth } from '@/auth/AuthContext'; import { DataState } from '@/components/dashboard/DataState'; +import { ErrorDetail } from '@/components/dashboard/ErrorDetail'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { apiFetch, ApiError } from '@/lib/api'; @@ -507,25 +508,22 @@ function RunResult({ result }: { result: ScriptRunResult }) {

{result.error && ( -
-

{result.error.message}

-

- Druh: {result.error.kind} - {result.error.status ? `, HTTP ${result.error.status}` : ''},{' '} - {result.error.retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'} -

+
+ {result.error.issues && result.error.issues.length > 0 && ( -
    +
      {result.error.issues.map((issue) => (
    • {issue.message}
    • ))}
    )} - {result.error.detail && ( -
    -              {result.error.detail}
    -            
    - )}
)} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index fd9b884..35ddaf2 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -298,6 +298,10 @@ export interface ConnectorTestResult { checked: string; status?: number; message: string; + /** Ktere volani to bylo. Bez toho je zprava jen pulka informace. */ + request?: { method: string; path: string }; + /** Cela odpoved sluzby. Tady je napsane, co ji vadilo. */ + detail?: string; } export interface ServiceCatalog { @@ -526,7 +530,11 @@ export interface ScriptRunResult { message: string; retryable: boolean; status?: number; + /** Cela odpoved sluzby, nebo zasobnik volani u chyby ve skriptu. */ detail?: string; + request?: { method: string; path: string }; + /** Jen mimo produkci. */ + stack?: string; issues?: Array<{ field: string; message: string }>; } | null; }