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.3 KB · 207 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useState } from "react";5import { Badge, Button, EmptyState } from "@/components/ui";6import { adminFetch, adminFetchText, type ConfigListItem, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";7import { Checkbox, DiffView, ErrorNote, Field, Input, KV, Mono, Panel, Select, Spinner } from "../primitives";8import { useToast } from "../toast";9import { bumpVersion } from "../yaml";10import type { DevtoolShared } from "./state";1112interface DeployLog {13  step: string;14  ok: boolean;15  detail: string;16}1718/** Loads config/connectors/<id>.yaml text; `token` forces a reload. Loading is derived from the key, never set in the effect. */19function useSavedYaml(id: string, token: number): { loading: boolean; text: string | null; error: string | null } {20  const key = `${id}|${token}`;21  const [slot, setSlot] = useState<{ key: string; text: string | null; error: string | null }>({ key: "", text: null, error: null });22  useEffect(() => {23    if (!id) return;24    let alive = true;25    void adminFetchText(`devtool/configs/${encodeURIComponent(id)}`).then((r) => {26      if (!alive) return;27      setSlot({ key, text: r.ok ? r.text : null, error: r.ok ? null : r.status === 404 ? `No saved config for "${id}" — this would be a new connector (save it in step 4 first).` : (r.error ?? "load failed") });28    });29    return () => {30      alive = false;31    };32  }, [id, key]);33  const current = slot.key === key;34  return { loading: Boolean(id) && !current, text: current ? slot.text : null, error: current ? slot.error : null };35}3637export function StepDeploy({ s, configs, onReloadConfigs }: { s: DevtoolShared; configs: ConfigListItem[]; onReloadConfigs: () => void }) {38  const { draft, update, validation, setValidation, setSaveResult } = s;39  const [id, setId] = useState(draft.meta.id);40  const [reloadToken, setReloadToken] = useState(0);41  const savedQ = useSavedYaml(id, reloadToken);42  const savedText = savedQ.text;43  const saved = useMemo(() => (savedText != null ? { id, text: savedText } : null), [id, savedText]);44  const [versionInput, setVersionInput] = useState<string | null>(null);45  const [reprocess, setReprocess] = useState(true);46  const [busy, setBusy] = useState(false);47  const [log, setLog] = useState<DeployLog[]>([]);48  const toast = useToast();49  const listed = configs.find((c) => c.id === id);50  const savedVersion = useMemo(() => {51    const m = /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(saved?.text ?? "");52    return m?.[1]?.trim() ?? listed?.parserVersion ?? "v1";53  }, [saved, listed]);54  const draftVersion = useMemo(() => /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(draft.yaml)?.[1]?.trim() ?? null, [draft.yaml]);55  const version = versionInput ?? bumpVersion(savedVersion);5657  const changeId = (v: string) => {58    setId(v.toLowerCase());59    setVersionInput(null);60    setLog([]);61  };6263  const deploy = async () => {64    if (!id) return;65    if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return toast({ tone: "danger", title: "Invalid connector id" });66    if (!draft.yaml.trim()) return toast({ tone: "danger", title: "No YAML to deploy (step 4)" });67    if (!window.confirm(`Deploy ${id}: set parserVersion ${version}, write ${id}.yaml (backup kept), upsert the connectors row${reprocess ? ", then enqueue a reprocess run over stored documents" : ""}?`)) return;68    setBusy(true);69    const out: DeployLog[] = [];70    const push = (l: DeployLog) => {71      out.push(l);72      setLog([...out]);73    };74    const v = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } });75    if (!v.ok || !v.data.ok) {76      push({ step: "validate", ok: false, detail: v.ok ? (v.data.ok ? "" : v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ")) : v.error });77      setValidation(v.ok ? v.data : null);78      setBusy(false);79      toast({ tone: "danger", title: "Deploy aborted: YAML invalid" });80      return;81    }82    setValidation(v.data);83    if (String(v.data.config.id) !== id) {84      push({ step: "validate", ok: false, detail: `YAML id "${String(v.data.config.id)}" does not match "${id}"` });85      setBusy(false);86      return;87    }88    push({ step: "validate", ok: true, detail: `${v.data.warnings.length} warning(s)` });89    const sv = await adminFetch<SaveConfigResult>("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml, version: version.trim() || undefined } });90    if (!sv.ok) {91      push({ step: "save-config", ok: false, detail: sv.error });92      setBusy(false);93      toast({ tone: "danger", title: "Deploy failed at save", description: sv.error });94      return;95    }96    setSaveResult(sv.data);97    push({ step: "save-config", ok: true, detail: `${sv.data.path} · parser ${sv.data.parserVersion}${sv.data.backup ? ` · backup ${sv.data.backup.split("/").pop()}` : ""}` });98    // reflect the bumped version in the editor99    update((d) => ({ ...d, yaml: /^parserVersion:/m.test(d.yaml) ? d.yaml.replace(/^parserVersion:.*$/m, `parserVersion: ${sv.data.parserVersion}`) : `${d.yaml.trimEnd()}\nparserVersion: ${sv.data.parserVersion}\n`, meta: { ...d.meta, id } }));100    if (reprocess) {101      const run = await adminFetch<{ enqueued: boolean; job?: { id?: string } }>(`connectors/${encodeURIComponent(id)}/run`, { method: "POST", body: { task: "reprocess" } });102      if (run.ok) push({ step: "run reprocess", ok: true, detail: run.data.job?.id ? `job ${run.data.job.id}` : "enqueued" });103      else push({ step: "run reprocess", ok: false, detail: run.error });104    }105    setBusy(false);106    setReloadToken((t) => t + 1);107    setVersionInput(null);108    onReloadConfigs();109    toast({ tone: out.every((l) => l.ok) ? "ok" : "warn", title: `Deployed ${id} @ ${sv.data.parserVersion}`, description: out.map((l) => `${l.step}: ${l.ok ? "ok" : "failed"}`).join(" · ") });110  };111112  return (113    <div className="space-y-3">114      <Panel title="6 · Compare with the saved connector" description="Left = config/connectors/<id>.yaml on disk, right = the YAML in your editor (step 4).">115        <div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">116          <Field label="Connector id">117            <div className="flex gap-2">118              <Input value={id} onChange={(e) => changeId(e.target.value)} mono placeholder="connector id" className="flex-1" />119              <Select value="" onChange={(e) => { if (e.target.value) changeId(e.target.value); }} className="w-[200px]" aria-label="Pick saved connector">120                <option value="">pick saved…</option>121                {configs.map((c) => (122                  <option key={c.id} value={c.id}>123                    {c.id} · {c.parserVersion}124                  </option>125                ))}126              </Select>127            </div>128          </Field>129          <Button size="md" variant="secondary" onClick={() => setReloadToken((t) => t + 1)} disabled={!id || savedQ.loading}>130            {savedQ.loading ? <Spinner /> : null} Reload saved131          </Button>132        </div>133        <ErrorNote error={savedQ.error} className="mt-3" />134        {saved && (135          <div className="mt-3">136            <KV137              cols={3}138              items={[139                { k: "Saved parser version", v: <Mono>{savedVersion}</Mono> },140                { k: "Editor parser version", v: <Mono>{draftVersion ?? "(none)"}</Mono> },141                {142                  k: "Listed",143                  v: listed ? (144                    <span>145                      {listed.valid ? (146                        <Badge size="xs" tone="ok">147                          valid148                        </Badge>149                      ) : (150                        <Badge size="xs" tone="danger">151                          invalid152                        </Badge>153                      )}{" "}154                      · enabled {String(listed.enabled)}155                    </span>156                  ) : (157                    "not on disk list"158                  ),159                },160              ]}161            />162            <div className="mt-3">163              <DiffView before={saved.text} after={draft.yaml} context={3} labels={[`${saved.id}.yaml (saved)`, "editor"]} maxHeight={520} />164            </div>165          </div>166        )}167        {!saved && !savedQ.error && !savedQ.loading && !id && <EmptyState compact title="Pick or type a connector id" />}168      </Panel>169170      <Panel title="Deploy parser version" description="Bumps parserVersion in the YAML, saves it (backup kept), upserts the connectors row, then enqueues `reprocess` so stored bodies are re-extracted with the new rules — no refetch, no premium credits.">171        <div className="grid gap-3 sm:grid-cols-[180px_1fr_auto] sm:items-end">172          <Field label="New parserVersion" hint={`saved: ${savedVersion}`}>173            <Input value={version} onChange={(e) => setVersionInput(e.target.value)} mono placeholder={bumpVersion(savedVersion)} />174          </Field>175          <Checkbox label="enqueue reprocess run after saving" checked={reprocess} onChange={(e) => setReprocess(e.target.checked)} />176          <Button variant="accent" onClick={() => void deploy()} disabled={busy || !id || !draft.yaml.trim()}>177            {busy ? <Spinner className="text-white" /> : null} Deploy178          </Button>179        </div>180        {validation && !validation.ok && <ErrorNote error={`YAML invalid: ${validation.errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`} className="mt-3" />}181        {log.length > 0 && (182          <ol className="mt-3 space-y-1 text-[12.5px]">183            {log.map((l, i) => (184              <li key={i} className="flex flex-wrap items-center gap-2">185                <Badge size="xs" tone={l.ok ? "ok" : "danger"}>186                  {l.ok ? "ok" : "failed"}187                </Badge>188                <Mono className="text-ink">{l.step}</Mono>189                <span className="text-ink-2">{l.detail}</span>190              </li>191            ))}192            {log.some((l) => l.step === "save-config" && l.ok) && (193              <li className="pt-1 text-[12px] text-ink-3">194                Follow the run on{" "}195                <Link href={`/admin/connectors/${encodeURIComponent(id)}`} className="text-accent underline">196                  the connector page197                </Link>198                . Remember to set <Mono>enabled: true</Mono> and document the source in docs/connectors/{id}.md before scheduling it.199              </li>200            )}201          </ol>202        )}203      </Panel>204    </div>205  );206}207