"use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { Badge, Button, EmptyState } from "@/components/ui"; import { adminFetch, adminFetchText, type ConfigListItem, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api"; import { Checkbox, DiffView, ErrorNote, Field, Input, KV, Mono, Panel, Select, Spinner } from "../primitives"; import { useToast } from "../toast"; import { bumpVersion } from "../yaml"; import type { DevtoolShared } from "./state"; interface DeployLog { step: string; ok: boolean; detail: string; } /** Loads config/connectors/.yaml text; `token` forces a reload. Loading is derived from the key, never set in the effect. */ function useSavedYaml(id: string, token: number): { loading: boolean; text: string | null; error: string | null } { const key = `${id}|${token}`; const [slot, setSlot] = useState<{ key: string; text: string | null; error: string | null }>({ key: "", text: null, error: null }); useEffect(() => { if (!id) return; let alive = true; void adminFetchText(`devtool/configs/${encodeURIComponent(id)}`).then((r) => { if (!alive) return; 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") }); }); return () => { alive = false; }; }, [id, key]); const current = slot.key === key; return { loading: Boolean(id) && !current, text: current ? slot.text : null, error: current ? slot.error : null }; } export function StepDeploy({ s, configs, onReloadConfigs }: { s: DevtoolShared; configs: ConfigListItem[]; onReloadConfigs: () => void }) { const { draft, update, validation, setValidation, setSaveResult } = s; const [id, setId] = useState(draft.meta.id); const [reloadToken, setReloadToken] = useState(0); const savedQ = useSavedYaml(id, reloadToken); const savedText = savedQ.text; const saved = useMemo(() => (savedText != null ? { id, text: savedText } : null), [id, savedText]); const [versionInput, setVersionInput] = useState(null); const [reprocess, setReprocess] = useState(true); const [busy, setBusy] = useState(false); const [log, setLog] = useState([]); const toast = useToast(); const listed = configs.find((c) => c.id === id); const savedVersion = useMemo(() => { const m = /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(saved?.text ?? ""); return m?.[1]?.trim() ?? listed?.parserVersion ?? "v1"; }, [saved, listed]); const draftVersion = useMemo(() => /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(draft.yaml)?.[1]?.trim() ?? null, [draft.yaml]); const version = versionInput ?? bumpVersion(savedVersion); const changeId = (v: string) => { setId(v.toLowerCase()); setVersionInput(null); setLog([]); }; const deploy = async () => { if (!id) return; if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return toast({ tone: "danger", title: "Invalid connector id" }); if (!draft.yaml.trim()) return toast({ tone: "danger", title: "No YAML to deploy (step 4)" }); 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; setBusy(true); const out: DeployLog[] = []; const push = (l: DeployLog) => { out.push(l); setLog([...out]); }; const v = await adminFetch("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } }); if (!v.ok || !v.data.ok) { push({ step: "validate", ok: false, detail: v.ok ? (v.data.ok ? "" : v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ")) : v.error }); setValidation(v.ok ? v.data : null); setBusy(false); toast({ tone: "danger", title: "Deploy aborted: YAML invalid" }); return; } setValidation(v.data); if (String(v.data.config.id) !== id) { push({ step: "validate", ok: false, detail: `YAML id "${String(v.data.config.id)}" does not match "${id}"` }); setBusy(false); return; } push({ step: "validate", ok: true, detail: `${v.data.warnings.length} warning(s)` }); const sv = await adminFetch("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml, version: version.trim() || undefined } }); if (!sv.ok) { push({ step: "save-config", ok: false, detail: sv.error }); setBusy(false); toast({ tone: "danger", title: "Deploy failed at save", description: sv.error }); return; } setSaveResult(sv.data); push({ step: "save-config", ok: true, detail: `${sv.data.path} · parser ${sv.data.parserVersion}${sv.data.backup ? ` · backup ${sv.data.backup.split("/").pop()}` : ""}` }); // reflect the bumped version in the editor 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 } })); if (reprocess) { const run = await adminFetch<{ enqueued: boolean; job?: { id?: string } }>(`connectors/${encodeURIComponent(id)}/run`, { method: "POST", body: { task: "reprocess" } }); if (run.ok) push({ step: "run reprocess", ok: true, detail: run.data.job?.id ? `job ${run.data.job.id}` : "enqueued" }); else push({ step: "run reprocess", ok: false, detail: run.error }); } setBusy(false); setReloadToken((t) => t + 1); setVersionInput(null); onReloadConfigs(); 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(" · ") }); }; return (
changeId(e.target.value)} mono placeholder="connector id" className="flex-1" />
{saved && (
{savedVersion} }, { k: "Editor parser version", v: {draftVersion ?? "(none)"} }, { k: "Listed", v: listed ? ( {listed.valid ? ( valid ) : ( invalid )}{" "} · enabled {String(listed.enabled)} ) : ( "not on disk list" ), }, ]} />
)} {!saved && !savedQ.error && !savedQ.loading && !id && }
setVersionInput(e.target.value)} mono placeholder={bumpVersion(savedVersion)} /> setReprocess(e.target.checked)} />
{validation && !validation.ok && `${e.path}: ${e.message}`).join("; ")}`} className="mt-3" />} {log.length > 0 && (
    {log.map((l, i) => (
  1. {l.ok ? "ok" : "failed"} {l.step} {l.detail}
  2. ))} {log.some((l) => l.step === "save-config" && l.ok) && (
  3. Follow the run on{" "} the connector page . Remember to set enabled: true and document the source in docs/connectors/{id}.md before scheduling it.
  4. )}
)}
); }