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) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 14:38:43 +02:00
co-authored by Claude Opus 5
parent ad56c7f513
commit 3279dd7dac
9 changed files with 306 additions and 70 deletions
@@ -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 (
<div
className={cn(
'rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm',
className,
)}
>
<p className="text-danger-400">{message}</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
{request && (
<span className="font-mono">
{request.method} {request.path}
</span>
)}
{status !== undefined && <span>HTTP {status}</span>}
{kind && <span>{kind}</span>}
{retryable !== undefined && (
<span className={retryable ? 'text-warn-400' : undefined}>
{retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}
</span>
)}
</p>
{hasMore && (
<div className="mt-2 flex flex-wrap items-center gap-3">
{detail && (
<button
type="button"
onClick={() => setOpen((previous) => !previous)}
className="flex items-center gap-1 text-xs text-white/50 transition-colors hover:text-white"
>
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{open ? 'Skrýt odpověď služby' : 'Zobrazit celou odpověď služby'}
</button>
)}
<button
type="button"
onClick={copy}
className="flex items-center gap-1 text-xs text-white/50 transition-colors hover:text-white"
>
{copied ? <Check className="size-3 text-ok-400" /> : <Copy className="size-3" />}
{copied ? 'Zkopírováno' : 'Kopírovat vše'}
</button>
</div>
)}
{open && detail && (
<pre className="mt-2 max-h-80 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap break-words text-white/70">
{detail}
</pre>
)}
</div>
);
}
+20 -14
View File
@@ -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 && (
<p className="mt-2 line-clamp-3 text-xs text-danger-400">{connector.lastError}</p>
<p className="mt-2 text-xs text-danger-400">
{connector.lastError}
<span className="mt-0.5 block text-white/35">
Celé hlášení uvidíte po stisku Ověřit.
</span>
</p>
)}
{test && (
<p
className={cn(
'mt-2 flex items-start gap-1.5 text-xs',
test.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{test.ok ? (
<CheckCircle2 className="mt-0.5 size-3 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
)}
{test?.ok && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-ok-400">
<CheckCircle2 className="mt-0.5 size-3 shrink-0" />
<span>
{test.message}
<span className="block text-white/35">Ověřeno: {test.checked}</span>
@@ -246,6 +242,16 @@ function ConnectorCard({
</p>
)}
{test && !test.ok && (
<ErrorDetail
className="mt-2"
message={test.message}
detail={test.detail}
status={test.status}
request={test.request}
/>
)}
{error && <p className="mt-2 text-xs text-danger-400">{error}</p>}
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-4">
+11 -13
View File
@@ -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 }) {
</p>
{result.error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{result.error.message}</p>
<p className="mt-1 text-xs text-white/40">
Druh: {result.error.kind}
{result.error.status ? `, HTTP ${result.error.status}` : ''},{' '}
{result.error.retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}
</p>
<div className="space-y-2">
<ErrorDetail
message={result.error.message}
detail={result.error.detail}
status={result.error.status}
request={result.error.request}
retryable={result.error.retryable}
kind={result.error.kind}
/>
{result.error.issues && result.error.issues.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
<ul className="ml-4 list-disc text-xs text-white/60">
{result.error.issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
{result.error.detail && (
<pre className="mt-2 overflow-x-auto rounded bg-ink-950/60 p-2 text-xs text-white/50">
{result.error.detail}
</pre>
)}
</div>
)}
+8
View File
@@ -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;
}