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