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%
12.0 KB · 222 lines tsx
Raw Blame History
1"use client";23import { 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 DevtoolPreviewResult } from "@/lib/admin-api";8import { ErrorNote, Field, fmtMs, Input, JsonView, LevelBadge, Mono, Panel, Spinner } from "../primitives";9import { useToast } from "../toast";10import { defaultsToConfig, extractorToConfig, fieldUnion, isEmptyValue, type DevtoolShared } from "./state";1112export function StepPreview({ s }: { s: DevtoolShared }) {13  const { draft, update, fetchResult, previewResult: r, setPreviewResult } = s;14  const [busy, setBusy] = useState(false);15  const [error, setError] = useState<string | null>(null);16  const [details, setDetails] = useState<unknown>(null);17  const toast = useToast();18  const d = draft.defaults;19  const setDefaults = (patch: Partial<typeof d>) => update({ defaults: { ...d, ...patch } });20  const canReuseHtml = Boolean(fetchResult?.html && fetchResult.url === draft.fetch.url && !fetchResult.error);2122  const run = async () => {23    const url = draft.fetch.url.trim();24    if (!url) return setError("No URL — go back to step 1.");25    const extractor = extractorToConfig(draft.extractor);26    if (!extractor.fields && !extractor.parser) return setError("Define at least one field rule (or a parser) in step 2.");27    setBusy(true);28    setError(null);29    setDetails(null);30    const body: Record<string, unknown> = { url, extractor, defaults: defaultsToConfig(d), connectorId: draft.meta.id || undefined, kind: draft.extractor.kind, level: draft.fetch.level, maxLevel: Math.max(draft.fetch.level, draft.fetch.maxLevel) };31    if (canReuseHtml) body.html = fetchResult!.html;32    const res = await adminFetch<DevtoolPreviewResult>("devtool/preview", { method: "POST", body, timeoutMs: 120_000 });33    setBusy(false);34    if (!res.ok) {35      setError(res.error);36      setDetails(res.details ?? null);37      toast({ tone: "danger", title: "Preview failed", description: res.error });38      return;39    }40    setPreviewResult(res.data);41    const v = res.data.validation;42    toast({ tone: v.rejected > 0 ? "warn" : res.data.entities.length ? "ok" : "warn", title: `${fmtInt(res.data.records.length)} record(s) → ${fmtInt(res.data.entities.length)} entit${res.data.entities.length === 1 ? "y" : "ies"}`, description: `${fmtInt(v.valid)} valid · ${fmtInt(v.rejected)} rejected · ${fmtInt(v.issues.length)} issue(s)` });43  };4445  const entityFields = r ? fieldUnion(r.entities, ["entityType", "key", "name", "operatorName", "city", "countryIso2", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw"]) : [];46  const recordFields = r ? fieldUnion(r.records) : [];47  const ruleNames = draft.extractor.fields.map((f) => f.name.trim()).filter(Boolean);48  const emptyRules = r && r.records.length ? ruleNames.filter((n) => r.records.every((rec) => isEmptyValue(rec[n]))) : [];4950  return (51    <div className="space-y-3">52      <Panel title="3 · Preview extraction" description={canReuseHtml ? "Runs the extractor on the HTML fetched in step 1 (no new request), then normalize → validate through GenericConnector." : "The page will be fetched again (step 1 result is for another URL or failed)."}>53        <div className="grid gap-3 lg:grid-cols-[repeat(5,1fr)_auto] lg:items-end">54          <Field label="Default operator" hint="merged into every entity">55            <Input value={d.operatorName} onChange={(e) => setDefaults({ operatorName: e.target.value })} placeholder="Digital Realty" />56          </Field>57          <Field label="Operator kind">58            <Input value={d.operatorKind} onChange={(e) => setDefaults({ operatorKind: e.target.value })} placeholder="colocation" mono />59          </Field>60          <Field label="Country (iso2)">61            <Input value={d.countryIso2} onChange={(e) => setDefaults({ countryIso2: e.target.value.toUpperCase().slice(0, 2) })} placeholder="US" mono />62          </Field>63          <Field label="Facility type">64            <Input value={d.facilityType} onChange={(e) => setDefaults({ facilityType: e.target.value })} placeholder="colocation" mono />65          </Field>66          <Field label="Status">67            <Input value={d.status} onChange={(e) => setDefaults({ status: e.target.value })} placeholder="operational" mono />68          </Field>69          <Button variant="accent" onClick={() => void run()} disabled={busy}>70            {busy ? <Spinner className="text-white" /> : null}71            {busy ? "Running…" : "Preview fields"}72          </Button>73        </div>74        <ErrorNote error={error} className="mt-3" />75        {details ? <JsonView value={details} maxHeight={160} className="mt-2" /> : null}76      </Panel>7778      {r && (79        <>80          <div className="flex flex-wrap items-center gap-2 text-[12.5px]">81            <Badge size="sm" tone="outline" mono>82              HTTP {r.fetch.status}83            </Badge>84            <Badge size="sm" tone="outline">85              {r.fetch.fetcher}86            </Badge>87            <LevelBadge level={r.fetch.level} />88            <span className="text-ink-3">{fmtMs(r.fetch.durationMs)}</span>89            <span className="figure text-ink">{fmtInt(r.records.length)} records</span>90            <span className="text-ink-4">→</span>91            <span className="figure text-ink">{fmtInt(r.entities.length)} entities</span>92            <span className="text-ink-4">→</span>93            <span className={cn("figure", r.validation.rejected ? "text-danger" : "text-ok")}>94              {fmtInt(r.validation.valid)} valid / {fmtInt(r.validation.rejected)} rejected95            </span>96            {emptyRules.length > 0 && (97              <Badge size="sm" tone="warn">98                empty on every record: {emptyRules.join(", ")}99              </Badge>100            )}101          </div>102103          <div className="grid gap-3 xl:grid-cols-2">104            <Panel title="Records (raw extraction)" description="Field values as produced by the rules, before normalization. Empty cells are highlighted." padded={false}>105              {r.records.length === 0 ? (106                <EmptyState compact title="No records" description="The rules matched nothing — check the selectors against the HTML source in step 1 (or `each` selects no element)." />107              ) : (108                <div className="max-h-[440px] overflow-auto">109                  <Table responsive={false} className="text-[12px]">110                    <THead>111                      <Tr>112                        <Th className="pl-3">Field</Th>113                        {r.records.slice(0, 6).map((_, i) => (114                          <Th key={i}>#{i + 1}</Th>115                        ))}116                      </Tr>117                    </THead>118                    <TBody>119                      {[...new Set([...ruleNames, ...recordFields])].map((k) => (120                        <Tr key={k}>121                          <Td className="pl-3">122                            <Mono className={cn(ruleNames.includes(k) ? "text-ink" : "text-ink-3")}>{k}</Mono>123                          </Td>124                          {r.records.slice(0, 6).map((rec, i) => (125                            <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(rec[k]) && "bg-warn/10 text-warn")} title={typeof rec[k] === "object" ? JSON.stringify(rec[k]) : String(rec[k] ?? "")}>126                              {isEmptyValue(rec[k]) ? "empty" : fmtValue(rec[k])}127                            </Td>128                          ))}129                        </Tr>130                      ))}131                    </TBody>132                  </Table>133                  {r.records.length > 6 && <p className="px-3 py-1.5 text-[11.5px] text-ink-4">Showing 6 of {fmtInt(r.records.length)} records.</p>}134                </div>135              )}136            </Panel>137138            <Panel title="Normalized entities" description="After normalize(): units parsed, statuses mapped, defaults merged. Empty fields highlighted." padded={false}>139              {r.entities.length === 0 ? (140                <EmptyState compact title="No entities" description="Records did not survive normalization (missing name / key?). Check the validation issues." />141              ) : (142                <div className="max-h-[440px] overflow-auto">143                  <Table responsive={false} className="text-[12px]">144                    <THead>145                      <Tr>146                        <Th className="pl-3">Field</Th>147                        {r.entities.slice(0, 6).map((_, i) => (148                          <Th key={i}>#{i + 1}</Th>149                        ))}150                      </Tr>151                    </THead>152                    <TBody>153                      {entityFields.map((k) => (154                        <Tr key={k}>155                          <Td className="pl-3">156                            <Mono className="text-ink">{k}</Mono>157                          </Td>158                          {r.entities.slice(0, 6).map((e, i) => (159                            <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(e[k]) && "bg-warn/10 text-warn")} title={typeof e[k] === "object" ? JSON.stringify(e[k]) : String(e[k] ?? "")}>160                              {isEmptyValue(e[k]) ? "empty" : fmtValue(e[k])}161                            </Td>162                          ))}163                        </Tr>164                      ))}165                    </TBody>166                  </Table>167                </div>168              )}169            </Panel>170          </div>171172          <div className="grid gap-3 xl:grid-cols-2">173            <Panel title="Validation issues" description={`${fmtInt(r.validation.issues.length)} issue(s) — rejected entities never reach the index`} padded={false}>174              {r.validation.issues.length === 0 ? (175                <EmptyState compact title="No validation issues" />176              ) : (177                <ul className="max-h-[320px] divide-y divide-hair overflow-auto text-[12.5px]">178                  {r.validation.issues.map((i, k) => {179                    const level = String(i.level ?? i.severity ?? "error");180                    return (181                      <li key={k} className="flex flex-wrap items-center gap-2 px-3 py-1.5">182                        <Badge size="xs" tone={level === "warn" || level === "warning" ? "warn" : "danger"}>183                          {level}184                        </Badge>185                        {(i.field ?? i.path) && <Mono className="text-ink">{String(i.field ?? i.path)}</Mono>}186                        <span className="text-ink-2">{String(i.message ?? JSON.stringify(i))}</span>187                        {(i.entity ?? i.key) != null && <Mono className="ml-auto text-ink-4">{String(i.entity ?? i.key)}</Mono>}188                      </li>189                    );190                  })}191                </ul>192              )}193            </Panel>194            <Panel title="Pipeline log" padded={false}>195              {r.logs.length === 0 ? (196                <EmptyState compact title="No log lines" />197              ) : (198                <div className="max-h-[320px] overflow-auto font-mono text-[11.5px] leading-[1.5]">199                  {r.logs.map((l, i) => (200                    <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-3 py-0.5 last:border-0", l.level === "error" && "text-danger", l.level === "warn" && "text-warn")}>201                      <span className="w-10 shrink-0 text-ink-4">{l.level}</span>202                      <span className="min-w-0 break-all text-ink-2">{l.msg}</span>203                    </div>204                  ))}205                </div>206              )}207            </Panel>208          </div>209          {r.entities.length > 0 && (210            <details className="rounded-sm border border-hair bg-surface">211              <summary className="cursor-pointer px-3 py-2 text-[12.5px] text-ink-2">Raw JSON (first entity)</summary>212              <div className="px-3 pb-3">213                <JsonView value={r.entities[0] ?? DASH} maxHeight={360} />214              </div>215            </details>216          )}217        </>218      )}219    </div>220  );221}222