SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
28.0 KB · 605 lines tsx
Raw Blame History
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