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