spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import { Badge, Button, EmptyState, LineChart, RelativeTime, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtDateTimeUtc, fmtInt } from "@/lib/format";8import { SOURCE_KIND_LABEL } from "@/lib/labels";9import type { AdminRun, ConnectorAdminDetail } from "@/lib/admin-api";10import { PauseResumeButton, RunForm, ScheduleEditor } from "./connector-actions";11import { Code, Disclosure, durationBetween, ErrorNote, ExtLink, fmtMs, fmtPct01, HealthDot, JsonView, KV, Mono, Panel, RefreshButton, RunStatusBadge, Stat } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useAdminQuery } from "./use-admin-query";14import { toYaml } from "./yaml";1516function n(v: unknown): number | null {17 return typeof v === "number" && Number.isFinite(v) ? v : null;18}1920function RunLog({ id }: { id: string }) {21 const q = useAdminQuery<AdminRun>(`runs/${encodeURIComponent(id)}`);22 if (q.loading) return <SkeletonRows rows={3} />;23 if (q.error || !q.data) return <ErrorNote error={q.error ?? "not found"} onRetry={() => void q.refresh()} />;24 const log = q.data.log ?? [];25 return (26 <div className="space-y-2">27 <JsonView value={q.data.stats} maxHeight={200} collapsible />28 {log.length === 0 ? (29 <p className="text-[12px] text-ink-4">No log lines recorded.</p>30 ) : (31 <div className="max-h-[360px] overflow-auto rounded-xs border border-hair bg-paper font-mono text-[11.5px] leading-[1.5]">32 {log.map((l, i) => (33 <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-2 py-0.5 last:border-0", l.level === "error" && "bg-danger/10 text-danger", l.level === "warn" && "bg-warn/10 text-warn")}>34 <span className="shrink-0 text-ink-4">{l.t ? String(l.t).slice(11, 19) : ""}</span>35 <span className="w-10 shrink-0 text-ink-3">{l.level ?? ""}</span>36 <span className="min-w-0 break-all text-ink-2">{String(l.msg ?? JSON.stringify(l))}</span>37 </div>38 ))}39 </div>40 )}41 </div>42 );43}4445export function ConnectorDetailView({ id }: { id: string }) {46 const q = useAdminQuery<ConnectorAdminDetail>(`connectors/${encodeURIComponent(id)}`, { refreshMs: 30_000, timeoutMs: 30_000 });47 const [runOpen, setRunOpen] = useState(false);48 const d = q.data;49 const h = d?.health;50 const yaml = useMemo(() => (d ? toYaml(d.config) : ""), [d]);51 const series = useMemo(() => {52 if (!d) return [];53 const runs = [...d.runs].reverse().filter((r) => r.startedAt);54 const label = (iso: string | null) => (iso ? iso.slice(5, 16).replace("T", " ") : "");55 const pick = (k: string) => runs.map((r) => ({ x: label(r.startedAt), y: n((r.stats ?? {})[k]) }));56 return [57 { name: "fetched", data: pick("fetched"), color: "var(--ink-2)" },58 { name: "changed", data: pick("changed"), color: "var(--accent)" },59 { name: "failed", data: pick("failed"), color: "var(--danger)" },60 ];61 }, [d]);6263 if (q.error && !d) {64 return (65 <div>66 <AdminPageHeader eyebrow="Connector" title={id} />67 <ErrorNote error={q.status === 404 ? `Connector "${id}" not found` : q.error} onRetry={() => void q.refresh()} />68 <Button href="/admin/connectors" className="mt-3" size="sm">69 Back to connectors70 </Button>71 </div>72 );73 }7475 return (76 <div>77 <AdminPageHeader78 eyebrow={79 <Link href="/admin/connectors" className="hover:text-ink">80 Connectors81 </Link>82 }83 title={84 <span className="inline-flex items-center gap-2">85 {h && <HealthDot health={h.lastStatus === "running" ? "running" : h.health} size={10} />}86 <span className="font-mono">{id}</span>87 </span>88 }89 description={90 h ? (91 <span className="inline-flex flex-wrap items-center gap-2">92 {h.sourceName} · <ExtLink href={`https://${h.domain}`}>{h.domain}</ExtLink>93 <Badge size="xs">{SOURCE_KIND_LABEL[h.kind] ?? h.kind}</Badge>94 <Badge size="xs" mono tone="outline">95 {h.mode}96 </Badge>97 <Badge size="xs" mono tone="outline">98 parser {h.parserVersion}99 </Badge>100 {d?.paused && (101 <Badge size="xs" tone="warn">102 paused103 </Badge>104 )}105 {!h.enabled && !d?.paused && <Badge size="xs">disabled</Badge>}106 </span>107 ) : undefined108 }109 aside={110 <>111 <RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />112 {h && (113 <>114 <PauseResumeButton connector={h} size="sm" onChanged={() => void q.refresh()} />115 <Button size="sm" href={`/admin/documents?connector=${encodeURIComponent(id)}`}>116 Documents117 </Button>118 <Button size="sm" href={`/admin/devtool?id=${encodeURIComponent(id)}&step=4`}>119 Open in dev tool120 </Button>121 <Button size="sm" variant="accent" onClick={() => setRunOpen((v) => !v)}>122 Run…123 </Button>124 </>125 )}126 </>127 }128 />129 {runOpen && h && (130 <Panel title="Run connector" className="mb-3">131 <RunForm connector={h} onDone={() => { setRunOpen(false); void q.refresh(); }} />132 </Panel>133 )}134 {d?.lastError && <ErrorNote error={`Last error: ${d.lastError}`} className="mb-3" />}135136 {!h ? (137 <SkeletonBlock className="mb-3 h-16" />138 ) : (139 <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">140 <Stat label="Discovered" value={fmtInt(h.discovered)} />141 <Stat label="Fetched" value={fmtInt(h.fetched)} />142 <Stat label="Changed" value={fmtInt(h.changed)} tone={h.changed > 0 ? "ok" : undefined} />143 <Stat label="Failed" value={fmtInt(h.failed)} tone={h.failed > 0 ? "danger" : undefined} />144 <Stat label="Extraction" value={fmtPct01(h.extractionSuccess)} tone={h.extractionSuccess != null && h.extractionSuccess < 0.7 ? "warn" : undefined} />145 <Stat label="Avg response" value={h.avgResponseMs != null ? fmtMs(h.avgResponseMs) : DASH} />146 <Stat label="Credits (7 d)" value={fmtInt(h.cost.credits)} note={`${fmtInt(h.cost.direct)} direct · ${fmtInt(h.cost.firecrawl)} fc · ${fmtInt(h.cost.scrapfly)} sf`} />147 <Stat label="Next run" value={h.nextRunAt ? <RelativeTime iso={h.nextRunAt} /> : DASH} note={h.lastRunAt ? <span>last <RelativeTime iso={h.lastRunAt} /></span> : "never run"} />148 </div>149 )}150151 <div className="grid gap-3 lg:grid-cols-3">152 <Panel title="Health history" description="Per run — fetched, changed, failed (last 30 runs)" className="lg:col-span-2">153 {!d ? <SkeletonBlock className="h-[180px]" /> : series[0]!.data.length < 2 ? <EmptyState compact title="Not enough runs to chart" /> : <LineChart series={series} height={180} ariaLabel="Run history" />}154 </Panel>155 <Panel title="Cost & fetchers" description="From ClickHouse crawl_log over 7 days (falls back to run stats)">156 {!h ? (157 <SkeletonRows rows={4} />158 ) : (159 <KV160 items={[161 { k: "Direct requests", v: fmtInt(h.cost.direct) },162 { k: "Firecrawl requests", v: fmtInt(h.cost.firecrawl) },163 { k: "Scrapfly requests", v: fmtInt(h.cost.scrapfly) },164 { k: "Premium credits", v: <span className={cn(h.cost.credits > 0 && "text-accent")}>{fmtInt(h.cost.credits)}</span> },165 { k: "Fetch level", v: <Mono>{String((d?.config.fetch as { level?: number } | undefined)?.level ?? 1)} → max {String((d?.config.fetch as { maxLevel?: number } | undefined)?.maxLevel ?? 2)}</Mono> },166 { k: "Max credits / run", v: fmtInt((d?.config.fetch as { maxCreditsPerRun?: number } | undefined)?.maxCreditsPerRun ?? null) },167 { k: "Created", v: d?.createdAt ? fmtDateTimeUtc(d.createdAt) : DASH },168 { k: "Updated", v: d?.updatedAt ? fmtDateTimeUtc(d.updatedAt) : DASH },169 ]}170 />171 )}172 </Panel>173 </div>174175 <Tabs defaultValue="runs" className="mt-4">176 <TabList label="Connector sections">177 <Tab value="runs" count={d?.runs.length ?? null}>178 Runs179 </Tab>180 <Tab value="config">Config</Tab>181 <Tab value="schedule">Schedule</Tab>182 <Tab value="documents" count={d ? d.documentsByPageType.reduce((a, x) => a + x.count, 0) : null}>183 Documents184 </Tab>185 <Tab value="errors" count={d?.errorSamples.length ?? null}>186 Errors187 </Tab>188 </TabList>189190 <TabPanel value="runs" className="pt-3">191 {!d ? (192 <SkeletonRows rows={5} />193 ) : d.runs.length === 0 ? (194 <EmptyState compact title="No runs yet" description="Use Run… to enqueue a discover / crawl / full job." />195 ) : (196 <div className="space-y-1.5">197 {d.runs.map((r) => {198 const s = r.stats ?? {};199 return (200 <Disclosure201 key={r.id}202 summary={203 <span className="flex flex-wrap items-center gap-x-3 gap-y-1">204 <RunStatusBadge status={r.status} />205 <Badge size="xs" mono>206 {r.task}207 </Badge>208 <span className="text-ink-3">209 <RelativeTime iso={r.startedAt} />210 </span>211 <span className="figure text-ink-3">{fmtMs(durationBetween(r.startedAt, r.finishedAt))}</span>212 <span className="figure text-ink-2">213 {fmtInt(n(s.fetched))} fetched · {fmtInt(n(s.changed))} changed · {fmtInt(n(s.entities))} entities · <span className={cn((n(s.failed) ?? 0) > 0 && "text-danger")}>{fmtInt(n(s.failed))} failed</span>214 {n(s.credits) ? <span className="text-accent"> · {fmtInt(n(s.credits))} cr</span> : null}215 </span>216 <Link href={`/admin/runs/${encodeURIComponent(r.id)}`} className="ml-auto hidden font-mono text-[11.5px] text-ink-3 hover:text-accent hover:underline sm:inline" title="Run detail: changes written, rollback">217 {r.id} →218 </Link>219 {r.error && <span className="w-full truncate text-[11.5px] text-danger">{r.error}</span>}220 </span>221 }222 >223 <RunLog id={r.id} />224 </Disclosure>225 );226 })}227 </div>228 )}229 </TabPanel>230231 <TabPanel value="config" className="pt-3">232 {!d ? (233 <SkeletonBlock className="h-40" />234 ) : (235 <div className="space-y-2">236 <div className="flex flex-wrap items-center justify-between gap-2 text-[12px] text-ink-3">237 <span>Config stored in the connectors table (synced from config/connectors/{id}.yaml). Read-only here — edit in the dev tool.</span>238 <Button size="xs" href={`/admin/devtool?id=${encodeURIComponent(id)}&step=4`}>239 Edit YAML in dev tool240 </Button>241 </div>242 <Code text={yaml} lang="yaml" maxHeight={640} />243 </div>244 )}245 </TabPanel>246247 <TabPanel value="schedule" className="pt-3">{h ? <ScheduleEditor key={JSON.stringify(h.schedule)} connector={h} onSaved={() => void q.refresh()} /> : <SkeletonRows rows={2} />}</TabPanel>248249 <TabPanel value="documents" className="pt-3">250 {!d ? (251 <SkeletonRows rows={3} />252 ) : d.documentsByPageType.length === 0 ? (253 <EmptyState compact title="No documents registered" />254 ) : (255 <Table className="text-[12.5px]">256 <THead>257 <Tr>258 <Th>Page type</Th>259 <Th numeric>Documents</Th>260 <Th numeric>Changed</Th>261 <Th numeric>Failed</Th>262 <Th numeric>Quarantined</Th>263 </Tr>264 </THead>265 <TBody>266 {d.documentsByPageType.map((x) => (267 <Tr key={x.pageType}>268 <Td primary>269 <Link href={`/admin/documents?connector=${encodeURIComponent(id)}&pageType=${encodeURIComponent(x.pageType)}`} className="font-mono text-[12.5px] text-ink hover:text-accent hover:underline">270 {x.pageType}271 </Link>272 </Td>273 <Td label="Documents" numeric>274 {fmtInt(x.count)}275 </Td>276 <Td label="Changed" numeric>277 {fmtInt(x.changed)}278 </Td>279 <Td label="Failed" numeric className={cn(x.failed > 0 && "text-danger")}>280 {fmtInt(x.failed)}281 </Td>282 <Td label="Quarantined" numeric className={cn(x.quarantined > 0 && "text-warn")}>283 {fmtInt(x.quarantined)}284 </Td>285 </Tr>286 ))}287 </TBody>288 </Table>289 )}290 </TabPanel>291292 <TabPanel value="errors" className="pt-3">293 {!d ? (294 <SkeletonRows rows={3} />295 ) : d.errorSamples.length === 0 ? (296 <EmptyState compact title="No document errors" />297 ) : (298 <Table className="text-[12.5px]">299 <THead>300 <Tr>301 <Th>URL</Th>302 <Th>Error</Th>303 <Th numeric>HTTP</Th>304 <Th numeric>Count</Th>305 <Th>Last checked</Th>306 </Tr>307 </THead>308 <TBody>309 {d.errorSamples.map((e) => (310 <Tr key={e.id}>311 <Td primary>312 <Link href={`/admin/documents/${encodeURIComponent(e.id)}`} className="block max-w-[420px] truncate font-mono text-[12px] text-ink hover:text-accent hover:underline" title={e.url}>313 {e.url.replace(/^https?:\/\//, "")}314 </Link>315 </Td>316 <Td label="Error" className="max-w-[360px] break-words text-danger">317 {e.error ?? DASH}318 </Td>319 <Td label="HTTP" numeric>320 {e.statusCode ?? DASH}321 </Td>322 <Td label="Count" numeric>323 {fmtInt(e.errorCount)}324 </Td>325 <Td label="Last checked" muted>326 <RelativeTime iso={e.lastChecked} />327 </Td>328 </Tr>329 ))}330 </TBody>331 </Table>332 )}333 </TabPanel>334 </Tabs>335 </div>336 );337}338