SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
10.4 KB · 204 lines tsx
Raw Blame History
1"use client";23import { useMemo, useState } from "react";4import { Badge, Button, EmptyState, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";5import { cn } from "@/components/ui/cn";6import { DASH, fmtInt, fmtValue } from "@/lib/format";7import { adminFetch, type RunSampleResult } from "@/lib/admin-api";8import { Disclosure, ErrorNote, ExtLink, Field, fmtMs, JsonView, LevelBadge, Mono, Panel, Select, Spinner, Textarea } from "../primitives";9import { useToast } from "../toast";10import { fieldUnion, isEmptyValue, parseUrls, type DevtoolShared } from "./state";1112const HIDDEN = new Set(["provenance", "entityType", "key", "sourceUrl", "url"]);1314export function StepSample({ s }: { s: DevtoolShared }) {15  const { draft, update, sampleResult: r, setSampleResult } = s;16  const [busy, setBusy] = useState(false);17  const [error, setError] = useState<string | null>(null);18  const [level, setLevel] = useState<string>("");19  const toast = useToast();20  const urls = useMemo(() => parseUrls(draft.sampleUrls), [draft.sampleUrls]);2122  const run = async () => {23    if (!draft.yaml.trim()) return setError("No YAML — generate or paste it in step 4 first.");24    if (urls.length === 0) return setError("Enter at least one http(s) URL (one per line).");25    if (urls.length > 10) return setError("At most 10 URLs per run.");26    setBusy(true);27    setError(null);28    const res = await adminFetch<RunSampleResult>("devtool/run-sample", { method: "POST", body: { yaml: draft.yaml, urls, level: level ? Number(level) : undefined }, timeoutMs: 290_000 });29    setBusy(false);30    if (!res.ok) {31      setError(res.error);32      toast({ tone: "danger", title: "Sample run failed", description: res.error });33      return;34    }35    setSampleResult(res.data);36    toast({ tone: res.data.summary.ok === res.data.summary.urls ? "ok" : "warn", title: `${res.data.summary.ok}/${res.data.summary.urls} pages produced valid entities`, description: res.data.summary.credits ? `${res.data.summary.credits} premium credits spent` : "no premium credits" });37  };3839  const firstEntities = useMemo(() => (r ? r.results.map((x) => x.entities?.[0] ?? null) : []), [r]);40  const fields = useMemo(() => fieldUnion(firstEntities.filter((e): e is Record<string, unknown> => Boolean(e)), ["name", "operatorName", "city", "countryIso2", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw", "openedOn"]).filter((f) => !HIDDEN.has(f)), [firstEntities]);4142  return (43    <div className="space-y-3">44      <Panel title="5 · Run on multiple pages" description="Fetch + extract + normalize + validate each URL with the YAML from step 4 (test context: nothing is persisted; premium credits are counted). Up to 10 URLs.">45        <div className="grid gap-3 lg:grid-cols-[1fr_160px_auto] lg:items-end">46          <Field label={`URLs (${urls.length} valid)`} hint="one per line — use “+ sample” in the step-1 links tab to collect them">47            <Textarea rows={6} mono value={draft.sampleUrls} onChange={(e) => update({ sampleUrls: e.target.value })} placeholder={"https://www.example.com/data-centers/a\nhttps://www.example.com/data-centers/b"} />48          </Field>49          <Field label="Min fetch level" hint="override the YAML start level">50            <Select value={level} onChange={(e) => setLevel(e.target.value)}>51              <option value="">from YAML</option>52              <option value="1">L1 direct</option>53              <option value="2">L2 browser UA</option>54              <option value="3">L3 Firecrawl</option>55              <option value="4">L4 Scrapfly</option>56            </Select>57          </Field>58          <Button variant="accent" onClick={() => void run()} disabled={busy || urls.length === 0}>59            {busy ? <Spinner className="text-white" /> : null}60            {busy ? `Running ${urls.length} page(s)…` : "Run sample"}61          </Button>62        </div>63        <ErrorNote error={error} className="mt-3" />64      </Panel>6566      {r && (67        <>68          <div className="flex flex-wrap items-center gap-2 text-[12.5px]">69            <Badge size="sm" tone={r.summary.ok === r.summary.urls ? "ok" : r.summary.ok ? "warn" : "danger"}>70              {r.summary.ok} / {r.summary.urls} ok71            </Badge>72            <span className="text-ink-3">73              credits <span className={cn("figure", r.summary.credits > 0 && "text-accent")}>{fmtInt(r.summary.credits)}</span>74            </span>75            <span className="text-ink-3">field coverage:</span>76            {Object.entries(r.summary.fieldCoverage)77              .filter(([k]) => !HIDDEN.has(k))78              .sort((a, b) => b[1] - a[1])79              .map(([k, n]) => (80                <Badge key={k} size="xs" mono tone={n === r.summary.urls ? "ok" : n === 0 ? "danger" : "outline"}>81                  {k} {n}/{r.summary.urls}82                </Badge>83              ))}84          </div>8586          <Panel title="Comparison — first entity per URL" description="Empty cells highlighted: a rule that works on one page and not another usually means the markup differs." padded={false}>87            {fields.length === 0 ? (88              <EmptyState compact title="No entities extracted on any page" />89            ) : (90              <div className="overflow-auto">91                <Table responsive={false} className="min-w-[720px] text-[12px]">92                  <THead>93                    <Tr>94                      <Th className="pl-3">URL</Th>95                      <Th>OK</Th>96                      {fields.map((f) => (97                        <Th key={f}>98                          <Mono className="text-ink-3">{f}</Mono>99                        </Th>100                      ))}101                    </Tr>102                  </THead>103                  <TBody>104                    {r.results.map((x, i) => {105                      const e = firstEntities[i];106                      return (107                        <Tr key={x.url}>108                          <Td className="max-w-[260px] pl-3">109                            <ExtLink href={x.url} />110                          </Td>111                          <Td>{x.ok ? <span className="text-ok">✓</span> : <span className="text-danger">✗</span>}</Td>112                          {fields.map((f) => (113                            <Td key={f} className={cn("max-w-[200px] truncate", (!e || isEmptyValue(e[f])) && "bg-warn/10 text-warn")} title={e ? (typeof e[f] === "object" ? JSON.stringify(e[f]) : String(e[f] ?? "")) : ""}>114                              {!e || isEmptyValue(e[f]) ? "empty" : fmtValue(e[f])}115                            </Td>116                          ))}117                        </Tr>118                      );119                    })}120                  </TBody>121                </Table>122              </div>123            )}124          </Panel>125126          <div className="space-y-1.5">127            {r.results.map((x) => (128              <Disclosure129                key={x.url}130                summary={131                  <span className="flex flex-wrap items-center gap-2">132                    {x.ok ? (133                      <Badge size="xs" tone="ok">134                        ok135                      </Badge>136                    ) : (137                      <Badge size="xs" tone="danger">138                        failed139                      </Badge>140                    )}141                    <span className="min-w-0 max-w-[420px] truncate font-mono text-[12px] text-ink">{x.url.replace(/^https?:\/\//, "")}</span>142                    {x.fetch && (143                      <>144                        <Mono>HTTP {x.fetch.status}</Mono>145                        <LevelBadge level={x.fetch.level} />146                        <span className="text-ink-3">{x.fetch.fetcher}</span>147                      </>148                    )}149                    <span className="figure text-ink-3">{fmtMs(x.durationMs)}</span>150                    {x.entities && <span className="figure text-ink-3">{fmtInt(x.records?.length ?? 0)} rec → {fmtInt(x.entities.length)} ent</span>}151                    {x.validation && (152                      <span className={cn("figure", x.validation.rejected ? "text-danger" : "text-ink-3")}>153                        {fmtInt(x.validation.valid)} valid / {fmtInt(x.validation.rejected)} rejected154                      </span>155                    )}156                    {x.error && <span className="truncate text-danger">{x.error}</span>}157                  </span>158                }159              >160                <div className="grid gap-3 lg:grid-cols-2">161                  <div>162                    <div className="label-caps mb-1">Validation issues</div>163                    {!x.validation || x.validation.issues.length === 0 ? (164                      <p className="text-[12px] text-ink-4">{x.validation ? "none" : DASH}</p>165                    ) : (166                      <ul className="space-y-0.5 text-[12px]">167                        {x.validation.issues.map((i, k) => (168                          <li key={k} className="flex flex-wrap gap-2">169                            <Badge size="xs" tone={String(i.level ?? i.severity ?? "error").startsWith("warn") ? "warn" : "danger"}>170                              {String(i.level ?? i.severity ?? "error")}171                            </Badge>172                            {(i.field ?? i.path) && <Mono className="text-ink">{String(i.field ?? i.path)}</Mono>}173                            <span className="text-ink-2">{String(i.message ?? JSON.stringify(i))}</span>174                          </li>175                        ))}176                      </ul>177                    )}178                    {x.logs && x.logs.length > 0 && (179                      <>180                        <div className="label-caps mb-1 mt-3">Log</div>181                        <div className="max-h-[160px] overflow-auto font-mono text-[11.5px] text-ink-3">182                          {x.logs.map((l, k) => (183                            <div key={k} className={cn(l.level === "error" && "text-danger", l.level === "warn" && "text-warn")}>184                              [{l.level}] {l.msg}185                            </div>186                          ))}187                        </div>188                      </>189                    )}190                  </div>191                  <div>192                    <div className="label-caps mb-1">Entities</div>193                    {x.entities && x.entities.length ? <JsonView value={x.entities} maxHeight={300} /> : <p className="text-[12px] text-ink-4">none</p>}194                  </div>195                </div>196              </Disclosure>197            ))}198          </div>199        </>200      )}201    </div>202  );203}204