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