"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { adminFetch, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api"; import { Code, ErrorNote, Field, Input, Mono, Panel, Select, Spinner, Textarea } from "../primitives"; import { useToast } from "../toast"; import { buildYaml, SOURCE_KINDS_LIST, type DevtoolShared } from "./state"; /** Returns the 1-based line of the first occurrence of a dotted config path in the YAML, best effort. */ function lineForPath(yaml: string, path: string): number | null { if (!path) return null; const parts = path.split("."); const lines = yaml.split("\n"); let depth = 0; let from = 0; let found: number | null = null; for (const part of parts) { let hit = -1; for (let i = from; i < lines.length; i++) { const l = lines[i]!; const indent = l.length - l.trimStart().length; if (indent < depth * 2 && i > from && depth > 0) break; if (/^\d+$/.test(part)) { if (/^\s*-\s/.test(l)) { hit = i; break; } } else if (new RegExp(`^\\s*"?${part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?\\s*:`).test(l)) { hit = i; break; } } if (hit < 0) break; found = hit + 1; from = hit + 1; depth++; } return found; } export function StepSave({ s }: { s: DevtoolShared }) { const { draft, update, validation, setValidation, saveResult, setSaveResult } = s; const [busy, setBusy] = useState<"validate" | "save" | null>(null); const [error, setError] = useState(null); const [view, setView] = useState<"edit" | "highlight">("edit"); const toast = useToast(); const meta = draft.meta; const ta = useRef(null); // first entry: generate the template from the steps when the editor is empty useEffect(() => { if (!draft.yaml.trim()) update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false })); }, [draft.yaml, update]); const setMeta = (patch: Partial) => update({ meta: { ...meta, ...patch } }); const regenerate = () => { if (draft.yamlDirty && !window.confirm("Replace the hand-edited YAML with a template regenerated from steps 1–3?")) return; update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false })); setValidation(null); setSaveResult(null); }; const validate = async (): Promise => { setBusy("validate"); setError(null); const r = await adminFetch("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } }); setBusy(null); if (!r.ok) { setError(r.error); return null; } setValidation(r.data); if (r.data.ok) { const cfg = r.data.config; if (typeof cfg.id === "string" && cfg.id !== meta.id) setMeta({ id: cfg.id }); toast({ tone: r.data.warnings.length ? "warn" : "ok", title: "YAML is valid", description: r.data.warnings.length ? `${r.data.warnings.length} warning(s)` : `connector ${String(cfg.id)} · parser ${String(cfg.parserVersion)}` }); } else toast({ tone: "danger", title: `${r.data.errors.length} schema error(s)` }); return r.data; }; const save = async () => { const v = await validate(); if (!v || !v.ok) return; const id = String(v.config.id); if (id.startsWith("_") && !id.startsWith("_devtool")) return setError("ids starting with _ are reserved (except _devtool-* test ids)"); if (!window.confirm(`Write config/connectors/${id}.yaml (previous file backed up) and upsert the connectors row?`)) return; setBusy("save"); const r = await adminFetch("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml } }); setBusy(null); if (!r.ok) { setError(r.error); toast({ tone: "danger", title: "Save failed", description: r.error }); return; } setSaveResult(r.data); setMeta({ id }); toast({ tone: "ok", title: `Saved ${id}.yaml`, description: `parser ${r.data.parserVersion}${r.data.backup ? " · backup kept" : ""}${r.data.enabled ? "" : " · enabled: false"}` }); }; const errorLines = useMemo(() => { const errs = validation && !validation.ok ? validation.errors : []; return new Set(errs.map((e) => lineForPath(draft.yaml, e.path)).filter((n): n is number => n != null)); }, [validation, draft.yaml]); const jumpTo = (line: number | null) => { if (!line || !ta.current) return; setView("edit"); const el = ta.current; const idx = draft.yaml.split("\n").slice(0, line - 1).join("\n").length + (line > 1 ? 1 : 0); el.focus(); el.setSelectionRange(idx, idx + (draft.yaml.split("\n")[line - 1]?.length ?? 0)); const lh = 18.2; el.scrollTop = Math.max(0, (line - 4) * lh); }; return (
setMeta({ id: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-") })} mono placeholder="digitalrealty" /> setMeta({ name: e.target.value })} placeholder="Digital Realty" /> setMeta({ domain: e.target.value })} mono placeholder="www.digitalrealty.com" /> setMeta({ license: e.target.value })} placeholder="Publicly accessible operator pages; factual data only; © …" /> setMeta({ attribution: e.target.value })} placeholder="Digital Realty" /> setMeta({ homepage: e.target.value })} mono placeholder="https://…" /> setMeta({ scheduleGroup: e.target.value })} mono placeholder="facility_pages" /> setMeta({ scheduleInterval: e.target.value })} mono placeholder="weekly" list="dci-intervals" /> setMeta({ rpm: e.target.value })} mono inputMode="numeric" /> setMeta({ maxCreditsPerRun: e.target.value })} mono inputMode="numeric" />
{["hourly", "6h", "12h", "daily", "weekly", "biweekly", "monthly", "quarterly", "never"].map((v) => (
} > {view === "edit" ? (