spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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