search: add Search section calling arcadia-search admin sidecar
New /search route manages tenants and corpora on the arcadia-search box via its privileged /admin/* surface (default :7801) — KPI tiles, flat tenant×corpus table with Rebuild / Edit config / Delete actions, New tenant / New corpus dialogs, and a Restart service button. New app/lib/search-admin.ts wraps the bearer-token fetch. Configured by VITE_ARCADIA_SEARCH_ADMIN_URL + VITE_ARCADIA_SEARCH_ADMIN_TOKEN; the route renders a warning banner when the token is unset. Token ships in the client bundle — fine for this internal tool, called out in CLAUDE.md and the source comments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
ShieldCheck,
|
||||
Megaphone,
|
||||
AlertOctagon,
|
||||
SearchCode,
|
||||
// CREMA:NAV-ICONS
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -112,6 +113,7 @@ const navItems: NavItem[] = [
|
||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
{ to: "/settings", icon: Settings, label: "Settings" },
|
||||
// CREMA:NAV-ITEMS
|
||||
|
||||
113
app/lib/search-admin.ts
Normal file
113
app/lib/search-admin.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Client for the arcadia-search admin sidecar (`/admin/*` on the
|
||||
// search box, default :7801). Used by the Search route to manage
|
||||
// tenants, corpora, and trigger rebuilds.
|
||||
//
|
||||
// Auth: static bearer token from VITE_ARCADIA_SEARCH_ADMIN_TOKEN,
|
||||
// matched constant-time against ADMIN_TOKEN on the sidecar. The token
|
||||
// ships in the client bundle — fine for an internal admin tool on a
|
||||
// trusted network; in production, proxy through arcadia-core.
|
||||
|
||||
const BASE_URL =
|
||||
import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_URL ?? "http://127.0.0.1:7801"
|
||||
const TOKEN = import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_TOKEN ?? ""
|
||||
|
||||
export class SearchAdminError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "SearchAdminError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (TOKEN) headers["Authorization"] = `Bearer ${TOKEN}`
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new SearchAdminError(
|
||||
text || `${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type TenantSummary = { id: string; corpus_count: number }
|
||||
export type CorpusSummary = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
indexed: boolean
|
||||
num_docs: number | null
|
||||
live_path: string | null
|
||||
}
|
||||
export type CorpusDetail = {
|
||||
config: Record<string, unknown>
|
||||
status: CorpusSummary
|
||||
}
|
||||
export type RebuildResult = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
chunk_count: number
|
||||
live_path: string
|
||||
built_at: string
|
||||
}
|
||||
|
||||
export const searchAdmin = {
|
||||
baseUrl: BASE_URL,
|
||||
hasToken: !!TOKEN,
|
||||
listTenants: () =>
|
||||
call<{ tenants: TenantSummary[] }>("GET", "/admin/tenants"),
|
||||
createTenant: (id: string) =>
|
||||
call<TenantSummary>("POST", "/admin/tenants", { id }),
|
||||
deleteTenant: (id: string) =>
|
||||
call<void>("DELETE", `/admin/tenants/${encodeURIComponent(id)}`),
|
||||
listCorpora: (tenant: string) =>
|
||||
call<{ corpora: CorpusSummary[] }>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
),
|
||||
createCorpus: (tenant: string, body: Record<string, unknown>) =>
|
||||
call<CorpusSummary>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
body,
|
||||
),
|
||||
getCorpus: (tenant: string, corpus: string) =>
|
||||
call<CorpusDetail>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
updateCorpus: (
|
||||
tenant: string,
|
||||
corpus: string,
|
||||
body: Record<string, unknown>,
|
||||
) =>
|
||||
call<CorpusSummary>(
|
||||
"PUT",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
body,
|
||||
),
|
||||
deleteCorpus: (tenant: string, corpus: string) =>
|
||||
call<void>(
|
||||
"DELETE",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
rebuild: (tenant: string, corpus: string) =>
|
||||
call<RebuildResult>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}/rebuild`,
|
||||
),
|
||||
restart: () => call<void>("POST", "/admin/restart"),
|
||||
}
|
||||
@@ -26,5 +26,6 @@ export default [
|
||||
route("sso", "routes/sso.tsx"),
|
||||
route("announcements", "routes/announcements.tsx"),
|
||||
route("status-page", "routes/status-page.tsx"),
|
||||
route("search", "routes/search.tsx"),
|
||||
// CREMA:ROUTES
|
||||
] satisfies RouteConfig
|
||||
|
||||
874
app/routes/search.tsx
Normal file
874
app/routes/search.tsx
Normal file
@@ -0,0 +1,874 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileText,
|
||||
Plus,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
ActionsCell,
|
||||
DataTable,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import {
|
||||
AlertBanner,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingOverlay,
|
||||
} from "@crema/feedback-ui"
|
||||
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import {
|
||||
searchAdmin,
|
||||
SearchAdminError,
|
||||
type CorpusSummary,
|
||||
type TenantSummary,
|
||||
} from "~/lib/search-admin"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Search")
|
||||
|
||||
type Row = CorpusSummary & { rowId: string }
|
||||
|
||||
type EditorState =
|
||||
| { kind: "new-tenant" }
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||
| null
|
||||
|
||||
export default function SearchRoute() {
|
||||
const session = useSession()
|
||||
|
||||
const [tenants, setTenants] = useState<TenantSummary[]>([])
|
||||
const [corpora, setCorpora] = useState<Row[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{
|
||||
tenant: string
|
||||
corpus: string
|
||||
} | null>(null)
|
||||
const [restartConfirm, setRestartConfirm] = useState(false)
|
||||
const [rebuilding, setRebuilding] = useState<string | null>(null)
|
||||
|
||||
const reportError = useCallback((err: unknown, fallback: string) => {
|
||||
setError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: fallback,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const tRes = await searchAdmin.listTenants()
|
||||
setTenants(tRes.tenants)
|
||||
// Fan out per-tenant corpus lookups in parallel.
|
||||
const cByT = await Promise.all(
|
||||
tRes.tenants.map(async (t) => {
|
||||
try {
|
||||
const r = await searchAdmin.listCorpora(t.id)
|
||||
return r.corpora
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
const flat: Row[] = cByT.flat().map((c) => ({
|
||||
...c,
|
||||
rowId: `${c.tenant}/${c.corpus}`,
|
||||
}))
|
||||
setCorpora(flat)
|
||||
} catch (err) {
|
||||
reportError(err, "Failed to load search admin state.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [reportError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const indexed = corpora.filter((c) => c.indexed).length
|
||||
const docs = corpora.reduce((a, c) => a + (c.num_docs ?? 0), 0)
|
||||
return { indexed, docs }
|
||||
}, [corpora])
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (tenant: string, corpus: string) => {
|
||||
const id = `${tenant}/${corpus}`
|
||||
setRebuilding(id)
|
||||
setError(null)
|
||||
try {
|
||||
const out = await searchAdmin.rebuild(tenant, corpus)
|
||||
setInfo(
|
||||
`Rebuilt ${tenant}/${corpus} — ${out.chunk_count} chunks indexed.`,
|
||||
)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Rebuild failed.")
|
||||
} finally {
|
||||
setRebuilding(null)
|
||||
}
|
||||
},
|
||||
[refresh, reportError],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage arcadia-search tenants and corpora. Trigger rebuilds and
|
||||
restart the service after env changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
data-action="search-refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRestartConfirm(true)}
|
||||
data-action="search-restart"
|
||||
>
|
||||
<Power className="size-4" />
|
||||
Restart service
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "new-tenant" })}
|
||||
data-action="search-new-tenant"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={tenants.length === 0}
|
||||
onClick={() =>
|
||||
setEditor({
|
||||
kind: "new-corpus",
|
||||
tenant: tenants[0]?.id ?? "",
|
||||
})
|
||||
}
|
||||
data-action="search-new-corpus"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New corpus
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!searchAdmin.hasToken ? (
|
||||
<AlertBanner variant="warning">
|
||||
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
|
||||
return 401 until the bearer token is configured. Endpoint:{" "}
|
||||
<code className="font-mono">{searchAdmin.baseUrl}</code>
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{error ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner
|
||||
variant="success"
|
||||
dismissible
|
||||
onDismiss={() => setInfo(null)}
|
||||
>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
|
||||
<div className="grid grid-cols-3 gap-3 min-w-0">
|
||||
<KpiTile
|
||||
label="Tenants"
|
||||
value={formatCompact(tenants.length)}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Corpora indexed"
|
||||
value={`${totals.indexed} / ${corpora.length}`}
|
||||
/>
|
||||
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<TenantsCard
|
||||
tenants={tenants}
|
||||
onDelete={(id) => setPendingDeleteTenant(id)}
|
||||
/>
|
||||
|
||||
<CorporaCard
|
||||
corpora={corpora}
|
||||
loading={loading}
|
||||
rebuildingId={rebuilding}
|
||||
onRebuild={rebuild}
|
||||
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
|
||||
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New tenant */}
|
||||
<NewTenantDialog
|
||||
open={editor?.kind === "new-tenant"}
|
||||
onClose={() => setEditor(null)}
|
||||
onCreated={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* New / edit corpus */}
|
||||
<CorpusEditor
|
||||
editor={
|
||||
editor?.kind === "new-corpus" || editor?.kind === "edit-corpus"
|
||||
? editor
|
||||
: null
|
||||
}
|
||||
tenants={tenants}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* Delete tenant */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteTenant !== null}
|
||||
onOpenChange={(o) => !o && setPendingDeleteTenant(null)}
|
||||
title={`Delete tenant ${pendingDeleteTenant ?? ""}?`}
|
||||
description="Removes the tenant's config directory AND its entire index directory. This cannot be undone."
|
||||
confirmLabel="Delete tenant"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDeleteTenant) return
|
||||
try {
|
||||
await searchAdmin.deleteTenant(pendingDeleteTenant)
|
||||
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
|
||||
setPendingDeleteTenant(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteTenant(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete corpus */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteCorpus !== null}
|
||||
onOpenChange={(o) => !o && setPendingDeleteCorpus(null)}
|
||||
title={
|
||||
pendingDeleteCorpus
|
||||
? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?`
|
||||
: ""
|
||||
}
|
||||
description="Removes the corpus config and its index directory. The tenant is preserved."
|
||||
confirmLabel="Delete corpus"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDeleteCorpus) return
|
||||
const { tenant, corpus } = pendingDeleteCorpus
|
||||
try {
|
||||
await searchAdmin.deleteCorpus(tenant, corpus)
|
||||
setInfo(`Deleted ${tenant}/${corpus}.`)
|
||||
setPendingDeleteCorpus(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteCorpus(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Restart confirm */}
|
||||
<ConfirmDialog
|
||||
open={restartConfirm}
|
||||
onOpenChange={(o) => !o && setRestartConfirm(false)}
|
||||
title="Restart arcadia-search admin?"
|
||||
description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted."
|
||||
confirmLabel="Restart"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
setRestartConfirm(false)
|
||||
try {
|
||||
await searchAdmin.restart()
|
||||
setInfo("Restart requested.")
|
||||
} catch (err) {
|
||||
reportError(err, "Restart request failed.")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tenants card --------------------------------------------------------
|
||||
|
||||
function TenantsCard({
|
||||
tenants,
|
||||
onDelete,
|
||||
}: {
|
||||
tenants: TenantSummary[]
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold">Tenants</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{tenants.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No tenants yet."
|
||||
description="Create one to start adding corpora."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{tenants.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5"
|
||||
>
|
||||
<code className="font-mono text-xs">{t.id}</code>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onDelete(t.id)}
|
||||
aria-label={`Delete tenant ${t.id}`}
|
||||
data-action={`tenant-${t.id}-delete`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Corpora table -------------------------------------------------------
|
||||
|
||||
function CorporaCard({
|
||||
corpora,
|
||||
loading,
|
||||
rebuildingId,
|
||||
onRebuild,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
corpora: Row[]
|
||||
loading: boolean
|
||||
rebuildingId: string | null
|
||||
onRebuild: (tenant: string, corpus: string) => void
|
||||
onEdit: (tenant: string, corpus: string) => void
|
||||
onDelete: (tenant: string, corpus: string) => void
|
||||
}) {
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const columns = useMemo<Column<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "tenant",
|
||||
header: "Tenant",
|
||||
accessor: "tenant",
|
||||
sortable: true,
|
||||
cell: (r) => (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{r.tenant}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "corpus",
|
||||
header: "Corpus",
|
||||
accessor: "corpus",
|
||||
sortable: true,
|
||||
cell: (r) => (
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
{r.corpus}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "indexed",
|
||||
header: "Status",
|
||||
sortable: true,
|
||||
accessor: (r) => (r.indexed ? 1 : 0),
|
||||
cell: (r) =>
|
||||
r.indexed ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Indexed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not built
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
header: "Docs",
|
||||
sortable: true,
|
||||
accessor: (r) => r.num_docs ?? -1,
|
||||
cell: (r) =>
|
||||
r.num_docs != null ? (
|
||||
<span className="font-mono text-xs">
|
||||
{r.num_docs.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (r) => {
|
||||
const id = `${r.tenant}/${r.corpus}`
|
||||
const isRebuilding = rebuildingId === id
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "rebuild",
|
||||
label: isRebuilding ? "Rebuilding…" : "Rebuild",
|
||||
icon: (
|
||||
<RefreshCw
|
||||
className={`size-4 ${isRebuilding ? "animate-spin" : ""}`}
|
||||
/>
|
||||
),
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`,
|
||||
onSelect: () =>
|
||||
isRebuilding ? undefined : onRebuild(r.tenant, r.corpus),
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit config",
|
||||
icon: <FileText className="size-4" />,
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-edit`,
|
||||
onSelect: () => onEdit(r.tenant, r.corpus),
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-delete`,
|
||||
onSelect: () => onDelete(r.tenant, r.corpus),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ActionsCell
|
||||
items={items}
|
||||
triggerDataAction={`corpus-${r.tenant}-${r.corpus}-actions`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[rebuildingId, onRebuild, onEdit, onDelete],
|
||||
)
|
||||
|
||||
const table = useTable<Row>({
|
||||
data: corpora,
|
||||
columns,
|
||||
getRowId: (r) => r.rowId,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search by tenant or corpus"
|
||||
data-action="corpora-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{table.total} of {corpora.length}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay
|
||||
active={loading && corpora.length === 0}
|
||||
label="Loading corpora…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={search ? "No matches." : "No corpora yet."}
|
||||
description={
|
||||
search ? "Try a different search." : "Create one above."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(r) => r.rowId}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && corpora.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Dialogs -------------------------------------------------------------
|
||||
|
||||
function NewTenantDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [id, setId] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setId("")
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await searchAdmin.createTenant(id)
|
||||
await onCreated(`Tenant ${id} created.`)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Create failed.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creates an empty config dir at{" "}
|
||||
<code className="font-mono text-xs">
|
||||
$INDEX_CONFIG_DIR/<id>/
|
||||
</code>
|
||||
. Add corpora separately. Names are alphanumeric, dash, or
|
||||
underscore.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-tenant-id">Tenant id</Label>
|
||||
<Input
|
||||
id="new-tenant-id"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="acme"
|
||||
data-action="tenant-form-id"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="tenant-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !id}
|
||||
data-action="tenant-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CORPUS_CONFIG_TEMPLATE = `{
|
||||
"corpus": "docs",
|
||||
"sources": [
|
||||
{
|
||||
"type": "arcadia",
|
||||
"list_url": "/api/v1/files?tenant_id={tenant}",
|
||||
"item_url": "/api/v1/files/{id}/content",
|
||||
"title_field": "name",
|
||||
"id_field": "id",
|
||||
"mtime_field": "updated_at",
|
||||
"tags": ["uploaded"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
function CorpusEditor({
|
||||
editor,
|
||||
tenants,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
editor:
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||
| null
|
||||
tenants: TenantSummary[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [tenant, setTenant] = useState("")
|
||||
const [text, setText] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isEdit = editor?.kind === "edit-corpus"
|
||||
const headerCorpus = isEdit ? editor.corpus : ""
|
||||
|
||||
// Hydrate on open: load existing config for edit, template for new.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setTenant(editor.tenant)
|
||||
if (editor.kind === "edit-corpus") {
|
||||
setLoading(true)
|
||||
searchAdmin
|
||||
.getCorpus(editor.tenant, editor.corpus)
|
||||
.then((res) => {
|
||||
setText(JSON.stringify(res.config, null, 2))
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Load failed.",
|
||||
)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setText(CORPUS_CONFIG_TEMPLATE)
|
||||
}
|
||||
}, [editor, onError])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("config must be a JSON object")
|
||||
}
|
||||
if (editor.kind === "new-corpus") {
|
||||
const corpus = parsed.corpus
|
||||
if (typeof corpus !== "string" || !corpus) {
|
||||
throw new Error('config must have a string "corpus" field')
|
||||
}
|
||||
await searchAdmin.createCorpus(tenant, parsed)
|
||||
await onSaved(`Created ${tenant}/${corpus}.`)
|
||||
} else {
|
||||
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
|
||||
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
JSON config matching arcadia-search's IndexerConfig schema. The{" "}
|
||||
<code className="font-mono text-xs">tenant</code> field is set
|
||||
from the URL — your value is overwritten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isEdit ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="corpus-tenant">Tenant</Label>
|
||||
<Select value={tenant} onValueChange={setTenant}>
|
||||
<SelectTrigger
|
||||
id="corpus-tenant"
|
||||
data-action="corpus-form-tenant"
|
||||
>
|
||||
<SelectValue placeholder="Pick a tenant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="corpus-config">Config JSON</Label>
|
||||
<Textarea
|
||||
id="corpus-config"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={20}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
data-action="corpus-form-config"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="corpus-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || loading || !tenant}
|
||||
data-action="corpus-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
{isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user