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.6 KB · 235 lines tsx
Raw Blame History
1"use client";23import { Check } from "lucide-react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";5import { useMounted } from "@/components/theme";6import { Badge, Button } from "@/components/ui";7import { cn } from "@/components/ui/cn";8import { adminFetch, adminFetchText, type ConfigListItem, type DevtoolFetchResult, type DevtoolPreviewResult, type RunSampleResult, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";9import { ErrorNote, Mono, Select, Spinner } from "../primitives";10import { AdminPageHeader } from "../shell";11import { useToast } from "../toast";12import { useAdminQuery } from "../use-admin-query";13import { useUrlState } from "../use-url-state";14import { buildYaml, draftFromConfig, EMPTY_DRAFT, loadDraft, saveDraft, type DevtoolShared, type Draft } from "./state";15import { StepDeploy } from "./step-deploy";16import { StepFetch } from "./step-fetch";17import { StepPreview } from "./step-preview";18import { StepRules } from "./step-rules";19import { StepSample } from "./step-sample";20import { StepSave } from "./step-save";2122const STEPS = [23  { n: 1, label: "Fetch", short: "Fetch sample" },24  { n: 2, label: "Rules", short: "Extraction rules" },25  { n: 3, label: "Preview", short: "Preview fields" },26  { n: 4, label: "Save", short: "Save connector" },27  { n: 5, label: "Sample", short: "Run on pages" },28  { n: 6, label: "Deploy", short: "Compare & deploy" },29] as const;3031export function DevTool() {32  const [params, setParams] = useUrlState();33  const step = Math.min(6, Math.max(1, Number(params.step ?? 1) || 1));34  // Draft is read lazily from localStorage (+ ?url=) on the client; the server render and the first client render35  // both return null (useMounted) so there is no hydration mismatch and no setState inside an effect.36  const hydrated = useMounted();37  const [draft, setDraft] = useState<Draft>(() => {38    if (typeof window === "undefined") return EMPTY_DRAFT;39    const stored = loadDraft() ?? EMPTY_DRAFT;40    const u = new URLSearchParams(window.location.search).get("url");41    return u ? { ...stored, fetch: { ...stored.fetch, url: u } } : stored;42  });43  const [initialId] = useState<string | null>(() => (typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("id")));44  const [fetchResult, setFetchResult] = useState<DevtoolFetchResult | null>(null);45  const [previewResult, setPreviewResult] = useState<DevtoolPreviewResult | null>(null);46  const [validation, setValidation] = useState<ValidateConfigResult | null>(null);47  const [saveResult, setSaveResult] = useState<SaveConfigResult | null>(null);48  const [sampleResult, setSampleResult] = useState<RunSampleResult | null>(null);49  const [loadingId, setLoadingId] = useState<string | null>(null);50  const [loadError, setLoadError] = useState<string | null>(null);51  const toast = useToast();52  const configs = useAdminQuery<ConfigListItem[]>("devtool/configs", { timeoutMs: 30_000 });5354  // ?id=<connector> different from the stored draft → load it once after mount55  const requestedId = useRef<string | null>(initialId && initialId !== draft.meta.id ? initialId : null);5657  // persist (debounced)58  useEffect(() => {59    if (!hydrated) return;60    const t = setTimeout(() => saveDraft(draft), 300);61    return () => clearTimeout(t);62  }, [draft, hydrated]);6364  const update = useCallback((patch: Partial<Draft> | ((d: Draft) => Draft)) => {65    setDraft((d) => (typeof patch === "function" ? patch(d) : { ...d, ...patch }));66  }, []);6768  const goto = useCallback(69    (n: number) => {70      setParams({ step: n === 1 ? null : n, url: draft.fetch.url || null, id: draft.meta.id || null }, { push: true });71    },72    [setParams, draft.fetch.url, draft.meta.id],73  );7475  // keep url/id in the address bar in sync (replace, no history)76  useEffect(() => {77    if (!hydrated) return;78    const wantUrl = draft.fetch.url || null;79    const wantId = draft.meta.id || null;80    if ((params.url ?? null) !== wantUrl || (params.id ?? null) !== wantId) setParams({ url: wantUrl, id: wantId });81  }, [draft.fetch.url, draft.meta.id, hydrated, params.url, params.id, setParams]);8283  const loadConnector = useCallback(84    async (id: string) => {85      if (!id) return;86      setLoadingId(id);87      setLoadError(null);88      const text = await adminFetchText(`devtool/configs/${encodeURIComponent(id)}`);89      if (!text.ok) {90        setLoadingId(null);91        setLoadError(`Could not load ${id}.yaml: ${text.error}`);92        return;93      }94      const v = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: text.text } });95      setLoadingId(null);96      if (!v.ok) {97        setLoadError(`validate-config failed: ${v.error}`);98        return;99      }100      setValidation(v.data);101      if (v.data.ok) {102        setDraft((d) => draftFromConfig(v.data.ok ? v.data.config : {}, text.text, { ...d, fetch: { ...d.fetch, url: "" } }));103        toast({ tone: "ok", title: `Loaded ${id}`, description: `${Object.keys(v.data.config.extractors as object).length} extractor(s) · parser ${String(v.data.config.parserVersion)}` });104      } else {105        setDraft((d) => ({ ...d, yaml: text.text, yamlDirty: true, meta: { ...d.meta, id } }));106        toast({ tone: "warn", title: `Loaded ${id} with schema errors`, description: v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ").slice(0, 200) });107        setParams({ step: 4 });108      }109      setFetchResult(null);110      setPreviewResult(null);111      setSaveResult(null);112      setSampleResult(null);113    },114    [toast, setParams],115  );116117  useEffect(() => {118    if (!hydrated || !requestedId.current) return;119    const id = requestedId.current;120    requestedId.current = null;121    const t = setTimeout(() => void loadConnector(id), 0);122    return () => clearTimeout(t);123  }, [hydrated, loadConnector]);124125  const reset = () => {126    if (!window.confirm("Clear the current draft (rules, YAML, sample URLs)?")) return;127    setDraft(EMPTY_DRAFT);128    setFetchResult(null);129    setPreviewResult(null);130    setValidation(null);131    setSaveResult(null);132    setSampleResult(null);133    setParams({ step: null, url: null, id: null });134  };135136  const shared: DevtoolShared = useMemo(137    () => ({ draft, update, fetchResult, setFetchResult, previewResult, setPreviewResult, validation, setValidation, saveResult, setSaveResult, sampleResult, setSampleResult, goto }),138    [draft, update, fetchResult, previewResult, validation, saveResult, sampleResult, goto],139  );140141  const done = {142    1: Boolean(fetchResult && !fetchResult.error),143    2: draft.extractor.fields.some((f) => f.name.trim()),144    3: Boolean(previewResult && previewResult.entities.length > 0),145    4: Boolean(saveResult),146    5: Boolean(sampleResult),147    6: false,148  } as Record<number, boolean>;149150  if (!hydrated) return null;151152  return (153    <div>154      <AdminPageHeader155        title="Connector development tool"156        description="Fetch a live page through the escalation stack, define declarative extraction rules, preview the normalized entities, save the YAML, run it on several pages, then deploy a parser version. Nothing here persists documents or entities."157        aside={158          <>159            <div className="flex items-center gap-2">160              <Select value="" onChange={(e) => void loadConnector(e.target.value)} className="w-[230px]" aria-label="Load existing connector" disabled={loadingId !== null}>161                <option value="">{loadingId ? `Loading ${loadingId}…` : "Load existing connector…"}</option>162                {(configs.data ?? []).map((c) => (163                  <option key={c.id} value={c.id}>164                    {c.id}165                    {c.valid ? "" : " (invalid)"} · {c.parserVersion}166                  </option>167                ))}168              </Select>169              {loadingId && <Spinner />}170            </div>171            <Button size="sm" variant="ghost" onClick={reset}>172              New draft173            </Button>174          </>175        }176      />177      <ErrorNote error={loadError} className="mb-3" />178179      <div className="mb-3 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">180        {draft.meta.id ? (181          <span>182            Editing <Mono className="text-ink">{draft.meta.id}</Mono>183          </span>184        ) : (185          <span>New connector draft</span>186        )}187        {draft.fetch.url && (188          <span className="min-w-0 truncate">189            · <Mono>{draft.fetch.url}</Mono>190          </span>191        )}192        {draft.savedAt && <span className="ml-auto text-ink-4">draft kept in this browser</span>}193      </div>194195      <ol className="mb-4 flex overflow-x-auto rounded-sm border border-hair bg-surface no-scrollbar">196        {STEPS.map((s) => {197          const active = s.n === step;198          return (199            <li key={s.n} className="flex min-w-0 flex-1">200              <button type="button" onClick={() => goto(s.n)} aria-current={active ? "step" : undefined} className={cn("flex h-11 w-full min-w-[96px] items-center gap-2 border-r border-hair px-3 text-left text-[12.5px] last:border-r-0 sm:min-w-0", active ? "bg-ink text-paper" : "text-ink-2 hover:bg-surface-2")}>201                <span className={cn("inline-flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium", active ? "border-paper/50" : done[s.n] ? "border-ok bg-ok text-white" : "border-hair-2")}>{done[s.n] && !active ? <Check className="size-3" strokeWidth={2.5} /> : s.n}</span>202                <span className="truncate">203                  <span className="sm:hidden">{s.label}</span>204                  <span className="hidden sm:inline">{s.short}</span>205                </span>206              </button>207            </li>208          );209        })}210      </ol>211212      {step === 1 && <StepFetch s={shared} />}213      {step === 2 && <StepRules s={shared} />}214      {step === 3 && <StepPreview s={shared} />}215      {step === 4 && <StepSave s={shared} />}216      {step === 5 && <StepSample s={shared} />}217      {step === 6 && <StepDeploy s={shared} configs={configs.data ?? []} onReloadConfigs={() => void configs.refresh()} />}218219      <div className="mt-4 flex items-center justify-between border-t border-hair pt-3">220        <Button size="sm" variant="ghost" onClick={() => goto(step - 1)} disabled={step <= 1}>221          ← {STEPS[step - 2]?.short ?? ""}222        </Button>223        {step === 3 && !draft.yaml && (224          <Badge size="xs" tone="info">225            the YAML template is generated when you enter step 4226          </Badge>227        )}228        <Button size="sm" variant="secondary" onClick={() => { if (step === 3 && !draft.yamlDirty) update({ yaml: buildYaml(draft) }); goto(step + 1); }} disabled={step >= 6}>229          {STEPS[step]?.short ?? ""} →230        </Button>231      </div>232    </div>233  );234}235