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" import { useRegisterContext } from "@crema/aifirst-ui/context" 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([]) const [corpora, setCorpora] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [editor, setEditor] = useState(null) const [pendingDeleteTenant, setPendingDeleteTenant] = useState( null, ) const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{ tenant: string corpus: string } | null>(null) const [restartConfirm, setRestartConfirm] = useState(false) const [rebuilding, setRebuilding] = useState(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]) // Publish a snapshot to the assistant's admin context so the agent // can answer "what corpora exist?" / "is the docs corpus indexed?" // without having to call list_search_corpora. const adminSurface = useMemo( () => ({ endpoint: searchAdmin.baseUrl, tenants: tenants.map((t) => ({ id: t.id, corpus_count: t.corpus_count })), corpora: corpora.map((c) => ({ tenant: c.tenant, corpus: c.corpus, indexed: c.indexed, num_docs: c.num_docs, })), }), [tenants, corpora], ) useRegisterContext("search", adminSurface) 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 (

Search

Manage arcadia-search tenants and corpora. Trigger rebuilds and restart the service after env changes.

{!searchAdmin.hasToken ? ( VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will return 401 until the bearer token is configured. Endpoint:{" "} {searchAdmin.baseUrl} ) : null} {error ? ( setError(null)} > {error} ) : null} {info ? ( setInfo(null)} > {info} ) : null}
setPendingDeleteTenant(id)} /> setEditor({ kind: "edit-corpus", tenant: t, corpus: c })} onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })} />
{/* New tenant */} setEditor(null)} onCreated={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={(msg) => setError(msg)} /> {/* New / edit corpus */} setEditor(null)} onSaved={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={(msg) => setError(msg)} /> {/* Delete tenant */} !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 */} !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 */} !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.") } }} />
) } // --- Tenants card -------------------------------------------------------- function TenantsCard({ tenants, onDelete, }: { tenants: TenantSummary[] onDelete: (id: string) => void }) { return (

Tenants

{tenants.length === 0 ? ( ) : (
    {tenants.map((t) => (
  • {t.id} {t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
  • ))}
)}
) } // --- 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[]>( () => [ { id: "tenant", header: "Tenant", accessor: "tenant", sortable: true, cell: (r) => ( {r.tenant} ), }, { id: "corpus", header: "Corpus", accessor: "corpus", sortable: true, cell: (r) => ( {r.corpus} ), }, { id: "indexed", header: "Status", sortable: true, accessor: (r) => (r.indexed ? 1 : 0), cell: (r) => r.indexed ? ( Indexed ) : ( Not built ), }, { id: "docs", header: "Docs", sortable: true, accessor: (r) => r.num_docs ?? -1, cell: (r) => r.num_docs != null ? ( {r.num_docs.toLocaleString()} ) : ( ), }, { 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: ( ), dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`, onSelect: () => isRebuilding ? undefined : onRebuild(r.tenant, r.corpus), }, { id: "edit", label: "Edit config", icon: , dataAction: `corpus-${r.tenant}-${r.corpus}-edit`, onSelect: () => onEdit(r.tenant, r.corpus), }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `corpus-${r.tenant}-${r.corpus}-delete`, onSelect: () => onDelete(r.tenant, r.corpus), }, ] return ( ) }, }, ], [rebuildingId, onRebuild, onEdit, onDelete], ) const table = useTable({ data: corpora, columns, getRowId: (r) => r.rowId, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (
{table.total} of {corpora.length}
{table.total === 0 && !loading ? ( } title={search ? "No matches." : "No corpora yet."} description={ search ? "Try a different search." : "Create one above." } className="py-12" /> ) : ( <> r.rowId} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && corpora.length > 0} stickyHeader /> )}
) } // --- Dialogs ------------------------------------------------------------- function NewTenantDialog({ open, onClose, onCreated, onError, }: { open: boolean onClose: () => void onCreated: (msg?: string) => Promise 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 ( !o && onClose()}> New tenant Creates an empty config dir at{" "} $INDEX_CONFIG_DIR/<id>/ . Add corpora separately. Names are alphanumeric, dash, or underscore.
setId(e.target.value)} placeholder="acme" data-action="tenant-form-id" />
) } 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 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 ( !o && onClose()}> {isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"} JSON config matching arcadia-search's IndexerConfig schema. The{" "} tenant field is set from the URL — your value is overwritten.
{!isEdit ? (
) : null}