SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
17.8 KB · 362 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { Badge, Button, EmptyState, RelativeTime, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";5import { cn } from "@/components/ui/cn";6import { DASH, fmtInt } from "@/lib/format";7import type { OpsOverview } from "@/lib/admin-api";8import { durationBetween, ErrorNote, fmtBytes, fmtMs, HealthDot, JsonView, KV, Mono, Panel, RefreshButton, RunStatusBadge, Stat } from "./primitives";9import { AdminPageHeader } from "./shell";10import { useToast } from "./toast";11import { useAdminMutation, useAdminQuery } from "./use-admin-query";1213const REFRESH_MS = 30_000;1415function num(v: unknown): number | null {16  return typeof v === "number" && Number.isFinite(v) ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : null;17}18function str(v: unknown): string | null {19  return typeof v === "string" ? v : null;20}2122/** Best-effort reading of the worker heartbeat (shape is free-form JSON published by the worker). */23function readWorker(w: unknown): { heartbeat: string | null; host: string | null; pid: number | null; running: Array<Record<string, unknown>> | number | null; version: string | null; rest: Record<string, unknown> } | null {24  if (!w || typeof w !== "object") return null;25  const o = w as Record<string, unknown>;26  const heartbeat = str(o.heartbeat) ?? str(o.at) ?? str(o.ts) ?? str(o.updatedAt) ?? str(o.time) ?? str(o.lastHeartbeat) ?? null;27  const host = str(o.host) ?? str(o.hostname) ?? null;28  const pid = num(o.pid);29  const runningRaw = o.running ?? o.activeRuns ?? o.jobs ?? o.active ?? null;30  const running = Array.isArray(runningRaw) ? (runningRaw as Array<Record<string, unknown>>) : num(runningRaw);31  const version = str(o.version) ?? null;32  const rest: Record<string, unknown> = {};33  for (const [k, v] of Object.entries(o)) if (!["heartbeat", "at", "ts", "updatedAt", "time", "lastHeartbeat", "host", "hostname", "pid", "running", "activeRuns", "jobs", "active", "version"].includes(k)) rest[k] = v;34  return { heartbeat, host, pid, running, version, rest };35}3637function BudgetBar({ label, used, limit }: { label: string; used: number; limit: number }) {38  const share = limit > 0 ? Math.min(1, used / limit) : 0;39  const tone = share >= 0.9 ? "bg-danger" : share >= 0.6 ? "bg-warn" : "bg-ink-2";40  return (41    <div>42      <div className="flex items-baseline justify-between text-[12.5px]">43        <span className="text-ink-2">{label}</span>44        <span className="figure text-ink">45          {fmtInt(used)} <span className="text-ink-4">/ {fmtInt(limit)} credits</span>46        </span>47      </div>48      <div className="mt-1 h-1.5 w-full rounded-full bg-surface-3">49        <div className={cn("h-full rounded-full", tone)} style={{ width: `${share * 100}%` }} />50      </div>51    </div>52  );53}5455export function OpsOverviewView() {56  const q = useAdminQuery<OpsOverview>("ops", { refreshMs: REFRESH_MS, timeoutMs: 20_000 });57  const { busy, run } = useAdminMutation();58  const toast = useToast();59  const d = q.data;60  const worker = readWorker(d?.worker);61  // age measured against the time the ops payload arrived (state) — keeps render pure62  const hbAge = worker?.heartbeat && q.updatedAt ? q.updatedAt - new Date(worker.heartbeat).getTime() : null;63  const workerOk = worker ? (hbAge == null ? true : hbAge < 90_000) : false;64  const budgets = d?.budgets;65  const used = budgets?.used ?? {};66  const scrapflyUsed = used.scrapfly?.credits ?? (typeof budgets?.["dci:budget:scrapfly"] === "string" ? Number(budgets["dci:budget:scrapfly"]) : 0) ?? 0;67  const firecrawlUsed = used.firecrawl?.credits ?? (typeof budgets?.["dci:budget:firecrawl"] === "string" ? Number(budgets["dci:budget:firecrawl"]) : 0) ?? 0;6869  const maintenance = async (task: "rankings" | "metrics" | "refresh-stats") => {70    const r = await run<{ enqueued: boolean; job?: { id?: string } }>(`m:${task}`, `maintenance/${task}`, { body: {} });71    if (r.ok) toast({ tone: "ok", title: `Enqueued ${task}`, description: r.data.job?.id ? `job ${r.data.job.id}` : undefined });72    else toast({ tone: "danger", title: `Could not enqueue ${task}`, description: r.error });73  };74  const invalidate = async () => {75    const r = await run<{ invalidated: number }>("cache", "cache/invalidate", { body: { prefix: "" } });76    if (r.ok) toast({ tone: "ok", title: "API cache invalidated", description: `${fmtInt(r.data.invalidated)} entries` });77    else toast({ tone: "danger", title: "Cache invalidation failed", description: r.error });78  };79  const resolve = async (id: string) => {80    const r = await run<{ resolved: boolean }>(`alert:${id}`, `alerts/${id}/resolve`, { body: {} });81    if (r.ok) {82      q.mutate((prev) => (prev ? { ...prev, alerts: prev.alerts.filter((a) => a.id !== id) } : prev));83      toast({ tone: "ok", title: "Alert resolved" });84    } else toast({ tone: "danger", title: "Could not resolve alert", description: r.error });85  };8687  return (88    <div>89      <AdminPageHeader title="Operations" description="Worker, queues, premium budgets, storage health and the latest runs. Auto-refreshes every 30 s." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />90      <ErrorNote error={q.error && !d ? `Ops endpoint unavailable: ${q.error}` : null} onRetry={() => void q.refresh()} className="mb-3" />9192      {/* health strip */}93      <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">94        <Stat label="Worker" tone={d ? (workerOk ? "ok" : "danger") : undefined} value={d ? (worker ? (workerOk ? "alive" : "stale") : "offline") : "…"} note={worker?.heartbeat ? <RelativeTime iso={worker.heartbeat} /> : d ? "no heartbeat key" : undefined} />95        <Stat label="Postgres" tone={d ? "ok" : undefined} value={d?.db.sizePretty ?? "…"} note={d ? `${fmtInt(d.db.tables.reduce((a, t) => a + t.rows, 0))} rows (est.)` : undefined} />96        <Stat label="Redis" tone={d ? (d.redis.ok ? "ok" : "danger") : undefined} value={d ? (d.redis.ok ? "ok" : "down") : "…"} note={d ? `cache ${fmtInt(d.apiCache.entries)}/${fmtInt(d.apiCache.max)}` : undefined} />97        <Stat label="ClickHouse" tone={d ? (d.clickhouse.ok ? "ok" : "danger") : undefined} value={d ? (d.clickhouse.ok ? "ok" : "down") : "…"} note={d ? <Mono>{d.clickhouse.url.replace(/^https?:\/\//, "")}</Mono> : undefined} />98        <Stat label="MinIO" tone={d ? (d.minio.ok ? "ok" : "danger") : undefined} value={d ? (d.minio.ok ? "ok" : "down") : "…"} note={d ? <Mono>{d.minio.bucket}</Mono> : undefined} />99        <Stat label="Open alerts" tone={d ? (d.alerts.length ? "warn" : "ok") : undefined} value={d ? fmtInt(d.alerts.length) : "…"} note={d?.alerts.length ? `${d.alerts.filter((a) => a.level === "error" || a.level === "critical").length} error-level` : undefined} />100      </div>101102      <div className="grid gap-3 lg:grid-cols-3">103        <Panel title="Worker" description="Heartbeat published in Redis (dci:worker:status)">104          {!d ? (105            <SkeletonRows rows={3} />106          ) : !worker ? (107            <EmptyState compact title="No worker heartbeat" description="The worker process is not publishing its status. Jobs will queue until it starts." />108          ) : (109            <div className="space-y-2">110              <KV111                items={[112                  { k: "Heartbeat", v: worker.heartbeat ? <RelativeTime iso={worker.heartbeat} /> : DASH },113                  { k: "Host", v: worker.host ? <Mono>{worker.host}</Mono> : DASH },114                  { k: "PID", v: worker.pid != null ? <Mono>{worker.pid}</Mono> : DASH },115                  { k: "Version", v: worker.version ? <Mono>{worker.version}</Mono> : DASH },116                  { k: "Running jobs", v: Array.isArray(worker.running) ? fmtInt(worker.running.length) : worker.running != null ? fmtInt(worker.running) : DASH },117                ]}118              />119              {Array.isArray(worker.running) && worker.running.length > 0 && (120                <ul className="space-y-1 text-[12px]">121                  {worker.running.slice(0, 8).map((j, i) => (122                    <li key={i} className="flex items-center gap-2">123                      <HealthDot health="running" />124                      <Mono>{str(j.connectorId) ?? str(j.id) ?? JSON.stringify(j).slice(0, 60)}</Mono>125                      {str(j.task) && <Badge size="xs">{str(j.task)}</Badge>}126                    </li>127                  ))}128                </ul>129              )}130              {Object.keys(worker.rest).length > 0 && <JsonView value={worker.rest} maxHeight={160} collapsible />}131            </div>132          )}133        </Panel>134135        <Panel title="Queues" description="BullMQ counts (prefix dci)" padded={false}>136          {!d ? (137            <div className="p-3">138              <SkeletonRows rows={3} />139            </div>140          ) : (141            <Table responsive={false} className="text-[12.5px]">142              <THead>143                <Tr>144                  <Th className="pl-3">Queue</Th>145                  <Th numeric>Wait</Th>146                  <Th numeric>Prio</Th>147                  <Th numeric>Active</Th>148                  <Th numeric>Delay</Th>149                  <Th numeric>Done</Th>150                  <Th numeric className="pr-3">151                    Failed152                  </Th>153                </Tr>154              </THead>155              <TBody>156                {Object.entries(d.queues).map(([name, c]) => (157                  <Tr key={name}>158                    <Td className="pl-3">159                      <Mono>{name.replace(/^dci:/, "")}</Mono>160                    </Td>161                    <Td numeric>{fmtInt(c.waiting ?? 0)}</Td>162                    <Td numeric>{fmtInt(c.prioritized ?? 0)}</Td>163                    <Td numeric className={cn((c.active ?? 0) > 0 && "text-accent")}>164                      {fmtInt(c.active ?? 0)}165                    </Td>166                    <Td numeric>{fmtInt(c.delayed ?? 0)}</Td>167                    <Td numeric muted>168                      {fmtInt(c.completed ?? 0)}169                    </Td>170                    <Td numeric className={cn("pr-3", (c.failed ?? 0) > 0 && "text-danger")}>171                      {fmtInt(c.failed ?? 0)}172                    </Td>173                  </Tr>174                ))}175                {Object.keys(d.queues).length === 0 && (176                  <Tr>177                    <Td colSpan={7} className="px-3 py-4 text-center text-ink-3">178                      No queue data (Redis unreachable?)179                    </Td>180                  </Tr>181                )}182              </TBody>183            </Table>184          )}185        </Panel>186187        <Panel title="Premium budgets" description={d ? `Today ${budgets?.day ?? ""} · source ${budgets?.source ?? DASH}` : undefined}>188          {!d ? (189            <SkeletonRows rows={2} />190          ) : (191            <div className="space-y-3">192              <BudgetBar label="Scrapfly (L4)" used={scrapflyUsed} limit={budgets?.limits.scrapfly ?? 0} />193              <BudgetBar label="Firecrawl (L3)" used={firecrawlUsed} limit={budgets?.limits.firecrawl ?? 0} />194              <KV195                items={[196                  { k: "Direct requests today", v: used.direct ? fmtInt(used.direct.requests) : DASH },197                  { k: "Firecrawl requests", v: used.firecrawl ? fmtInt(used.firecrawl.requests) : DASH },198                  { k: "Scrapfly requests", v: used.scrapfly ? fmtInt(used.scrapfly.requests) : DASH },199                ]}200              />201            </div>202          )}203        </Panel>204      </div>205206      <div className="mt-3 grid gap-3 lg:grid-cols-3">207        <Panel208          title="System alerts"209          description="Open alerts from the worker doctor"210          className="lg:col-span-2"211          padded={false}212          aside={213            d && d.alerts.length > 0 ? (214              <Badge tone="warn" size="xs">215                {fmtInt(d.alerts.length)} open216              </Badge>217            ) : undefined218          }219        >220          {!d ? (221            <div className="p-3">222              <SkeletonRows rows={2} />223            </div>224          ) : d.alerts.length === 0 ? (225            <EmptyState compact title="No open alerts" />226          ) : (227            <ul className="divide-y divide-hair">228              {d.alerts.map((a) => (229                <li key={a.id} className="flex flex-col gap-1 px-3 py-2 sm:flex-row sm:items-start sm:gap-3">230                  <div className="flex shrink-0 items-center gap-2 sm:w-[190px]">231                    <Badge tone={a.level === "error" || a.level === "critical" ? "danger" : a.level === "warn" ? "warn" : "info"} size="xs">232                      {a.level}233                    </Badge>234                    <Mono title={a.component}>{a.component}</Mono>235                  </div>236                  <div className="min-w-0 flex-1 text-[12.5px] text-ink-2">237                    <div className="break-words">{a.message}</div>238                    <div className="mt-0.5 text-[11px] text-ink-4">239                      <RelativeTime iso={a.createdAt} />240                    </div>241                  </div>242                  <Button size="xs" variant="ghost" onClick={() => void resolve(a.id)} disabled={busy === `alert:${a.id}`}>243                    Resolve244                  </Button>245                </li>246              ))}247            </ul>248          )}249        </Panel>250251        <Panel title="Quick actions">252          <div className="flex flex-wrap gap-2">253            <Button size="sm" href="/admin/connectors">254              Connectors255            </Button>256            <Button size="sm" href="/admin/documents?status=error">257              Failed documents258            </Button>259            <Button size="sm" href="/admin/matches">260              Match queue261            </Button>262            <Button size="sm" href="/admin/devtool">263              Dev tool264            </Button>265          </div>266          <div className="label-caps mb-1.5 mt-4">Maintenance jobs</div>267          <div className="flex flex-wrap gap-2">268            {(["rankings", "metrics", "refresh-stats"] as const).map((t) => (269              <Button key={t} size="xs" variant="secondary" onClick={() => void maintenance(t)} disabled={busy === `m:${t}`}>270                {t}271              </Button>272            ))}273            <Button size="xs" variant="ghost" onClick={() => void invalidate()} disabled={busy === "cache"}>274              Invalidate API cache275            </Button>276          </div>277          {d && (278            <>279              <div className="label-caps mb-1.5 mt-4">Largest tables</div>280              <KV items={d.db.tables.slice(0, 8).map((t) => ({ k: <Mono>{t.table}</Mono>, v: <span className="figure">{fmtInt(t.rows)} rows · {fmtBytes(t.bytes)}</span> }))} />281            </>282          )}283        </Panel>284      </div>285286      <Panel title="Last 20 runs" className="mt-3" padded={false} aside={<Button size="xs" variant="ghost" href="/admin/connectors">All connectors</Button>}>287        {!d ? (288          <div className="p-3">289            <SkeletonRows rows={6} />290          </div>291        ) : d.lastRuns.length === 0 ? (292          <EmptyState compact title="No runs yet" />293        ) : (294          <Table className="text-[12.5px]">295            <THead>296              <Tr>297                <Th className="pl-3">Connector</Th>298                <Th>Task</Th>299                <Th>Status</Th>300                <Th>Started</Th>301                <Th numeric>Duration</Th>302                <Th numeric>Fetched</Th>303                <Th numeric>Changed</Th>304                <Th numeric>Entities</Th>305                <Th numeric className="pr-3">306                  Failed307                </Th>308              </Tr>309            </THead>310            <TBody>311              {d.lastRuns.map((r) => {312                const s = r.stats ?? {};313                return (314                  <Tr key={r.id}>315                    <Td primary label="Connector" className="pl-3">316                      <Link href={`/admin/connectors/${encodeURIComponent(r.connectorId)}`} className="font-mono text-[12.5px] font-medium text-ink hover:text-accent hover:underline">317                        {r.connectorId}318                      </Link>319                      <div className="mt-0.5">320                        <Link href={`/admin/runs/${encodeURIComponent(r.id)}`} className="font-mono text-[11px] text-ink-4 hover:text-accent hover:underline" title="Run detail: changes written, rollback">321                          {r.id} →322                        </Link>323                      </div>324                      {r.error && <div className="mt-0.5 truncate text-[11.5px] text-danger sm:max-w-[320px]" title={r.error}>{r.error}</div>}325                    </Td>326                    <Td label="Task">327                      <Badge size="xs" mono>328                        {r.task}329                      </Badge>330                    </Td>331                    <Td label="Status">332                      <RunStatusBadge status={r.status} />333                    </Td>334                    <Td label="Started" muted>335                      <RelativeTime iso={r.startedAt} />336                    </Td>337                    <Td label="Duration" numeric>338                      {fmtMs(durationBetween(r.startedAt, r.finishedAt))}339                    </Td>340                    <Td label="Fetched" numeric>341                      {num(s.fetched) != null ? fmtInt(num(s.fetched)) : DASH}342                    </Td>343                    <Td label="Changed" numeric>344                      {num(s.changed) != null ? fmtInt(num(s.changed)) : DASH}345                    </Td>346                    <Td label="Entities" numeric>347                      {num(s.entities) != null ? fmtInt(num(s.entities)) : DASH}348                    </Td>349                    <Td label="Failed" numeric className={cn("pr-3", (num(s.failed) ?? 0) > 0 && "text-danger")}>350                      {num(s.failed) != null ? fmtInt(num(s.failed)) : DASH}351                    </Td>352                  </Tr>353                );354              })}355            </TBody>356          </Table>357        )}358      </Panel>359    </div>360  );361}362