SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
5.3 KB · 110 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ReviewDecisionButtons } from '@/components/admin/actions';4import type { ReviewItem } from '@/components/admin/types';5import { AdminError, AdminHeader, JsonInline } from '@/components/admin/ui';6import { StatusBadge } from '@/components/ui/badges';7import { Pagination } from '@/components/ui/pagination';8import { adminApi, safeAdmin } from '@/lib/admin-api';9import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';1011export const metadata: Metadata = { title: 'Entity resolution' };1213const STATUSES = ['open', 'merged', 'kept_separate', 'dismissed'] as const;1415function Entity({ label, slug, name, norad, cospar, status }: { label: string; slug: string | null; name: string | null; norad: number | null; cospar: string | null; status: string | null }) {16  return (17    <div className="min-w-0 rounded-md border border-rule px-3 py-2.5">18      <p className="eyebrow">{label}</p>19      {slug ? (20        <Link href={`/satellite/${slug}`} className="mt-1 block truncate text-sm font-medium text-ink hover:text-accent">21          {name ?? slug}22        </Link>23      ) : (24        <p className="mt-1 text-sm text-ink-3">— (entity no longer exists)</p>25      )}26      <dl className="mono mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-ink-2">27        <dt className="text-ink-3">NORAD</dt>28        <dd>{norad ?? '—'}</dd>29        <dt className="text-ink-3">COSPAR</dt>30        <dd>{cospar ?? '—'}</dd>31      </dl>32      <div className="mt-2">33        <StatusBadge status={status} />34      </div>35    </div>36  );37}3839function ReviewCard({ item, now }: { item: ReviewItem; now: number }) {40  return (41    <li className="border-t border-rule py-5">42      <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">43        <span className="mono text-ink-2">#{item.id}</span>44        <span className="rounded border border-rule px-1.5 py-0.5">{item.kind.replace(/_/g, ' ')}</span>45        <span>46          confidence <span className="tnum text-ink">{item.confidence === null ? '—' : item.confidence.toFixed(2)}</span>47        </span>48        <span title={fmtDateTime(item.created_at)}>raised {fmtAgo(item.created_at, now)}</span>49        {item.resolved_at && (50          <span>51            resolved {fmtAgo(item.resolved_at, now)} by {item.resolved_by ?? '?'}52          </span>53        )}54      </div>55      <div className="mt-3 grid gap-3 sm:grid-cols-2">56        <Entity label="A (kept on merge)" slug={item.a_slug} name={item.a_name} norad={item.a_norad} cospar={item.a_cospar} status={item.a_status} />57        <Entity label="B (merged into A)" slug={item.b_slug} name={item.b_name} norad={item.b_norad} cospar={item.b_cospar} status={item.b_status} />58      </div>59      {item.detail && (60        <div className="mt-2">61          <JsonInline value={item.detail} />62        </div>63      )}64      {item.status === 'open' && <ReviewDecisionButtons id={item.id} canMerge={!!item.entity_b_id && !!item.b_slug} className="mt-3" />}65    </li>66  );67}6869export default async function AdminEntityResolutionPage({ searchParams }: { searchParams: Promise<{ page?: string; status?: string }> }) {70  const sp = await searchParams;71  const page = Math.max(1, Number(sp.page) || 1);72  const status = (STATUSES as readonly string[]).includes(sp.status ?? '') ? (sp.status as (typeof STATUSES)[number]) : 'open';73  const res = await safeAdmin(adminApi.review(page, status, 25));74  const now = Date.now();75  const href = (p: number, s: string = status) => `/admin/entity-resolution?${new URLSearchParams({ ...(s !== 'open' ? { status: s } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;76  return (77    <>78      <AdminHeader title="Entity resolution" lede="Ambiguous matches from the resolver (NORAD → COSPAR → exact normalized name) are never merged automatically. Decide here; merges move aliases, identifiers and element history from B to A and are recorded in entity_merges with a snapshot of B." />79      <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:px-0">80        {STATUSES.map((s) => (81          <Link key={s} href={href(1, s)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${status === s ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>82            {s.replace('_', ' ')}83          </Link>84        ))}85      </div>86      {res.error !== null ? (87        <div className="mt-6">88          <AdminError message={res.error} />89        </div>90      ) : (91        <>92          <p className="mt-5 text-xs text-ink-3">93            {fmtInt(res.data.pagination.total)} {status.replace('_', ' ')} item{res.data.pagination.total === 1 ? '' : 's'}94          </p>95          {res.data.data.length === 0 ? (96            <p className="mt-3 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Queue is empty</p>97          ) : (98            <ul className="mt-2 border-b border-rule">99              {res.data.data.map((item) => (100                <ReviewCard key={item.id} item={item} now={now} />101              ))}102            </ul>103          )}104          <Pagination className="mt-4" page={res.data.pagination.page} pages={res.data.pagination.pages} total={res.data.pagination.total} pageSize={res.data.pagination.page_size} makeHref={(p) => href(p)} />105        </>106      )}107    </>108  );109}110