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