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%
20.4 KB · 369 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import { Badge, Button, EmptyState, RelativeTime, ScopeBadge, SemanticsLabel, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtInt, fmtMw, fmtUsd } from "@/lib/format";8import { entityHref, routes } from "@/lib/routes";9import type { QualityFlagDTO, QualityOverview } from "@/lib/admin-api";10import { ErrorNote, Input, JsonView, Mono, Pager, Panel, RefreshButton, Segmented, Select, Stat } from "./primitives";11import { AdminPageHeader } from "./shell";12import { useToast } from "./toast";13import { useAdminMutation, useAdminQuery } from "./use-admin-query";14import { useUrlState } from "./use-url-state";1516/* ------------------------------------------------------------------------------------------17   /admin/quality — QualityOverview counters, largest published values (with scope / semantics so a18   portfolio figure is never read as a site), the review queue (resolve / dismiss) and system alerts.19   ------------------------------------------------------------------------------------------ */2021const FLAG_STATUSES = ["open", "resolved", "dismissed", "all"] as const;22type FlagStatus = (typeof FLAG_STATUSES)[number];23const PER_PAGE = 30;2425const SEV_TONE = { info: "neutral", warn: "warn", critical: "danger" } as const;2627function SeverityBadge({ s }: { s: QualityFlagDTO["severity"] }) {28  return (29    <Badge size="xs" tone={SEV_TONE[s]} className="uppercase tracking-[0.04em]">30      {s}31    </Badge>32  );33}3435function codeLabel(code: string, labels: Map<string, string>): string {36  return labels.get(code) ?? code.replace(/_/g, " ");37}3839function LargestTable<T extends { id: string; slug: string; name: string }>({ rows, href, value, scope, semantics, unit }: { rows: T[]; href: (r: T) => string; value: (r: T) => string; scope?: (r: T) => string | null | undefined; semantics?: (r: T) => string | null | undefined; unit: string }) {40  if (!rows.length) return <EmptyState compact title="Nothing published yet" />;41  return (42    <Table className="text-[12.5px]" dense>43      <THead>44        <Tr>45          <Th>Record</Th>46          {scope && <Th>Scope</Th>}47          {semantics && <Th className="hidden md:table-cell">Semantics</Th>}48          <Th numeric>{unit}</Th>49        </Tr>50      </THead>51      <TBody>52        {rows.map((r) => (53          <Tr key={r.id}>54            <Td primary>55              <Link href={href(r)} target="_blank" className="font-medium text-ink hover:text-accent hover:underline">56                {r.name}57              </Link>58              <div className="text-[11px] text-ink-4">59                <Mono>{r.id}</Mono>60              </div>61            </Td>62            {scope && (63              <Td label="Scope">64                {scope(r) ? <ScopeBadge scope={scope(r)} /> : <span className="text-ink-4" title="No claim scope recorded — figure comes from a structured source field">{DASH}</span>}65              </Td>66            )}67            {semantics && (68              <Td label="Semantics" className="hidden md:table-cell">69                {semantics(r) ? <SemanticsLabel semantics={semantics(r)} className="text-[12px] text-ink-2" /> : <span className="text-ink-4">{DASH}</span>}70              </Td>71            )}72            <Td label={unit} numeric lead className="figure">73              {value(r)}74            </Td>75          </Tr>76        ))}77      </TBody>78    </Table>79  );80}8182export function QualityDashboard() {83  const q = useAdminQuery<QualityOverview>("quality", { refreshMs: 120_000, timeoutMs: 30_000 });84  const [params, setParams] = useUrlState();85  const status = (FLAG_STATUSES.includes(params.status as FlagStatus) ? params.status : "open") as FlagStatus;86  const code = params.code ?? "";87  const entityType = params.entity_type ?? "";88  const minPriority = params.min_priority ?? "";89  const page = Math.max(1, Number(params.page ?? 1) || 1);90  const flags = useAdminQuery<QualityFlagDTO[]>("quality/flags", { query: { status, code: code || undefined, entity_type: entityType || undefined, min_priority: minPriority || undefined, page, per_page: PER_PAGE }, timeoutMs: 30_000 });91  const { busy, run } = useAdminMutation();92  const toast = useToast();93  const [gone, setGone] = useState<Record<string, "resolved" | "dismissed">>({});94  const d = q.data;95  const methodology = typeof q.meta?.methodology === "string" ? q.meta.methodology : null;96  const labels = useMemo(() => new Map((d?.byCode ?? []).map((c) => [c.code, c.label])), [d]);97  const codes = useMemo(() => (d?.byCode ?? []).map((c) => c.code).sort(), [d]);98  const queue = useMemo(() => [...(flags.data ?? [])].sort((a, b) => b.priority - a.priority || (a.createdAt < b.createdAt ? 1 : -1)), [flags.data]);99  const total = Number(flags.meta?.total ?? queue.length);100101  const decide = async (f: QualityFlagDTO, action: "resolve" | "dismiss") => {102    let text: string | null = null;103    if (action === "resolve") {104      text = window.prompt(`Resolution for “${f.message.slice(0, 80)}” — what was fixed or verified?`, "");105      if (text === null) return;106      if (!text.trim()) return toast({ tone: "warn", title: "A resolution note is required" });107    } else {108      text = window.prompt("Dismiss reason (optional)", "") ?? "";109    }110    setGone((g) => ({ ...g, [f.id]: action === "resolve" ? "resolved" : "dismissed" }));111    const r = await run<QualityFlagDTO>(`${action}:${f.id}`, `quality/flags/${encodeURIComponent(f.id)}/${action}`, { body: action === "resolve" ? { resolution: text.trim() } : text.trim() ? { reason: text.trim() } : {} });112    if (r.ok) {113      toast({ tone: "ok", title: action === "resolve" ? "Flag resolved" : "Flag dismissed", description: f.message.slice(0, 100) });114      setTimeout(() => {115        flags.mutate((prev) => prev?.filter((x) => x.id !== f.id) ?? prev);116        q.mutate((prev) => (prev ? { ...prev, openFlags: Math.max(0, prev.openFlags - 1) } : prev));117      }, 500);118    } else {119      setGone((g) => {120        const n = { ...g };121        delete n[f.id];122        return n;123      });124      toast({ tone: "danger", title: `Could not ${action}`, description: r.error });125    }126  };127128  return (129    <div>130      <AdminPageHeader title="Data quality" description="Sanity-engine flags, largest published values with their scope, review queue and system alerts. Counts are over live records; nothing here rewrites a column — reviews open or close flags and claims." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />131      <ErrorNote error={q.error && !d ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />132133      {!d ? (134        <SkeletonBlock className="mb-3 h-24" />135      ) : (136        <div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">137          <Stat label="Open flags" value={fmtInt(d.openFlags)} tone={d.openFlags > 0 ? "warn" : "ok"} note={Object.entries(d.bySeverity).map(([k, v]) => `${fmtInt(v)} ${k}`).join(" · ") || undefined} />138          <Stat label="Possible duplicates" value={fmtInt(d.possibleDuplicates)} note={<Link href="/admin/matches" className="hover:text-ink underline decoration-hair-2">pending matches</Link>} />139          <Stat label="Suspicious MW" value={fmtInt(d.suspiciousMw)} tone={d.suspiciousMw > 0 ? "warn" : undefined} note="single-site > 1 000 MW, density, 5× jumps" />140          <Stat label="Suspicious investment" value={fmtInt(d.suspiciousInvestment)} tone={d.suspiciousInvestment > 0 ? "warn" : undefined} note="> $50 B on one site, deal/capex as project" />141          <Stat label="Project false-positive candidates" value={fmtInt(d.projectFalsePositiveCandidates)} tone={d.projectFalsePositiveCandidates > 0 ? "danger" : undefined} note={<Link href="/admin/projects" className="hover:text-ink underline decoration-hair-2">review projects</Link>} />142          <Stat label="Unverified large projects" value={fmtInt(d.unverifiedLargeProjects)} note="≥ 500 MW with a single non-primary source" />143          <Stat label="Unscoped claims" value={fmtInt(d.unscopedClaims)} note="portfolio / company / metro / country / unknown — stored, never summed" />144          <Stat label="Claims in review" value={fmtInt(d.claimsInReview)} note="critical sanity flag on the claim" />145          <Stat label="Facilities without country" value={fmtInt(d.facilitiesMissingCountry)} />146          <Stat label="Projects without facility link" value={fmtInt(d.projectsWithoutFacility)} />147          <Stat label="Orphan operators" value={fmtInt(d.orphanOperators)} note="no facility, no project" />148          <Stat label="Alerts" value={fmtInt(d.alerts.length)} tone={d.alerts.length ? "warn" : undefined} />149        </div>150      )}151      {methodology && <p className="mb-3 rounded-xs border border-dashed border-hair-2 px-2.5 py-2 text-[11.5px] text-ink-3">{methodology}</p>}152153      <div className="grid gap-3 lg:grid-cols-3">154        <Panel title="Open flags by code" description="Deterministic sanity checks (capacity, investment, scope, location, operator, duplicate, project false positive)." padded={false} className="lg:col-span-1">155          {!d ? (156            <div className="p-3">157              <SkeletonRows rows={6} />158            </div>159          ) : d.byCode.length === 0 ? (160            <EmptyState compact title="No open flag" />161          ) : (162            <ul className="divide-y divide-hair">163              {d.byCode.map((c) => {164                const max = d.byCode[0]?.count ?? 1;165                return (166                  <li key={c.code}>167                    <button type="button" onClick={() => setParams({ code: code === c.code ? null : c.code, page: null, status: null })} className={cn("flex w-full flex-col gap-1 px-3 py-2 text-left hover:bg-surface-2", code === c.code && "bg-surface-2")} aria-pressed={code === c.code}>168                      <span className="flex items-baseline justify-between gap-2 text-[12.5px]">169                        <span className="min-w-0 truncate text-ink">{c.label}</span>170                        <span className="figure shrink-0 text-ink-2">{fmtInt(c.count)}</span>171                      </span>172                      <span className="h-[3px] w-full bg-surface-3" aria-hidden>173                        <span className="block h-full bg-ink-3" style={{ width: `${Math.max(1.5, (c.count / max) * 100)}%` }} />174                      </span>175                      <Mono className="text-[10.5px] text-ink-4">{c.code}</Mono>176                    </button>177                  </li>178                );179              })}180            </ul>181          )}182        </Panel>183184        <Panel title="Largest published values" description="Plain ORDER BY on displayed figures. Scope and semantics say what the number describes; “—” = no claim recorded (structured source field)." padded={false} className="lg:col-span-2">185          {!d ? (186            <div className="p-3">187              <SkeletonRows rows={6} />188            </div>189          ) : (190            <Tabs defaultValue="op" className="px-3 pb-3">191              <TabList label="Largest values">192                <Tab value="op" count={d.largest.operationalFacilityMw.length}>193                  Operational facility MW194                </Tab>195                <Tab value="con" count={d.largest.constructionFacilityMw.length}>196                  Construction MW197                </Tab>198                <Tab value="prj" count={d.largest.projectMw.length}>199                  Project MW200                </Tab>201                <Tab value="inv" count={d.largest.investment.length}>202                  Investment203                </Tab>204                <Tab value="opp" count={d.largest.operatorPipelineMw.length}>205                  Operator pipeline206                </Tab>207                <Tab value="met" count={d.largest.metroPipelineMw.length}>208                  Metro pipeline209                </Tab>210              </TabList>211              <TabPanel value="op">212                <LargestTable rows={d.largest.operationalFacilityMw} href={(r) => routes.facility(r.slug)} value={(r) => fmtMw(r.mw)} scope={(r) => r.scope} semantics={(r) => r.semantics} unit="MW" />213              </TabPanel>214              <TabPanel value="con">215                <LargestTable rows={d.largest.constructionFacilityMw} href={(r) => routes.facility(r.slug)} value={(r) => fmtMw(r.mw)} unit="MW" />216              </TabPanel>217              <TabPanel value="prj">218                <LargestTable rows={d.largest.projectMw} href={(r) => routes.project(r.slug)} value={(r) => `≈ ${fmtMw(r.mw)}`} scope={(r) => r.scope} unit="Planned MW" />219              </TabPanel>220              <TabPanel value="inv">221                <LargestTable rows={d.largest.investment} href={(r) => routes.project(r.slug)} value={(r) => fmtUsd(r.investmentUsd)} scope={(r) => r.scope} unit="USD" />222              </TabPanel>223              <TabPanel value="opp">224                <LargestTable rows={d.largest.operatorPipelineMw} href={(r) => routes.operator(r.slug)} value={(r) => `≈ ${fmtMw(r.mw)}`} unit="Pipeline MW" />225              </TabPanel>226              <TabPanel value="met">227                <LargestTable rows={d.largest.metroPipelineMw} href={(r) => routes.market(r.slug)} value={(r) => `≈ ${fmtMw(r.mw)}`} unit="Pipeline MW" />228              </TabPanel>229            </Tabs>230          )}231        </Panel>232      </div>233234      <Panel235        title="Review queue"236        description="Flags sorted by priority (impact of the figure × severity). Resolve = fixed or verified (note required); Dismiss = not a problem."237        className="mt-3"238        padded={false}239        aside={<RefreshButton onClick={() => void flags.refresh()} busy={flags.refreshing} updatedAt={flags.updatedAt} />}240      >241        <div className="flex flex-col gap-2 border-b border-hair px-3 py-2 lg:flex-row lg:items-center">242          <Segmented label="Flag status" value={status} onChange={(v) => setParams({ status: v === "open" ? null : v, page: null })} options={FLAG_STATUSES.map((s) => ({ value: s, label: s[0]!.toUpperCase() + s.slice(1), count: s === status ? total : null }))} />243          <div className="flex flex-1 flex-wrap gap-2">244            <Select value={code} onChange={(e) => setParams({ code: e.target.value || null, page: null })} className="w-[240px]" aria-label="Code">245              <option value="">All codes</option>246              {code && !codes.includes(code) && <option value={code}>{code}</option>}247              {codes.map((c) => (248                <option key={c} value={c}>249                  {codeLabel(c, labels)}250                </option>251              ))}252            </Select>253            <Select value={entityType} onChange={(e) => setParams({ entity_type: e.target.value || null, page: null })} className="w-[150px]" aria-label="Entity type">254              <option value="">All entities</option>255              {["facility", "project", "operator", "claim", "metro", "country"].map((t) => (256                <option key={t} value={t}>257                  {t}258                </option>259              ))}260            </Select>261            <Input type="number" min={0} max={100} value={minPriority} onChange={(e) => setParams({ min_priority: e.target.value || null, page: null })} placeholder="min priority" className="w-[130px]" mono aria-label="Minimum priority" />262          </div>263        </div>264        <ErrorNote error={flags.error && !flags.data ? flags.error : null} onRetry={() => void flags.refresh()} className="m-3" />265        {flags.loading && !flags.data ? (266          <div className="p-3">267            <SkeletonRows rows={8} />268          </div>269        ) : queue.length === 0 ? (270          <EmptyState compact title={status === "open" ? "Review queue is empty" : "No flags"} description={status === "open" ? "Sanity flags open automatically after each connector run when a figure needs a human look." : undefined} />271        ) : (272          <ul className="divide-y divide-hair">273            {queue.map((f) => {274              const decided = gone[f.id];275              const href = f.entity ? entityHref(f.entityType, f.entity.slug, f.entity.name) : null;276              return (277                <li key={f.id} className={cn("grid gap-2 px-3 py-2.5 transition-opacity lg:grid-cols-[64px_minmax(0,1fr)_auto]", decided && "opacity-40")}>278                  <div className="flex items-center gap-2 lg:flex-col lg:items-start">279                    <span className="figure text-[18px] font-medium leading-none text-ink" title="Priority (0–100)">280                      {f.priority}281                    </span>282                    <SeverityBadge s={f.severity} />283                  </div>284                  <div className="min-w-0">285                    <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3">286                      <Badge size="xs" tone="outline" mono title={codeLabel(f.code, labels)}>287                        {f.code}288                      </Badge>289                      <span>{f.entityType}</span>290                      {f.field && <Mono>{f.field}</Mono>}291                      {f.claimId && (292                        <Mono className="text-ink-4" title="claim id">293                          {f.claimId}294                        </Mono>295                      )}296                      <RelativeTime iso={f.createdAt} className="ml-auto" />297                    </div>298                    <div className="mt-1 text-[13px] text-ink">299                      {href && f.entity ? (300                        <Link href={href} target="_blank" className="font-medium hover:text-accent hover:underline">301                          {f.entity.name}302                        </Link>303                      ) : (304                        <Mono>{f.entityId}</Mono>305                      )}306                    </div>307                    <p className="mt-0.5 text-[12.5px] text-ink-2">{f.message}</p>308                    {f.status !== "open" && (309                      <p className="mt-0.5 text-[11.5px] text-ink-3">310                        {f.status} {f.resolvedBy && `by ${f.resolvedBy}`} {f.resolvedAt && <RelativeTime iso={f.resolvedAt} />}311                        {f.resolution && <> — {f.resolution}</>}312                      </p>313                    )}314                    {f.details && Object.keys(f.details).length > 0 && (315                      <div className="mt-1.5">316                        <JsonView value={f.details} maxHeight={160} collapsible />317                      </div>318                    )}319                  </div>320                  <div className="flex flex-row gap-2 lg:w-[130px] lg:flex-col">321                    {f.status === "open" && !decided ? (322                      <>323                        <Button size="sm" variant="primary" className="flex-1" onClick={() => void decide(f, "resolve")} disabled={busy !== null}>324                          Resolve325                        </Button>326                        <Button size="sm" variant="ghost" className="flex-1" onClick={() => void decide(f, "dismiss")} disabled={busy !== null}>327                          Dismiss328                        </Button>329                      </>330                    ) : (331                      <span className="text-[12px] text-ink-3">{decided ?? f.status}</span>332                    )}333                  </div>334                </li>335              );336            })}337          </ul>338        )}339        <div className="px-3">340          <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />341        </div>342      </Panel>343344      <Panel title="System alerts" description="Regression checks and pipeline alerts (facilities −5 %, known MW +20 %, one operator +10 GW/day, one connector > 500 records/day, > 200 location changes/day)." className="mt-3" padded={false}>345        {!d ? (346          <div className="p-3">347            <SkeletonRows rows={3} />348          </div>349        ) : d.alerts.length === 0 ? (350          <EmptyState compact title="No active alert" />351        ) : (352          <ul className="divide-y divide-hair">353            {d.alerts.map((a) => (354              <li key={a.id} className="flex flex-wrap items-start gap-2 px-3 py-2 text-[12.5px]">355                <Badge size="xs" tone={a.level === "critical" || a.level === "error" ? "danger" : a.level === "warn" || a.level === "warning" ? "warn" : "info"} className="uppercase">356                  {a.level}357                </Badge>358                <Mono className="text-ink-3">{a.component}</Mono>359                <span className="min-w-0 flex-1 text-ink-2">{a.message}</span>360                <RelativeTime iso={a.createdAt} className="text-ink-4" />361              </li>362            ))}363          </ul>364        )}365      </Panel>366    </div>367  );368}369