SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.6 KB · 158 lines tsx
Raw Blame History
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