SPB Git

spb/findata

Public
6.9 KB · 149 lines tsx
1"use client";2import Link from "next/link";3import { useEffect, useState } from "react";4import { api, type Anomaly, type Dataset, type QualityOut } from "@/lib/api";5import { useAsync, useDebounced, useQueryParam } from "@/lib/hooks";6import { fmtInt, fmtTs } from "@/lib/format";7import { Card, Empty, ErrorState, Field, PageHeader, SeverityBadge, Skeleton, KV } from "@/components/ui";8import { DataTable } from "@/components/DataTable";9import { JsonView } from "@/components/JsonView";10import { CategoryBars } from "@/components/charts";1112const DEFAULT_DS = "market_bars.stock.1m.raw";1314export default function QualityPage() {15  const qDs = useQueryParam("dataset_id");16  const [datasetId, setDatasetId] = useState(DEFAULT_DS);17  const [code, setCode] = useState("");18  const [severity, setSeverity] = useState("");19  const [symbol, setSymbol] = useState("");20  const dsym = useDebounced(symbol.trim().toUpperCase(), 300);21  useEffect(() => {22    if (qDs) setDatasetId(qDs);23  }, [qDs]);2425  const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);26  const q = useAsync((s) => api.get<QualityOut>("/quality", { dataset_id: datasetId }, s), [datasetId]);27  const an = useAsync((s) => api.get<{ data: Anomaly[] }>("/quality/anomalies", { dataset_id: datasetId, code, severity, symbol: dsym, limit: 500 }, s), [datasetId, code, severity, dsym]);2829  const latest = q.data?.latest;30  const byCode = (q.data?.anomalies_by_code_30d ?? []).map((r) => ({ name: r.code, value: Number(r.n) }));3132  return (33    <>34      <PageHeader35        title="Quality"36        subtitle="Validation reports and anomalies recorded at ingestion"37        actions={38          <Field label="Dataset" className="w-72">39            <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>40              {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}41              {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (42                <option key={d.id} value={d.id}>43                  {d.id}44                </option>45              ))}46            </select>47          </Field>48        }49      />50      {q.error && <ErrorState error={q.error} retry={q.reload} />}51      <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">52        <Card title="Latest report">53          {q.loading && !q.data ? (54            <Skeleton rows={5} />55          ) : latest ? (56            <>57              <KV58                items={[59                  ["Report id", <code key="id" className="text-xs">{String(latest.id ?? "—")}</code>],60                  ["Created", fmtTs(latest.created_at as string)],61                  ["Rows checked", fmtInt(latest.rows_checked as number)],62                  ["Anomalies", fmtInt(latest.anomalies as number)],63                ]}64              />65              <div className="mt-3 text-xs text-ink-3">summary</div>66              <JsonView value={latest.summary ?? {}} maxHeight="16rem" />67            </>68          ) : (69            <Empty>No quality report yet for this dataset — enqueue a <code>quality</code> job from Ingestion.</Empty>70          )}71        </Card>72        <Card title="Anomalies by code (30 days)">73          {byCode.length ? <CategoryBars data={byCode} /> : <Empty>No anomalies in the last 30 days.</Empty>}74          <ul className="mt-3 grid gap-1 text-xs sm:grid-cols-2">75            {Object.entries(q.data?.codes ?? {}).map(([c, d]) => (76              <li key={c} className="flex gap-2">77                <button className={`font-mono shrink-0 ${code === c ? "text-accent-ink underline" : "text-ink-1 hover:underline"}`} onClick={() => setCode(code === c ? "" : c)}>78                  {c}79                </button>80                <span className="text-ink-3">{d}</span>81              </li>82            ))}83          </ul>84        </Card>85      </div>8687      <Card title={`Reports (${q.data?.reports.length ?? 0})`} className="mt-4">88        <DataTable89          rows={q.data?.reports}90          loading={q.loading}91          rowKey={(r) => r.id}92          mobile="scroll"93          maxHeight="18rem"94          columns={[95            { key: "created_at", header: "Created", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },96            { key: "rows_checked", header: "Rows checked", num: true, render: (r) => fmtInt(r.rows_checked) },97            { key: "anomalies", header: "Anomalies", num: true, render: (r) => fmtInt(r.anomalies) },98            { key: "summary", header: "Summary", render: (r) => <JsonView value={r.summary} collapsed /> },99          ]}100          empty="No reports."101        />102      </Card>103104      <Card105        title="Anomalies"106        className="mt-4"107        actions={108          <div className="flex flex-wrap gap-2">109            <input className="input w-28 py-1 font-mono text-xs uppercase" placeholder="symbol" value={symbol} onChange={(e) => setSymbol(e.target.value)} aria-label="Symbol" />110            <select className="input w-auto py-1 text-xs" value={severity} onChange={(e) => setSeverity(e.target.value)} aria-label="Severity">111              <option value="">any severity</option>112              <option value="info">info</option>113              <option value="warning">warning</option>114              <option value="error">error</option>115            </select>116            <select className="input w-auto py-1 font-mono text-xs" value={code} onChange={(e) => setCode(e.target.value)} aria-label="Code">117              <option value="">any code</option>118              {Object.keys(q.data?.codes ?? {}).map((c) => (119                <option key={c} value={c}>120                  {c}121                </option>122              ))}123            </select>124          </div>125        }126      >127        {an.error && <ErrorState error={an.error} retry={an.reload} />}128        <DataTable129          rows={an.data?.data}130          loading={an.loading}131          rowKey={(r, i) => String(r.id ?? i)}132          mobile="scroll"133          maxHeight="60dvh"134          columns={[135            { key: "created_at", header: "Recorded", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },136            { key: "severity", header: "Severity", render: (r) => <SeverityBadge severity={r.severity} /> },137            { key: "code", header: "Code", mono: true },138            { key: "symbol", header: "Symbol", render: (r) => (r.symbol ? <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono text-accent-ink hover:underline">{r.symbol}</Link> : "—") },139            { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date ?? "—"}</span> },140            { key: "detail", header: "Detail", render: (r) => <JsonView value={r.detail} collapsed /> },141          ]}142          empty="No anomalies match."143          footer={<span>{fmtInt(an.data?.data.length ?? 0)} rows (max 500)</span>}144        />145      </Card>146    </>147  );148}149
Files · main
Menu

Theme