spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { useState } from "react";4import { Badge, Button, EmptyState, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";5import { cn } from "@/components/ui/cn";6import { DASH, fmtInt, fmtValue } from "@/lib/format";7import { adminFetch, type DevtoolPreviewResult } from "@/lib/admin-api";8import { ErrorNote, Field, fmtMs, Input, JsonView, LevelBadge, Mono, Panel, Spinner } from "../primitives";9import { useToast } from "../toast";10import { defaultsToConfig, extractorToConfig, fieldUnion, isEmptyValue, type DevtoolShared } from "./state";1112export function StepPreview({ s }: { s: DevtoolShared }) {13 const { draft, update, fetchResult, previewResult: r, setPreviewResult } = s;14 const [busy, setBusy] = useState(false);15 const [error, setError] = useState<string | null>(null);16 const [details, setDetails] = useState<unknown>(null);17 const toast = useToast();18 const d = draft.defaults;19 const setDefaults = (patch: Partial<typeof d>) => update({ defaults: { ...d, ...patch } });20 const canReuseHtml = Boolean(fetchResult?.html && fetchResult.url === draft.fetch.url && !fetchResult.error);2122 const run = async () => {23 const url = draft.fetch.url.trim();24 if (!url) return setError("No URL — go back to step 1.");25 const extractor = extractorToConfig(draft.extractor);26 if (!extractor.fields && !extractor.parser) return setError("Define at least one field rule (or a parser) in step 2.");27 setBusy(true);28 setError(null);29 setDetails(null);30 const body: Record<string, unknown> = { url, extractor, defaults: defaultsToConfig(d), connectorId: draft.meta.id || undefined, kind: draft.extractor.kind, level: draft.fetch.level, maxLevel: Math.max(draft.fetch.level, draft.fetch.maxLevel) };31 if (canReuseHtml) body.html = fetchResult!.html;32 const res = await adminFetch<DevtoolPreviewResult>("devtool/preview", { method: "POST", body, timeoutMs: 120_000 });33 setBusy(false);34 if (!res.ok) {35 setError(res.error);36 setDetails(res.details ?? null);37 toast({ tone: "danger", title: "Preview failed", description: res.error });38 return;39 }40 setPreviewResult(res.data);41 const v = res.data.validation;42 toast({ tone: v.rejected > 0 ? "warn" : res.data.entities.length ? "ok" : "warn", title: `${fmtInt(res.data.records.length)} record(s) → ${fmtInt(res.data.entities.length)} entit${res.data.entities.length === 1 ? "y" : "ies"}`, description: `${fmtInt(v.valid)} valid · ${fmtInt(v.rejected)} rejected · ${fmtInt(v.issues.length)} issue(s)` });43 };4445 const entityFields = r ? fieldUnion(r.entities, ["entityType", "key", "name", "operatorName", "city", "countryIso2", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw"]) : [];46 const recordFields = r ? fieldUnion(r.records) : [];47 const ruleNames = draft.extractor.fields.map((f) => f.name.trim()).filter(Boolean);48 const emptyRules = r && r.records.length ? ruleNames.filter((n) => r.records.every((rec) => isEmptyValue(rec[n]))) : [];4950 return (51 <div className="space-y-3">52 <Panel title="3 · Preview extraction" description={canReuseHtml ? "Runs the extractor on the HTML fetched in step 1 (no new request), then normalize → validate through GenericConnector." : "The page will be fetched again (step 1 result is for another URL or failed)."}>53 <div className="grid gap-3 lg:grid-cols-[repeat(5,1fr)_auto] lg:items-end">54 <Field label="Default operator" hint="merged into every entity">55 <Input value={d.operatorName} onChange={(e) => setDefaults({ operatorName: e.target.value })} placeholder="Digital Realty" />56 </Field>57 <Field label="Operator kind">58 <Input value={d.operatorKind} onChange={(e) => setDefaults({ operatorKind: e.target.value })} placeholder="colocation" mono />59 </Field>60 <Field label="Country (iso2)">61 <Input value={d.countryIso2} onChange={(e) => setDefaults({ countryIso2: e.target.value.toUpperCase().slice(0, 2) })} placeholder="US" mono />62 </Field>63 <Field label="Facility type">64 <Input value={d.facilityType} onChange={(e) => setDefaults({ facilityType: e.target.value })} placeholder="colocation" mono />65 </Field>66 <Field label="Status">67 <Input value={d.status} onChange={(e) => setDefaults({ status: e.target.value })} placeholder="operational" mono />68 </Field>69 <Button variant="accent" onClick={() => void run()} disabled={busy}>70 {busy ? <Spinner className="text-white" /> : null}71 {busy ? "Running…" : "Preview fields"}72 </Button>73 </div>74 <ErrorNote error={error} className="mt-3" />75 {details ? <JsonView value={details} maxHeight={160} className="mt-2" /> : null}76 </Panel>7778 {r && (79 <>80 <div className="flex flex-wrap items-center gap-2 text-[12.5px]">81 <Badge size="sm" tone="outline" mono>82 HTTP {r.fetch.status}83 </Badge>84 <Badge size="sm" tone="outline">85 {r.fetch.fetcher}86 </Badge>87 <LevelBadge level={r.fetch.level} />88 <span className="text-ink-3">{fmtMs(r.fetch.durationMs)}</span>89 <span className="figure text-ink">{fmtInt(r.records.length)} records</span>90 <span className="text-ink-4">→</span>91 <span className="figure text-ink">{fmtInt(r.entities.length)} entities</span>92 <span className="text-ink-4">→</span>93 <span className={cn("figure", r.validation.rejected ? "text-danger" : "text-ok")}>94 {fmtInt(r.validation.valid)} valid / {fmtInt(r.validation.rejected)} rejected95 </span>96 {emptyRules.length > 0 && (97 <Badge size="sm" tone="warn">98 empty on every record: {emptyRules.join(", ")}99 </Badge>100 )}101 </div>102103 <div className="grid gap-3 xl:grid-cols-2">104 <Panel title="Records (raw extraction)" description="Field values as produced by the rules, before normalization. Empty cells are highlighted." padded={false}>105 {r.records.length === 0 ? (106 <EmptyState compact title="No records" description="The rules matched nothing — check the selectors against the HTML source in step 1 (or `each` selects no element)." />107 ) : (108 <div className="max-h-[440px] overflow-auto">109 <Table responsive={false} className="text-[12px]">110 <THead>111 <Tr>112 <Th className="pl-3">Field</Th>113 {r.records.slice(0, 6).map((_, i) => (114 <Th key={i}>#{i + 1}</Th>115 ))}116 </Tr>117 </THead>118 <TBody>119 {[...new Set([...ruleNames, ...recordFields])].map((k) => (120 <Tr key={k}>121 <Td className="pl-3">122 <Mono className={cn(ruleNames.includes(k) ? "text-ink" : "text-ink-3")}>{k}</Mono>123 </Td>124 {r.records.slice(0, 6).map((rec, i) => (125 <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(rec[k]) && "bg-warn/10 text-warn")} title={typeof rec[k] === "object" ? JSON.stringify(rec[k]) : String(rec[k] ?? "")}>126 {isEmptyValue(rec[k]) ? "empty" : fmtValue(rec[k])}127 </Td>128 ))}129 </Tr>130 ))}131 </TBody>132 </Table>133 {r.records.length > 6 && <p className="px-3 py-1.5 text-[11.5px] text-ink-4">Showing 6 of {fmtInt(r.records.length)} records.</p>}134 </div>135 )}136 </Panel>137138 <Panel title="Normalized entities" description="After normalize(): units parsed, statuses mapped, defaults merged. Empty fields highlighted." padded={false}>139 {r.entities.length === 0 ? (140 <EmptyState compact title="No entities" description="Records did not survive normalization (missing name / key?). Check the validation issues." />141 ) : (142 <div className="max-h-[440px] overflow-auto">143 <Table responsive={false} className="text-[12px]">144 <THead>145 <Tr>146 <Th className="pl-3">Field</Th>147 {r.entities.slice(0, 6).map((_, i) => (148 <Th key={i}>#{i + 1}</Th>149 ))}150 </Tr>151 </THead>152 <TBody>153 {entityFields.map((k) => (154 <Tr key={k}>155 <Td className="pl-3">156 <Mono className="text-ink">{k}</Mono>157 </Td>158 {r.entities.slice(0, 6).map((e, i) => (159 <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(e[k]) && "bg-warn/10 text-warn")} title={typeof e[k] === "object" ? JSON.stringify(e[k]) : String(e[k] ?? "")}>160 {isEmptyValue(e[k]) ? "empty" : fmtValue(e[k])}161 </Td>162 ))}163 </Tr>164 ))}165 </TBody>166 </Table>167 </div>168 )}169 </Panel>170 </div>171172 <div className="grid gap-3 xl:grid-cols-2">173 <Panel title="Validation issues" description={`${fmtInt(r.validation.issues.length)} issue(s) — rejected entities never reach the index`} padded={false}>174 {r.validation.issues.length === 0 ? (175 <EmptyState compact title="No validation issues" />176 ) : (177 <ul className="max-h-[320px] divide-y divide-hair overflow-auto text-[12.5px]">178 {r.validation.issues.map((i, k) => {179 const level = String(i.level ?? i.severity ?? "error");180 return (181 <li key={k} className="flex flex-wrap items-center gap-2 px-3 py-1.5">182 <Badge size="xs" tone={level === "warn" || level === "warning" ? "warn" : "danger"}>183 {level}184 </Badge>185 {(i.field ?? i.path) && <Mono className="text-ink">{String(i.field ?? i.path)}</Mono>}186 <span className="text-ink-2">{String(i.message ?? JSON.stringify(i))}</span>187 {(i.entity ?? i.key) != null && <Mono className="ml-auto text-ink-4">{String(i.entity ?? i.key)}</Mono>}188 </li>189 );190 })}191 </ul>192 )}193 </Panel>194 <Panel title="Pipeline log" padded={false}>195 {r.logs.length === 0 ? (196 <EmptyState compact title="No log lines" />197 ) : (198 <div className="max-h-[320px] overflow-auto font-mono text-[11.5px] leading-[1.5]">199 {r.logs.map((l, i) => (200 <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-3 py-0.5 last:border-0", l.level === "error" && "text-danger", l.level === "warn" && "text-warn")}>201 <span className="w-10 shrink-0 text-ink-4">{l.level}</span>202 <span className="min-w-0 break-all text-ink-2">{l.msg}</span>203 </div>204 ))}205 </div>206 )}207 </Panel>208 </div>209 {r.entities.length > 0 && (210 <details className="rounded-sm border border-hair bg-surface">211 <summary className="cursor-pointer px-3 py-2 text-[12.5px] text-ink-2">Raw JSON (first entity)</summary>212 <div className="px-3 pb-3">213 <JsonView value={r.entities[0] ?? DASH} maxHeight={360} />214 </div>215 </details>216 )}217 </>218 )}219 </div>220 );221}222