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%
11.8 KB · 256 lines tsx
Raw Blame History
1"use client";23import { useEffect, useMemo, useRef, useState } from "react";4import { Badge, Button } from "@/components/ui";5import { cn } from "@/components/ui/cn";6import { adminFetch, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";7import { Code, ErrorNote, Field, Input, Mono, Panel, Select, Spinner, Textarea } from "../primitives";8import { useToast } from "../toast";9import { buildYaml, SOURCE_KINDS_LIST, type DevtoolShared } from "./state";1011/** Returns the 1-based line of the first occurrence of a dotted config path in the YAML, best effort. */12function lineForPath(yaml: string, path: string): number | null {13  if (!path) return null;14  const parts = path.split(".");15  const lines = yaml.split("\n");16  let depth = 0;17  let from = 0;18  let found: number | null = null;19  for (const part of parts) {20    let hit = -1;21    for (let i = from; i < lines.length; i++) {22      const l = lines[i]!;23      const indent = l.length - l.trimStart().length;24      if (indent < depth * 2 && i > from && depth > 0) break;25      if (/^\d+$/.test(part)) {26        if (/^\s*-\s/.test(l)) {27          hit = i;28          break;29        }30      } else if (new RegExp(`^\\s*"?${part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?\\s*:`).test(l)) {31        hit = i;32        break;33      }34    }35    if (hit < 0) break;36    found = hit + 1;37    from = hit + 1;38    depth++;39  }40  return found;41}4243export function StepSave({ s }: { s: DevtoolShared }) {44  const { draft, update, validation, setValidation, saveResult, setSaveResult } = s;45  const [busy, setBusy] = useState<"validate" | "save" | null>(null);46  const [error, setError] = useState<string | null>(null);47  const [view, setView] = useState<"edit" | "highlight">("edit");48  const toast = useToast();49  const meta = draft.meta;50  const ta = useRef<HTMLTextAreaElement>(null);5152  // first entry: generate the template from the steps when the editor is empty53  useEffect(() => {54    if (!draft.yaml.trim()) update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false }));55  }, [draft.yaml, update]);5657  const setMeta = (patch: Partial<typeof meta>) => update({ meta: { ...meta, ...patch } });58  const regenerate = () => {59    if (draft.yamlDirty && !window.confirm("Replace the hand-edited YAML with a template regenerated from steps 1–3?")) return;60    update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false }));61    setValidation(null);62    setSaveResult(null);63  };6465  const validate = async (): Promise<ValidateConfigResult | null> => {66    setBusy("validate");67    setError(null);68    const r = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } });69    setBusy(null);70    if (!r.ok) {71      setError(r.error);72      return null;73    }74    setValidation(r.data);75    if (r.data.ok) {76      const cfg = r.data.config;77      if (typeof cfg.id === "string" && cfg.id !== meta.id) setMeta({ id: cfg.id });78      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)}` });79    } else toast({ tone: "danger", title: `${r.data.errors.length} schema error(s)` });80    return r.data;81  };8283  const save = async () => {84    const v = await validate();85    if (!v || !v.ok) return;86    const id = String(v.config.id);87    if (id.startsWith("_") && !id.startsWith("_devtool")) return setError("ids starting with _ are reserved (except _devtool-* test ids)");88    if (!window.confirm(`Write config/connectors/${id}.yaml (previous file backed up) and upsert the connectors row?`)) return;89    setBusy("save");90    const r = await adminFetch<SaveConfigResult>("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml } });91    setBusy(null);92    if (!r.ok) {93      setError(r.error);94      toast({ tone: "danger", title: "Save failed", description: r.error });95      return;96    }97    setSaveResult(r.data);98    setMeta({ id });99    toast({ tone: "ok", title: `Saved ${id}.yaml`, description: `parser ${r.data.parserVersion}${r.data.backup ? " · backup kept" : ""}${r.data.enabled ? "" : " · enabled: false"}` });100  };101102  const errorLines = useMemo(() => {103    const errs = validation && !validation.ok ? validation.errors : [];104    return new Set(errs.map((e) => lineForPath(draft.yaml, e.path)).filter((n): n is number => n != null));105  }, [validation, draft.yaml]);106  const jumpTo = (line: number | null) => {107    if (!line || !ta.current) return;108    setView("edit");109    const el = ta.current;110    const idx = draft.yaml.split("\n").slice(0, line - 1).join("\n").length + (line > 1 ? 1 : 0);111    el.focus();112    el.setSelectionRange(idx, idx + (draft.yaml.split("\n")[line - 1]?.length ?? 0));113    const lh = 18.2;114    el.scrollTop = Math.max(0, (line - 4) * lh);115  };116117  return (118    <div className="space-y-3">119      <Panel title="4 · Connector metadata" description="Used by the template generator; the YAML below is what gets saved. License and attribution are recorded on the source (legal crawling rule).">120        <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">121          <Field label="Connector id" hint="lowercase, digits, dashes — file name">122            <Input value={meta.id} onChange={(e) => setMeta({ id: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-") })} mono placeholder="digitalrealty" />123          </Field>124          <Field label="Source name">125            <Input value={meta.name} onChange={(e) => setMeta({ name: e.target.value })} placeholder="Digital Realty" />126          </Field>127          <Field label="Domain">128            <Input value={meta.domain} onChange={(e) => setMeta({ domain: e.target.value })} mono placeholder="www.digitalrealty.com" />129          </Field>130          <Field label="Source kind">131            <Select value={meta.kind} onChange={(e) => setMeta({ kind: e.target.value })}>132              {SOURCE_KINDS_LIST.map((k) => (133                <option key={k} value={k}>134                  {k}135                </option>136              ))}137            </Select>138          </Field>139          <Field label="License" className="sm:col-span-2">140            <Input value={meta.license} onChange={(e) => setMeta({ license: e.target.value })} placeholder="Publicly accessible operator pages; factual data only; © …" />141          </Field>142          <Field label="Attribution">143            <Input value={meta.attribution} onChange={(e) => setMeta({ attribution: e.target.value })} placeholder="Digital Realty" />144          </Field>145          <Field label="Homepage">146            <Input value={meta.homepage} onChange={(e) => setMeta({ homepage: e.target.value })} mono placeholder="https://…" />147          </Field>148          <Field label="Schedule group">149            <Input value={meta.scheduleGroup} onChange={(e) => setMeta({ scheduleGroup: e.target.value })} mono placeholder="facility_pages" />150          </Field>151          <Field label="Interval">152            <Input value={meta.scheduleInterval} onChange={(e) => setMeta({ scheduleInterval: e.target.value })} mono placeholder="weekly" list="dci-intervals" />153          </Field>154          <Field label="Requests / min">155            <Input value={meta.rpm} onChange={(e) => setMeta({ rpm: e.target.value })} mono inputMode="numeric" />156          </Field>157          <Field label="Max credits / run">158            <Input value={meta.maxCreditsPerRun} onChange={(e) => setMeta({ maxCreditsPerRun: e.target.value })} mono inputMode="numeric" />159          </Field>160        </div>161        <datalist id="dci-intervals">162          {["hourly", "6h", "12h", "daily", "weekly", "biweekly", "monthly", "quarterly", "never"].map((v) => (163            <option key={v} value={v} />164          ))}165        </datalist>166      </Panel>167168      <Panel169        title="Connector YAML"170        description={draft.yamlDirty ? "Edited by hand — regenerate to rebuild from steps 1–3 (your edits would be lost)." : "Generated from steps 1–3. Edit freely; validate before saving."}171        padded={false}172        aside={173          <div className="flex flex-wrap items-center gap-1.5">174            <Button size="xs" variant={view === "edit" ? "primary" : "ghost"} onClick={() => setView("edit")}>175              Edit176            </Button>177            <Button size="xs" variant={view === "highlight" ? "primary" : "ghost"} onClick={() => setView("highlight")}>178              Highlight179            </Button>180            <Button size="xs" variant="ghost" onClick={regenerate}>181              Regenerate from steps182            </Button>183            <Button size="sm" variant="secondary" onClick={() => void validate()} disabled={busy !== null}>184              {busy === "validate" ? <Spinner /> : null} Validate185            </Button>186            <Button size="sm" variant="accent" onClick={() => void save()} disabled={busy !== null || !draft.yaml.trim()}>187              {busy === "save" ? <Spinner className="text-white" /> : null} Save connector188            </Button>189          </div>190        }191      >192        {view === "edit" ? (193          <Textarea ref={ta} rows={28} mono value={draft.yaml} onChange={(e) => { update({ yaml: e.target.value, yamlDirty: true }); setSaveResult(null); }} className="rounded-none border-0 leading-[1.4] focus:border-0" spellCheck={false} />194        ) : (195          <Code text={draft.yaml} lang="yaml" maxHeight={620} />196        )}197        <ErrorNote error={error} className="m-3" />198        {validation && (199          <div className="border-t border-hair px-3 py-2">200            {validation.ok ? (201              <div className="space-y-1 text-[12.5px]">202                <div className="flex flex-wrap items-center gap-2">203                  <Badge tone="ok" size="sm">204                    valid205                  </Badge>206                  <Mono>207                    {String(validation.config.id)} · {String(validation.config.kind)} · parser {String(validation.config.parserVersion)} · {Object.keys((validation.config.extractors as object) ?? {}).length} extractor(s) · enabled {String(validation.config.enabled)}208                  </Mono>209                </div>210                {validation.warnings.map((w, i) => (211                  <div key={i} className="text-warn">212                    ⚠ {w}213                  </div>214                ))}215              </div>216            ) : (217              <ul className="space-y-1 text-[12.5px]">218                {validation.errors.map((e, i) => {219                  const line = lineForPath(draft.yaml, e.path);220                  return (221                    <li key={i} className="flex flex-wrap items-center gap-2">222                      <Badge tone="danger" size="xs">223                        error224                      </Badge>225                      <Mono className="text-ink">{e.path || "(root)"}</Mono>226                      <span className="text-ink-2">{e.message}</span>227                      {line && (228                        <button type="button" className="text-[11.5px] text-accent hover:underline" onClick={() => jumpTo(line)}>229                          line {line}230                        </button>231                      )}232                    </li>233                  );234                })}235                {errorLines.size > 0 && <li className="text-[11.5px] text-ink-4">Lines referenced: {[...errorLines].sort((a, b) => a - b).join(", ")}</li>}236              </ul>237            )}238          </div>239        )}240        {saveResult && (241          <div className={cn("border-t border-hair px-3 py-2 text-[12.5px]", "bg-ok/5")}>242            Saved <Mono className="text-ink">{saveResult.path}</Mono>243            {saveResult.backup && (244              <>245                {" "}246                · backup <Mono>{saveResult.backup}</Mono>247              </>248            )}{" "}249            · parser <Mono>{saveResult.parserVersion}</Mono> · enabled <Mono>{String(saveResult.enabled)}</Mono>. The connectors row was upserted; the worker picks the file up on its next config sync.250          </div>251        )}252      </Panel>253    </div>254  );255}256