spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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