spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useState } from "react";5import { PAGE_TYPES } from "@dci/core";6import { Badge, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";7import { cn } from "@/components/ui/cn";8import { DASH, fmtInt } from "@/lib/format";9import type { AdminDocument, ConnectorHealthDTO } from "@/lib/admin-api";10import { ErrorNote, fmtBytes, LevelBadge, Pager, RefreshButton, Segmented, Select } from "./primitives";11import { AdminPageHeader } from "./shell";12import { useAdminQuery } from "./use-admin-query";13import { useUrlState } from "./use-url-state";1415const STATUS = ["all", "error", "changed", "quarantined"] as const;16type Status = (typeof STATUS)[number];17const PER_PAGE = 50;1819export function DocumentsTable() {20 const [params, setParams] = useUrlState();21 const connector = params.connector ?? "";22 const pageType = params.pageType ?? "";23 const status = (STATUS.includes(params.status as Status) ? params.status : "all") as Status;24 const page = Math.max(1, Number(params.page ?? 1) || 1);25 const [search, setSearch] = useState(params.q ?? "");26 useEffect(() => {27 const t = setTimeout(() => {28 if ((params.q ?? "") !== search) setParams({ q: search || null, page: null });29 }, 350);30 return () => clearTimeout(t);31 }, [search, params.q, setParams]);3233 const connectors = useAdminQuery<ConnectorHealthDTO[]>("connectors", { timeoutMs: 30_000 });34 const q = useAdminQuery<AdminDocument[]>("documents", { query: { connector: connector || undefined, pageType: pageType || undefined, status: status === "all" ? undefined : status, q: params.q || undefined, page, per_page: PER_PAGE }, refreshMs: 60_000 });35 const total = Number(q.meta?.total ?? 0);36 const connectorIds = useMemo(() => (connectors.data ?? []).map((c) => c.id).sort(), [connectors.data]);3738 return (39 <div>40 <AdminPageHeader title="Documents" description={q.data ? `${fmtInt(total)} documents in the URL registry${connector ? ` · ${connector}` : ""}` : "URL registry: every page the crawler knows, with versions and extraction state"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />41 <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />42 <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">43 <Segmented label="Status" value={status} onChange={(v) => setParams({ status: v === "all" ? null : v, page: null })} options={STATUS.map((s) => ({ value: s, label: s === "all" ? "All" : s[0]!.toUpperCase() + s.slice(1) }))} />44 <div className="flex flex-1 flex-col gap-2 sm:flex-row">45 <Select value={connector} onChange={(e) => setParams({ connector: e.target.value || null, page: null })} className="sm:w-[200px]" aria-label="Connector">46 <option value="">All connectors</option>47 {connector && !connectorIds.includes(connector) && <option value={connector}>{connector}</option>}48 {connectorIds.map((id) => (49 <option key={id} value={id}>50 {id}51 </option>52 ))}53 </Select>54 <Select value={pageType} onChange={(e) => setParams({ pageType: e.target.value || null, page: null })} className="sm:w-[190px]" aria-label="Page type">55 <option value="">All page types</option>56 {PAGE_TYPES.map((t) => (57 <option key={t} value={t}>58 {t}59 </option>60 ))}61 </Select>62 <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Search URL or title" wrapperClassName="flex-1" />63 </div>64 </div>6566 {q.loading && !q.data ? (67 <SkeletonRows rows={10} />68 ) : !q.data || q.data.length === 0 ? (69 <EmptyState compact title="No documents match" />70 ) : (71 <div className="rounded-sm border border-hair bg-surface px-3">72 <Table className="text-[12.5px]">73 <THead>74 <Tr>75 <Th>URL</Th>76 <Th>Connector</Th>77 <Th>Page type</Th>78 <Th>Lvl</Th>79 <Th numeric>HTTP</Th>80 <Th>Last fetched</Th>81 <Th>Last changed</Th>82 <Th numeric>Changes</Th>83 <Th>Extract</Th>84 <Th numeric>Size</Th>85 </Tr>86 </THead>87 <TBody>88 {q.data.map((d) => (89 <Tr key={d.id} className={cn(d.quarantined && "opacity-70")}>90 <Td primary label="URL" className="max-w-[420px]">91 <Link href={`/admin/documents/${encodeURIComponent(d.id)}`} className="block truncate font-mono text-[12px] font-medium text-ink hover:text-accent hover:underline" title={d.url}>92 {d.url.replace(/^https?:\/\//, "")}93 </Link>94 <div className="flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">95 <span className="truncate">{d.title ?? d.id}</span>96 {d.quarantined && (97 <Badge size="xs" tone="warn">98 quarantined99 </Badge>100 )}101 {d.error && (102 <span className="truncate text-danger" title={d.error}>103 {d.error}104 </span>105 )}106 </div>107 </Td>108 <Td label="Connector">109 <Link href={`/admin/connectors/${encodeURIComponent(d.connectorId)}`} className="font-mono text-[12px] text-ink-2 hover:text-accent hover:underline">110 {d.connectorId}111 </Link>112 </Td>113 <Td label="Page type">114 <Badge size="xs" mono tone="outline">115 {d.pageType}116 </Badge>117 </Td>118 <Td label="Level">119 <LevelBadge level={d.fetchLevel} />120 </Td>121 <Td label="HTTP" numeric className={cn(d.statusCode != null && d.statusCode >= 400 && "text-danger")}>122 {d.statusCode ?? DASH}123 </Td>124 <Td label="Last fetched" muted>125 <RelativeTime iso={d.lastFetched} />126 </Td>127 <Td label="Last changed" muted>128 <RelativeTime iso={d.lastChanged} />129 </Td>130 <Td label="Changes" numeric>131 {fmtInt(d.changeCount)}132 <span className="text-ink-4"> / {fmtInt(d.fetchCount)}</span>133 </Td>134 <Td label="Extract">135 {d.extractOk == null ? (136 <span className="text-ink-4">{DASH}</span>137 ) : d.extractOk ? (138 <span className="text-ok">139 ok <span className="figure text-ink-3">{fmtInt(d.extractCount)}</span>140 </span>141 ) : (142 <span className="text-danger">failed{d.errorCount ? <span className="figure"> ×{d.errorCount}</span> : null}</span>143 )}144 </Td>145 <Td label="Size" numeric muted>146 {fmtBytes(d.sizeBytes)}147 </Td>148 </Tr>149 ))}150 </TBody>151 </Table>152 <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />153 </div>154 )}155 </div>156 );157}158