TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import Link from "next/link";4import type { FetchRequestInput, FetchResponseBody } from "@fetcha/core/client";5import { ArrowUpRight, Download, Loader2, MonitorSmartphone, PlayCircle, ShieldAlert } from "lucide-react";6import type { PlaygroundError } from "@/actions/playground";7import { Alert } from "@/components/ui/alert";8import { Badge } from "@/components/ui/badge";9import { Button } from "@/components/ui/button";10import { CodeBlock } from "@/components/ui/code-block";11import { CopyButton } from "@/components/ui/copy-button";12import { EmptyState } from "@/components/ui/empty-state";13import { Skeleton } from "@/components/ui/skeleton";14import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";15import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";16import type { CodegenLang } from "@/lib/codegen";17import { formatBytes, formatMs } from "@/lib/format";18import { cn } from "@/lib/utils";19import { CodePanel } from "./code-panel";20import { TimingBars } from "./timing-bars";21import { isHtmlContentType, isJsonContentType, statusVariant, stripHtml, truncateForDisplay, type NetworkClass, type PlaygroundKey } from "./types";2223export type ResultPhase = "idle" | "running" | "success" | "error";2425export interface ResultsPanelProps {26 phase: ResultPhase;27 result: FetchResponseBody | null;28 error: PlaygroundError | null;29 elapsedMs: number;30 request: FetchRequestInput;31 requestNetwork: NetworkClass;32 keys: PlaygroundKey[];33 baseUrl: string;34 codeLang: CodegenLang;35 onCodeLangChange: (l: CodegenLang) => void;36 keyId: string;37 onKeyChange: (id: string) => void;38 onSaveCurl: () => void;39}4041export function ResultsPanel(p: ResultsPanelProps) {42 const code = <CodePanel request={p.request} keys={p.keys} baseUrl={p.baseUrl} lang={p.codeLang} onLangChange={p.onCodeLangChange} keyId={p.keyId} onKeyChange={p.onKeyChange} />;4344 if (p.phase === "idle") {45 return (46 <div className="grid gap-6">47 <EmptyState icon={PlayCircle} title="Run a request to see the response" description="Enter a URL on the left and press Run. You'll get the body, headers, cookies, the network route Fetcha chose and a timing breakdown." compact />48 <Panel title="Code for this request">{code}</Panel>49 </div>50 );51 }5253 if (p.phase === "running") {54 return (55 <div className="grid gap-4 rounded-lg border border-border p-5" aria-busy="true" aria-live="polite">56 <div className="flex items-center gap-3">57 <Loader2 className="size-4 animate-spin text-accent" aria-hidden />58 <div className="min-w-0 flex-1">59 <p className="text-[13.5px] font-medium">Routing your request…</p>60 <p className="truncate text-xs text-fg-muted">61 {p.requestNetwork === "auto" ? "Auto mode picks the best network for this domain, then retries with a new IP or another network if the target blocks." : `Using the ${p.requestNetwork} network.`}62 </p>63 </div>64 <span className="shrink-0 font-mono text-[13px] tabular text-fg-muted">{formatMs(p.elapsedMs)}</span>65 </div>66 <div className="grid gap-2 pt-2">67 <Skeleton className="h-3.5 w-1/3" />68 <Skeleton className="h-3.5 w-2/3" />69 <Skeleton className="h-3.5 w-1/2" />70 <Skeleton className="mt-2 h-40 w-full" />71 </div>72 </div>73 );74 }7576 if (p.phase === "error" || !p.result) {77 const e = p.error ?? { code: "INTERNAL_ERROR", message: "Unknown error.", requestId: null };78 const issues = Array.isArray(e.details?.issues) ? (e.details!.issues as Array<{ path?: string; message?: string }>) : [];79 return (80 <div className="grid gap-6">81 <Alert82 variant="danger"83 title={84 <span className="flex flex-wrap items-center gap-2">85 <span className="font-mono text-[13px]">{e.code}</span>86 <span className="text-fg-muted">·</span>87 <span>{e.message}</span>88 </span>89 }90 >91 <div className="mt-2 grid gap-2 text-[13px]">92 {issues.length ? (93 <ul className="list-disc pl-5 text-fg-muted">94 {issues.map((i, idx) => (95 <li key={idx}>96 {i.path ? <span className="font-mono text-fg">{i.path}</span> : null}97 {i.path ? ": " : ""}98 {i.message}99 </li>100 ))}101 </ul>102 ) : null}103 <div className="flex flex-wrap items-center gap-x-4 gap-y-1">104 {e.requestId ? (105 <span className="inline-flex items-center gap-1 text-fg-muted">106 Request <span className="font-mono text-fg">{e.requestId}</span>107 <CopyButton value={e.requestId} className="size-6" />108 </span>109 ) : null}110 <Link href={`/docs/errors#${e.code}`} className="inline-flex items-center gap-1 text-accent underline-offset-4 hover:underline">111 About {e.code} <ArrowUpRight className="size-3.5" />112 </Link>113 {e.requestId ? (114 <Link href={`/dashboard/requests/${e.requestId}`} className="inline-flex items-center gap-1 text-fg-muted underline-offset-4 hover:underline">115 View in Requests116 </Link>117 ) : null}118 </div>119 <p className="text-xs text-fg-subtle">Took {formatMs(p.elapsedMs)}.</p>120 </div>121 </Alert>122 <Panel title="Reproduce with code">{code}</Panel>123 </div>124 );125 }126127 return <SuccessView {...p} result={p.result} code={code} />;128}129130function Panel({ title, children }: { title: string; children: React.ReactNode }) {131 return (132 <section className="grid gap-3">133 <h2 className="text-[13px] font-semibold uppercase tracking-wide text-fg-subtle">{title}</h2>134 {children}135 </section>136 );137}138139// ---------------------------------------------------------------------------140// Success141// ---------------------------------------------------------------------------142143function SuccessView({ result: r, request, elapsedMs, onSaveCurl, code }: ResultsPanelProps & { result: FetchResponseBody; code: React.ReactNode }) {144 const method = request.method ?? "GET";145 const isHead = method === "HEAD";146 const content = r.content ?? "";147 const hasContent = content.length > 0;148 const html = isHtmlContentType(r.content_type);149 const headerEntries = Object.entries(r.headers ?? {});150 const cookies = r.cookies ?? [];151 const meta = r.metadata;152 const [tab, setTab] = React.useState("preview");153154 const jsonValue = React.useMemo<{ ok: true; value: unknown } | { ok: false }>(() => {155 if (r.json !== undefined && r.json !== null) return { ok: true, value: r.json };156 if (!hasContent) return { ok: false };157 if (!isJsonContentType(r.content_type) && !/^\s*[[{]/.test(content)) return { ok: false };158 try {159 return { ok: true, value: JSON.parse(content) };160 } catch {161 return { ok: false };162 }163 }, [r.json, r.content_type, content, hasContent]);164165 const textValue = React.useMemo(() => {166 if (typeof r.text === "string") return r.text;167 if (!hasContent) return "";168 return html ? stripHtml(content) : content;169 }, [r.text, content, hasContent, html]);170171 const debugJson = React.useMemo(() => {172 const clone: Record<string, unknown> = { ...r };173 if (typeof r.content === "string") clone.content = `[omitted: ${r.content.length.toLocaleString("en-US")} chars — see the ${html ? "HTML" : "Body"} tab]`;174 if (typeof r.text === "string") clone.text = `[omitted: ${r.text.length.toLocaleString("en-US")} chars — see the Text tab]`;175 if (typeof r.markdown === "string") clone.markdown = `[omitted: ${r.markdown.length.toLocaleString("en-US")} chars — see the Markdown tab]`;176 if (typeof r.screenshot === "string") clone.screenshot = `[omitted: ${r.screenshot.length.toLocaleString("en-US")} base64 chars — see the Screenshot tab]`;177 if (Array.isArray(r.links)) clone.links = `[${r.links.length.toLocaleString("en-US")} links — see the Links tab]`;178 if (r.json !== undefined) clone.json = "[see the JSON tab]";179 return JSON.stringify(clone, null, 2);180 }, [r, html]);181182 const bodyLabel = html ? "HTML" : "Body";183 const hasMarkdown = typeof r.markdown === "string";184 const links = Array.isArray(r.links) ? r.links : null;185 const hasScreenshot = typeof r.screenshot === "string" && r.screenshot.length > 0;186 const mode = meta.mode ?? "http";187188 return (189 <div className="grid gap-4">190 {/* Header */}191 <div className="flex flex-col gap-3 rounded-lg border border-border bg-bg-subtle/40 px-4 py-3">192 <div className="flex flex-wrap items-center gap-2">193 <Badge variant={statusVariant(r.status)} className="font-mono text-[12px]">194 {r.status || "—"}195 </Badge>196 {r.success ? (197 <Badge variant="success" dot>198 success199 </Badge>200 ) : (201 <Badge variant="danger" dot>202 blocked203 </Badge>204 )}205 <Badge variant={mode === "browser" ? "accent" : "outline"} title={mode === "browser" ? "Rendered in the managed browser" : "Plain HTTP fetch"}>206 {mode === "browser" ? <MonitorSmartphone className="size-3" aria-hidden /> : null}207 {mode === "browser" ? "Browser" : "HTTP"}208 </Badge>209 {meta.cached ? <Badge variant="outline">cached</Badge> : null}210 <span className="hidden text-fg-subtle sm:inline">·</span>211 <Metric label="Duration" value={formatMs(meta.duration_ms ?? elapsedMs)} />212 <Metric label="Network" value={meta.network} />213 <Metric label="Country" value={meta.country ?? "any"} />214 <Metric label="Attempts" value={String(meta.attempts)} />215 <Metric label="Size" value={formatBytes(meta.bytes)} />216 <div className="ml-auto flex items-center gap-1">217 <Button type="button" variant="outline" size="sm" onClick={onSaveCurl} className="h-8">218 <Download className="size-3.5" /> Save as cURL219 </Button>220 </div>221 </div>222 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-fg-muted">223 <span className="inline-flex items-center gap-1">224 <span className="font-mono text-fg">{r.request_id}</span>225 <CopyButton value={r.request_id} className="size-6" />226 <Link href={`/dashboard/requests/${r.request_id}`} className="inline-flex items-center gap-0.5 text-accent underline-offset-4 hover:underline">227 Details <ArrowUpRight className="size-3" />228 </Link>229 </span>230 <span className="min-w-0 truncate font-mono" title={r.final_url}>231 {r.final_url !== r.url ? (232 <>233 <span className="text-fg-subtle">{r.url}</span> → {r.final_url}234 </>235 ) : (236 r.url237 )}238 </span>239 {r.content_type ? <span className="font-mono text-fg-subtle">{r.content_type}</span> : null}240 </div>241 {!r.success ? (242 <p className="flex items-start gap-2 text-xs text-fg-muted">243 <ShieldAlert className="mt-0.5 size-3.5 shrink-0 text-danger" aria-hidden />244 The target answered with HTTP {r.status} on every route Fetcha tried ({meta.attempts} attempt{meta.attempts === 1 ? "" : "s"}). Try browser rendering, a residential network, a different country or a session.245 </p>246 ) : null}247 </div>248249 {r.page ? <PageCard page={r.page} /> : null}250251 {/* Tabs */}252 <Tabs value={tab} onValueChange={setTab}>253 <TabsList variant="underline" className="-mx-1 px-1">254 <TabsTrigger value="preview">Preview</TabsTrigger>255 <TabsTrigger value="html">{bodyLabel}</TabsTrigger>256 <TabsTrigger value="text">Text</TabsTrigger>257 {hasMarkdown ? <TabsTrigger value="markdown">Markdown</TabsTrigger> : null}258 <TabsTrigger value="json">JSON</TabsTrigger>259 {links ? (260 <TabsTrigger value="links">261 Links <Count n={links.length} />262 </TabsTrigger>263 ) : null}264 {hasScreenshot ? <TabsTrigger value="screenshot">Screenshot</TabsTrigger> : null}265 <TabsTrigger value="headers">266 Headers <Count n={headerEntries.length} />267 </TabsTrigger>268 <TabsTrigger value="cookies">269 Cookies <Count n={cookies.length} />270 </TabsTrigger>271 <TabsTrigger value="network">Network</TabsTrigger>272 <TabsTrigger value="timing">Timing</TabsTrigger>273 <TabsTrigger value="debug">Debug</TabsTrigger>274 <TabsTrigger value="code">Code</TabsTrigger>275 </TabsList>276277 <TabsContent value="preview">278 {!hasContent ? (279 <NoBody isHead={isHead} />280 ) : html ? (281 <div className="grid gap-2">282 <iframe title="Response preview (sandboxed)" sandbox="" srcDoc={content} referrerPolicy="no-referrer" className="h-[560px] w-full rounded-lg border border-border bg-white" />283 <p className="text-xs text-fg-subtle">Rendered in a fully sandboxed frame: scripts are disabled and relative assets may not load.</p>284 </div>285 ) : jsonValue.ok ? (286 <JsonBlock value={jsonValue.value} />287 ) : (288 <TextBlock text={content} />289 )}290 </TabsContent>291292 <TabsContent value="html">{!hasContent ? <NoBody isHead={isHead} /> : <SourceBlock text={content} lang={html ? "html" : "text"} />}</TabsContent>293294 <TabsContent value="text">{!textValue ? <NoBody isHead={isHead} label="No text content." /> : <TextBlock text={textValue} />}</TabsContent>295296 {hasMarkdown ? (297 <TabsContent value="markdown">298 {!r.markdown ? <NoBody isHead={isHead} label="No Markdown content." /> : <TextBlock text={r.markdown} />}299 <p className="mt-2 text-xs text-fg-subtle">Main content first, navigation and boilerplate removed. Shown as plain text.</p>300 </TabsContent>301 ) : null}302303 {links ? (304 <TabsContent value="links">305 <div className="rounded-lg border border-border">306 <Table>307 <TableHeader>308 <TableRow>309 <TableHead>URL</TableHead>310 <TableHead>Text</TableHead>311 <TableHead>Scope</TableHead>312 <TableHead>Rel</TableHead>313 </TableRow>314 </TableHeader>315 <TableBody>316 {links.length === 0 ? <TableEmpty colSpan={4}>No hyperlinks found in the page.</TableEmpty> : null}317 {links.slice(0, 500).map((l, i) => (318 <TableRow key={`${l.url}-${i}`}>319 <TableCell className="max-w-[22rem] truncate font-mono text-[12px]" title={l.url}>320 <a href={l.url} target="_blank" rel="noreferrer noopener nofollow" className="text-accent underline-offset-4 hover:underline">321 {l.url}322 </a>323 </TableCell>324 <TableCell className="max-w-[14rem] truncate text-[12.5px] text-fg-muted" title={l.text}>325 {l.text || <span className="text-fg-subtle">—</span>}326 </TableCell>327 <TableCell>328 <Badge variant={l.internal ? "default" : "outline"}>{l.internal ? "internal" : "external"}</Badge>329 </TableCell>330 <TableCell className="text-[12px] text-fg-muted">{l.nofollow ? "nofollow" : "—"}</TableCell>331 </TableRow>332 ))}333 </TableBody>334 </Table>335 </div>336 {links.length > 500 ? <p className="mt-2 text-xs text-fg-muted">Showing the first 500 of {links.length.toLocaleString("en-US")} links.</p> : null}337 </TabsContent>338 ) : null}339340 {hasScreenshot ? (341 <TabsContent value="screenshot">342 <div className="grid gap-2">343 {/* eslint-disable-next-line @next/next/no-img-element -- data URL from the API, not an optimisable asset */}344 <img src={`data:image/png;base64,${r.screenshot}`} alt="Screenshot of the rendered page" className="w-full rounded-lg border border-border bg-white" />345 <p className="text-xs text-fg-subtle">PNG captured after the page settled in the managed browser.</p>346 </div>347 </TabsContent>348 ) : null}349350 <TabsContent value="json">351 {jsonValue.ok ? (352 <JsonBlock value={jsonValue.value} />353 ) : (354 <EmptyState compact title="Not JSON" description={hasContent ? "The response body could not be parsed as JSON. Set Output format to json to have the API parse it for you." : isHead ? "HEAD requests return headers only." : "The response had no body."} />355 )}356 </TabsContent>357358 <TabsContent value="headers">359 <div className="rounded-lg border border-border">360 <Table>361 <TableHeader>362 <TableRow>363 <TableHead className="w-[36%]">Header</TableHead>364 <TableHead>Value</TableHead>365 </TableRow>366 </TableHeader>367 <TableBody>368 {headerEntries.length === 0 ? <TableEmpty colSpan={2}>No response headers.</TableEmpty> : null}369 {headerEntries.map(([k, v]) => (370 <TableRow key={k}>371 <TableCell className="font-mono text-[12.5px] text-fg-muted">{k}</TableCell>372 <TableCell className="break-all font-mono text-[12.5px]">{v}</TableCell>373 </TableRow>374 ))}375 </TableBody>376 </Table>377 </div>378 </TabsContent>379380 <TabsContent value="cookies">381 <div className="rounded-lg border border-border">382 <Table>383 <TableHeader>384 <TableRow>385 <TableHead>Name</TableHead>386 <TableHead>Value</TableHead>387 <TableHead>Domain</TableHead>388 <TableHead>Path</TableHead>389 </TableRow>390 </TableHeader>391 <TableBody>392 {cookies.length === 0 ? <TableEmpty colSpan={4}>The target set no cookies.</TableEmpty> : null}393 {cookies.map((c, i) => (394 <TableRow key={`${c.name}-${i}`}>395 <TableCell className="font-mono text-[12.5px]">{c.name}</TableCell>396 <TableCell className="max-w-[24rem] truncate font-mono text-[12.5px]" title={c.value}>397 {c.value}398 </TableCell>399 <TableCell className="font-mono text-[12.5px] text-fg-muted">{c.domain ?? "—"}</TableCell>400 <TableCell className="font-mono text-[12.5px] text-fg-muted">{c.path ?? "—"}</TableCell>401 </TableRow>402 ))}403 </TableBody>404 </Table>405 </div>406 </TabsContent>407408 <TabsContent value="network">409 <NetworkTab result={r} request={request} />410 </TabsContent>411412 <TabsContent value="timing">413 <TimingBars timing={meta.timing} totalFallbackMs={meta.duration_ms ?? elapsedMs} />414 </TabsContent>415416 <TabsContent value="debug">417 <CodeBlock code={debugJson} lang="json" title="response.json" maxHeight={560} />418 </TabsContent>419420 <TabsContent value="code">{code}</TabsContent>421 </Tabs>422 </div>423 );424}425426function PageCard({ page }: { page: NonNullable<FetchResponseBody["page"]> }) {427 const rows: Array<{ label: string; value: string | null; mono?: boolean }> = [428 { label: "Title", value: page.title },429 { label: "Description", value: page.description },430 { label: "Canonical", value: page.canonical, mono: true },431 { label: "Language", value: page.lang, mono: true },432 { label: "Links", value: Number.isFinite(page.links_count) ? page.links_count.toLocaleString("en-US") : null, mono: true },433 ];434 const og = Object.entries(page.og ?? {});435 return (436 <section className="rounded-lg border border-border px-4 py-3" aria-label="Page metadata">437 <h2 className="text-[11.5px] font-semibold uppercase tracking-wide text-fg-subtle">Page</h2>438 <dl className="mt-2 grid gap-x-6 gap-y-1.5 text-[12.5px] sm:grid-cols-[auto_minmax(0,1fr)]">439 {rows.map((row) => (440 <React.Fragment key={row.label}>441 <dt className="text-fg-subtle">{row.label}</dt>442 <dd className={cn("min-w-0 break-words", row.mono && "font-mono", !row.value && "text-fg-subtle")} title={row.value ?? undefined}>443 {row.value || "—"}444 </dd>445 </React.Fragment>446 ))}447 </dl>448 {og.length ? (449 <details className="mt-2 text-xs text-fg-muted">450 <summary className="cursor-pointer select-none text-fg-subtle hover:text-fg">Open Graph ({og.length})</summary>451 <dl className="mt-1.5 grid gap-x-4 gap-y-1 sm:grid-cols-[auto_minmax(0,1fr)]">452 {og.map(([k, v]) => (453 <React.Fragment key={k}>454 <dt className="font-mono text-fg-subtle">{k}</dt>455 <dd className="min-w-0 break-words">{v}</dd>456 </React.Fragment>457 ))}458 </dl>459 </details>460 ) : null}461 </section>462 );463}464465function Metric({ label, value }: { label: string; value: string }) {466 return (467 <span className="inline-flex items-baseline gap-1 text-[12.5px]">468 <span className="text-fg-subtle">{label}</span>469 <span className="font-mono tabular text-fg">{value}</span>470 </span>471 );472}473474function Count({ n }: { n: number }) {475 return <span className="rounded-full bg-bg-muted px-1.5 text-[10.5px] font-medium tabular text-fg-muted">{n}</span>;476}477478function NoBody({ isHead, label }: { isHead: boolean; label?: string }) {479 return <EmptyState compact title={label ?? "No body"} description={isHead ? "HEAD requests return headers only — check the Headers tab." : "The response had an empty body."} />;480}481482function SourceBlock({ text, lang }: { text: string; lang: "html" | "text" }) {483 const t = truncateForDisplay(text);484 return (485 <div className="grid gap-2">486 {t.truncated ? (487 <p className="text-xs text-fg-muted">488 Showing first 200 KB of {formatBytes(t.total)}. Use the API or SDK to retrieve the full body.489 </p>490 ) : null}491 <CodeBlock code={t.text} lang={lang} lineNumbers maxHeight={560} />492 </div>493 );494}495496function TextBlock({ text }: { text: string }) {497 const t = truncateForDisplay(text);498 return (499 <div className="grid gap-2">500 {t.truncated ? <p className="text-xs text-fg-muted">Showing first 200 KB of {formatBytes(t.total)}.</p> : null}501 <pre className="max-h-[560px] overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-bg-subtle p-4 font-mono text-[12.5px] leading-relaxed scrollbar-thin">{t.text}</pre>502 </div>503 );504}505506function JsonBlock({ value }: { value: unknown }) {507 const pretty = React.useMemo(() => {508 try {509 return JSON.stringify(value, null, 2) ?? "";510 } catch {511 return String(value);512 }513 }, [value]);514 const t = truncateForDisplay(pretty);515 return (516 <div className="grid gap-2">517 {t.truncated ? <p className="text-xs text-fg-muted">Showing first 200 KB of {formatBytes(t.total)}.</p> : null}518 <CodeBlock code={t.text} lang="json" maxHeight={560} />519 </div>520 );521}522523function NetworkTab({ result: r, request }: { result: FetchResponseBody; request: FetchRequestInput }) {524 const m = r.metadata;525 const debugAttempts = m.debug?.attempts;526 const geo = [request.region, request.city].filter(Boolean).join(" / ");527 const rows: Array<{ label: string; value: string; muted?: boolean }> = [528 { label: "Requested network", value: request.network ?? "auto" },529 { label: "Network used", value: m.network },530 { label: "Mode", value: m.mode === "browser" ? "browser (rendered)" : "http" },531 { label: "Country", value: m.country ?? "any" },532 { label: "Region / city", value: geo || "—", muted: !geo },533 { label: "Attempts", value: String(m.attempts) },534 { label: "Session", value: m.session ?? "none (fresh IP)", muted: !m.session },535 { label: "Cached", value: m.cached ? "yes" : "no" },536 ];537 return (538 <div className="grid gap-5">539 <dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">540 {rows.map((row) => (541 <div key={row.label} className="flex items-center justify-between gap-3 border-b border-border py-1.5">542 <dt className="text-fg-muted">{row.label}</dt>543 <dd className={cn("text-right font-mono tabular", row.muted && "font-sans text-fg-subtle")}>{row.value}</dd>544 </div>545 ))}546 </dl>547548 {debugAttempts && debugAttempts.length ? (549 <div className="grid gap-2">550 <h3 className="text-[12.5px] font-semibold uppercase tracking-wide text-fg-subtle">Attempts</h3>551 <div className="rounded-lg border border-border">552 <Table>553 <TableHeader>554 <TableRow>555 <TableHead>#</TableHead>556 <TableHead>Route</TableHead>557 <TableHead>Network</TableHead>558 <TableHead>Mode</TableHead>559 <TableHead>Country</TableHead>560 <TableHead>Outcome</TableHead>561 <TableHead>Status</TableHead>562 <TableHead className="text-right">Duration</TableHead>563 </TableRow>564 </TableHeader>565 <TableBody>566 {debugAttempts.map((a, i) => (567 <TableRow key={i}>568 <TableCell className="font-mono tabular text-fg-muted">{i + 1}</TableCell>569 <TableCell className="font-mono text-[12.5px]">{a.provider}</TableCell>570 <TableCell className="font-mono text-[12.5px]">{a.network}</TableCell>571 <TableCell className="font-mono text-[12.5px]">{a.mode ?? "http"}</TableCell>572 <TableCell className="font-mono text-[12.5px]">{a.country ?? "any"}</TableCell>573 <TableCell>574 <Badge variant={a.outcome === "success" ? "success" : a.outcome === "blocked" ? "warning" : "danger"} dot>575 {a.outcome}576 </Badge>577 {a.block_reason ? (578 <span className="ml-2 font-mono text-[11.5px] text-fg-muted" title="Block reason">579 {a.block_reason}580 </span>581 ) : null}582 {a.error ? <span className="ml-2 text-xs text-fg-muted">{a.error}</span> : null}583 </TableCell>584 <TableCell className="font-mono tabular">{a.status ?? "—"}</TableCell>585 <TableCell className="text-right font-mono tabular">{formatMs(a.duration_ms)}</TableCell>586 </TableRow>587 ))}588 </TableBody>589 </Table>590 </div>591 </div>592 ) : (593 <p className="text-xs text-fg-subtle">Turn on <span className="font-medium text-fg-muted">Debug</span> in the request options to see each routing attempt.</p>594 )}595596 <div className={cn("rounded-lg border border-border bg-bg-subtle/50 p-4 text-[13px] leading-relaxed text-fg-muted")}>597 <p className="font-medium text-fg">How routing works</p>598 <p className="mt-1">599 In <span className="font-mono">auto</span> mode Fetcha scores every available route for this domain — historical success (35%), cost (20%), latency (15%), network health (15%), geography (10%) and session stability (5%) — starts with the cheapest network likely to succeed and escalates to premium networks when the target blocks or times out. Each retry uses a new IP. When an attempt is blocked by a JavaScript challenge and browser fallback is on, the next attempt is rendered in the managed browser. Requesting a specific network pins the first attempt to that class.600 </p>601 </div>602 </div>603 );604}605