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, useState } from "react";5import { Badge, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtInt } from "@/lib/format";8import { SOURCE_KIND_LABEL } from "@/lib/labels";9import { routes } from "@/lib/routes";10import type { SourceListItem } from "@/lib/admin-api";11import { ErrorNote, ExtLink, Mono, RefreshButton, Select } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useAdminQuery } from "./use-admin-query";1415type SortKey = "name" | "kind" | "documents" | "provenanceRows" | "facilities" | "lastDocumentAt";1617export function SourcesTable() {18 const q = useAdminQuery<SourceListItem[]>("v1/sources", { refreshMs: 120_000 });19 const [kind, setKind] = useState("");20 const [search, setSearch] = useState("");21 const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "provenanceRows", dir: "desc" });22 const list = useMemo(() => q.data ?? [], [q.data]);23 const kinds = useMemo(() => [...new Set(list.map((s) => s.kind))].sort(), [list]);24 const rows = useMemo(() => {25 const s = search.trim().toLowerCase();26 const out = list.filter((x) => (!kind || x.kind === kind) && (!s || x.name.toLowerCase().includes(s) || x.domain.toLowerCase().includes(s) || (x.connectorId ?? "").includes(s) || (x.license ?? "").toLowerCase().includes(s)));27 out.sort((a, b) => {28 const va = a[sort.key];29 const vb = b[sort.key];30 const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va ?? "").localeCompare(String(vb ?? ""));31 return (sort.dir === "asc" ? cmp : -cmp) || a.name.localeCompare(b.name);32 });33 return out;34 }, [list, kind, search, sort]);35 const th = (key: SortKey, label: string, numeric?: boolean) => (36 <Th numeric={numeric} sort={sort.key === key ? sort.dir : null} className="cursor-pointer select-none" onClick={() => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : numeric ? "desc" : "asc" }))}>37 {label}38 </Th>39 );40 const totals = useMemo(() => rows.reduce((a, s) => ({ documents: a.documents + s.documents, provenance: a.provenance + s.provenanceRows, facilities: a.facilities + s.facilities, licensed: a.licensed + (s.license ? 1 : 0) }), { documents: 0, provenance: 0, facilities: 0, licensed: 0 }), [rows]);4142 return (43 <div>44 <AdminPageHeader title="Sources" description={q.data ? `${fmtInt(list.length)} sources · ${fmtInt(totals.licensed)} with a recorded license · ${fmtInt(totals.provenance)} provenance rows` : "Every source behind the index, with license, attribution and what it produced"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />45 <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />46 <div className="mb-3 flex flex-col gap-2 sm:flex-row">47 <Select value={kind} onChange={(e) => setKind(e.target.value)} className="sm:w-[180px]" aria-label="Kind">48 <option value="">All kinds</option>49 {kinds.map((k) => (50 <option key={k} value={k}>51 {SOURCE_KIND_LABEL[k] ?? k}52 </option>53 ))}54 </Select>55 <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Search name, domain, connector, license" wrapperClassName="flex-1" />56 </div>57 {q.loading && !q.data ? (58 <SkeletonRows rows={10} />59 ) : rows.length === 0 ? (60 <EmptyState compact title="No sources match" />61 ) : (62 <div className="rounded-sm border border-hair bg-surface px-3">63 <Table className="text-[12.5px]">64 <THead>65 <Tr>66 {th("name", "Source")}67 {th("kind", "Kind")}68 <Th>License</Th>69 <Th>Attribution</Th>70 <Th>Docs</Th>71 {th("documents", "Documents", true)}72 {th("provenanceRows", "Provenance", true)}73 {th("facilities", "Facilities", true)}74 {th("lastDocumentAt", "Last document")}75 </Tr>76 </THead>77 <TBody>78 {rows.map((s) => (79 <Tr key={s.id}>80 <Td primary label="Source" className="max-w-[260px]">81 <Link href={routes.source(s.id)} target="_blank" className="block truncate font-medium text-ink hover:text-accent hover:underline">82 {s.name}83 </Link>84 <div className="flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">85 <span className="truncate">{s.domain}</span>86 {s.connectorId && (87 <Link href={`/admin/connectors/${encodeURIComponent(s.connectorId)}`} className="font-mono hover:text-accent">88 {s.connectorId}89 </Link>90 )}91 {s.robotsAllowed === false && (92 <Badge size="xs" tone="danger">93 robots blocked94 </Badge>95 )}96 </div>97 </Td>98 <Td label="Kind" muted>99 {SOURCE_KIND_LABEL[s.kind] ?? s.kind}100 </Td>101 <Td label="License" className={cn("max-w-[260px]", !s.license && "text-warn")}>102 <span className="line-clamp-2" title={s.license ?? undefined}>103 {s.license ?? "not recorded"}104 </span>105 </Td>106 <Td label="Attribution" className="max-w-[200px]">107 <span className="line-clamp-2" title={s.attribution ?? undefined}>108 {s.attribution ?? DASH}109 </span>110 </Td>111 <Td label="Docs">112 {s.url ? <ExtLink href={s.url}>link</ExtLink> : <span className="text-ink-4">{DASH}</span>}113 {s.notes && (114 <span className="ml-1 cursor-help text-ink-4" title={s.notes}>115 ⓘ116 </span>117 )}118 </Td>119 <Td label="Documents" numeric>120 {fmtInt(s.documents)}121 </Td>122 <Td label="Provenance" numeric>123 {fmtInt(s.provenanceRows)}124 </Td>125 <Td label="Facilities" numeric>126 {fmtInt(s.facilities)}127 </Td>128 <Td label="Last document" muted>129 {s.lastDocumentAt ? <RelativeTime iso={s.lastDocumentAt} /> : <Mono>{DASH}</Mono>}130 </Td>131 </Tr>132 ))}133 </TBody>134 </Table>135 <div className="flex flex-wrap gap-4 border-t border-hair py-2 text-[12px] text-ink-3">136 <span>{fmtInt(rows.length)} sources</span>137 <span className="figure">{fmtInt(totals.documents)} documents</span>138 <span className="figure">{fmtInt(totals.provenance)} provenance rows</span>139 <span className="figure">{fmtInt(totals.facilities)} facilities</span>140 </div>141 </div>142 )}143 </div>144 );145}146