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