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%
6.8 KB · 128 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useMemo } from "react";5import { EmptyState, SkeletonBlock } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { fmtInt } from "@/lib/format";8import type { DataGaps } from "@/lib/admin-api";9import { ErrorNote, Panel, RefreshButton, Stat } from "./primitives";10import { AdminPageHeader } from "./shell";11import { useAdminQuery } from "./use-admin-query";1213/* ------------------------------------------------------------------------------------------14   /admin/data-gaps — what we do not know, ranked for enrichment. Counters come from the worker15   (GET /data-gaps proxied by the API); a 502 means the maintenance worker is unreachable.16   ------------------------------------------------------------------------------------------ */1718const GROUPS: Array<{ key: string; label: string; prefix: RegExp; description: string }> = [19  { key: "facilities", label: "Facilities", prefix: /^facilities_/, description: "Live facility records (merged duplicates excluded)." },20  { key: "projects", label: "Projects", prefix: /^projects_/, description: "Live project records (hidden and merged excluded)." },21  { key: "operators", label: "Operators", prefix: /^operators_/, description: "Operator registry." },22  { key: "other", label: "Pipeline & other entities", prefix: /.*/, description: "Reconciliation queue, quality flags, claims, cloud regions, IXPs." },23];2425/** Where a gap can be worked on — only links that are guaranteed to exist. */26const LINKS: Record<string, string> = {27  pending_matches: "/admin/matches",28  open_quality_flags: "/admin/quality",29  unscoped_claims: "/admin/quality",30  claims_in_review: "/admin/quality",31  projects_without_facility_link: "/admin/projects",32  projects_without_coordinates: "/admin/projects",33  projects_without_country: "/admin/projects",34  projects_without_operator: "/admin/projects",35  projects_without_capacity: "/admin/projects",36  projects_without_expected_opening: "/admin/projects",37  projects_without_source: "/admin/projects",38  facilities_without_country: "/admin/quality?code=facility_no_country",39};4041function humanize(key: string): string {42  const s = key.replace(/^(facilities|projects|operators)_/, "").replace(/_/g, " ");43  return s.charAt(0).toUpperCase() + s.slice(1);44}4546export function DataGapsView() {47  const q = useAdminQuery<DataGaps>("data-gaps", { refreshMs: 300_000, timeoutMs: 30_000 });48  const source = typeof q.meta?.source === "string" ? q.meta.source : null;49  const groups = useMemo(() => {50    const entries = Object.entries(q.data ?? {}).filter(([, v]) => Number.isFinite(v));51    const used = new Set<string>();52    return GROUPS.map((g) => {53      const items = entries.filter(([k]) => !used.has(k) && g.prefix.test(k));54      for (const [k] of items) used.add(k);55      items.sort((a, b) => b[1] - a[1]);56      return { ...g, items, total: items.reduce((a, [, v]) => a + v, 0) };57    }).filter((g) => g.items.length);58  }, [q.data]);59  const max = useMemo(() => Math.max(1, ...Object.values(q.data ?? {})), [q.data]);60  const unreachable = q.error && /worker unreachable|502|fetch failed/i.test(q.error);6162  return (63    <div>64      <AdminPageHeader title="Data gaps" description={source ? `What the index does not know, ranked for enrichment — counted by the maintenance worker (${source}).` : "What the index does not know, ranked for enrichment — counted by the maintenance worker."} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />65      {q.error && !q.data && (66        <ErrorNote error={unreachable ? `The maintenance worker did not answer (${q.error}). Data-gap counters are computed by the worker (GET /data-gaps); start it or check DCI_WORKER_URL on the API.` : q.error} onRetry={() => void q.refresh()} className="mb-3" />67      )}68      {q.loading && !q.data ? (69        <div className="grid gap-3 lg:grid-cols-2">70          <SkeletonBlock className="h-56" />71          <SkeletonBlock className="h-56" />72        </div>73      ) : !q.data ? (74        !q.error && <EmptyState title="No counters" />75      ) : groups.length === 0 ? (76        <EmptyState title="No gap counters returned" />77      ) : (78        <div className="grid gap-3 lg:grid-cols-2">79          {groups.map((g) => (80            <Panel key={g.key} title={g.label} description={g.description} aside={<span className="figure text-[12px] text-ink-3">{fmtInt(g.total)} total</span>} padded={false}>81              <ul className="divide-y divide-hair">82                {g.items.map(([k, v]) => {83                  const href = LINKS[k];84                  const inner = (85                    <>86                      <div className="flex items-baseline justify-between gap-3">87                        <span className="min-w-0 truncate text-[13px] text-ink">{humanize(k)}</span>88                        <span className={cn("figure shrink-0 text-[15px] font-medium", v === 0 ? "text-ink-4" : "text-ink")}>{fmtInt(v)}</span>89                      </div>90                      <div className="mt-1 h-[3px] w-full bg-surface-3" aria-hidden>91                        <div className={cn("h-full", v / max > 0.5 ? "bg-warn" : "bg-ink-3")} style={{ width: `${Math.max(v > 0 ? 1.5 : 0, (v / max) * 100)}%` }} />92                      </div>93                      <div className="mt-0.5 flex items-center justify-between font-mono text-[10.5px] text-ink-4">94                        <span>{k}</span>95                        {href && <span className="text-ink-3">work on it →</span>}96                      </div>97                    </>98                  );99                  return (100                    <li key={k}>101                      {href ? (102                        <Link href={href} className="block px-3 py-2 hover:bg-surface-2">103                          {inner}104                        </Link>105                      ) : (106                        <div className="px-3 py-2">{inner}</div>107                      )}108                    </li>109                  );110                })}111              </ul>112            </Panel>113          ))}114        </div>115      )}116      {q.data && (117        <div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">118          <Stat label="Counters" value={fmtInt(Object.keys(q.data).length)} />119          <Stat label="Largest gap" value={fmtInt(max)} note={Object.entries(q.data).find(([, v]) => v === max)?.[0]?.replace(/_/g, " ")} />120          <Stat label="Zero gaps" value={fmtInt(Object.values(q.data).filter((v) => v === 0).length)} tone="ok" />121          <Stat label="Refreshed" value={q.updatedAt ? new Date(q.updatedAt).toISOString().slice(11, 19) : "—"} note="UTC" />122        </div>123      )}124      <p className="mt-3 text-[11px] text-ink-4">A gap is a missing published value, not a wrong one: the public site shows “—” for each of them. Enrichment adds sources; it never fills a gap with an estimate.</p>125    </div>126  );127}128