SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
15.2 KB · 338 lines tsx
Raw Blame History
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