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 { ConfirmDialog, EmptyState } from "@crema/feedback-ui" import { useToast } from "@crema/notification-ui" import { KpiTile, formatCompact } from "@crema/dashboard-ui" import { AppShell } from "~/components/layout/app-shell" import { DataState, DialogError, ErrorState } from "~/components/data-state" import { errorMessage } from "~/lib/errors" 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, 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 toast = useToast() const [tenants, setTenants] = useState([]) const [corpora, setCorpora] = useState([]) const [loading, setLoading] = useState(false) // arcadia-search is a separate sidecar, so this is often a plain Error / // TypeError (connection refused) rather than an ArcadiaError. Pass it through // raw — `describeError` already turns that into "Can't reach arcadia". const [error, setError] = useState(null) const [corporaError, setCorporaError] = 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 refresh = useCallback(async () => { setLoading(true) setError(null) setCorporaError(null) try { const tRes = await searchAdmin.listTenants() setTenants(tRes.tenants) // Fan out per-tenant corpus lookups in parallel. A tenant whose lookup // fails no longer disappears silently: if every lookup failed we surface // the failure instead of rendering "No corpora yet." const settled = await Promise.allSettled( tRes.tenants.map((t) => searchAdmin.listCorpora(t.id)), ) const ok = settled.filter( (r): r is PromiseFulfilledResult<{ corpora: CorpusSummary[] }> => r.status === "fulfilled", ) const firstFailure = settled.find((r) => r.status === "rejected") setCorpora( ok .flatMap((r) => r.value.corpora) .map((c) => ({ ...c, rowId: `${c.tenant}/${c.corpus}` })), ) if (settled.length > 0 && ok.length === 0 && firstFailure) { setCorporaError((firstFailure as PromiseRejectedResult).reason) } } catch (err) { setError(err) setTenants([]) setCorpora([]) } finally { setLoading(false) } }, []) 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) try { const out = await searchAdmin.rebuild(tenant, corpus) await refresh() toast.success(`Rebuilt ${id}`, { description: `${out.chunk_count} chunks indexed.`, }) } catch (err) { toast.error(errorMessage(err, `rebuild ${id}`)) } finally { setRebuilding(null) } }, [refresh, toast], ) return (

Search

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

{/* A standing configuration fact, not a load failure — so it isn't an error state and it isn't dismissible. It stays until it's fixed. */} {!searchAdmin.hasToken ? (

Admin token not configured

VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset, so every call below will come back 401. Endpoint:{" "} {searchAdmin.baseUrl}

) : null} {/* The whole screen hangs off one call to the sidecar. If that call failed, nothing here loaded — say so once, rather than rendering three cheerful empty states over a dead connection. */} {error ? ( ) : ( <>
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) await refresh() toast.success(msg) }} /> {/* New / edit corpus */} setEditor(null)} onSaved={async (msg) => { setEditor(null) await refresh() toast.success(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 const id = pendingDeleteTenant try { await searchAdmin.deleteTenant(id) setPendingDeleteTenant(null) await refresh() toast.success(`Deleted tenant ${id}`) } catch (err) { setPendingDeleteTenant(null) toast.error(errorMessage(err, `delete tenant ${id}`)) } }} /> {/* 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) setPendingDeleteCorpus(null) await refresh() toast.success(`Deleted ${tenant}/${corpus}`) } catch (err) { setPendingDeleteCorpus(null) toast.error(errorMessage(err, `delete ${tenant}/${corpus}`)) } }} /> {/* 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() toast.success("Requested a restart of arcadia-search") } catch (err) { toast.error(errorMessage(err, "restart arcadia-search")) } }} />
) } // --- Tenants card -------------------------------------------------------- function TenantsCard({ tenants, loading, onRetry, onDelete, }: { tenants: TenantSummary[] loading: boolean onRetry: () => void onDelete: (id: string) => void }) { return (

Tenants

{/* The sidecar's own failure is rendered once at page level, so by the time we get here the load succeeded — this empty state is true. */} } >
    {tenants.map((t) => (
  • {t.id} {t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
  • ))}
) } // --- Corpora table ------------------------------------------------------- function CorporaCard({ corpora, loading, error, onRetry, rebuildingId, onRebuild, onEdit, onDelete, }: { corpora: Row[] loading: boolean error: unknown onRetry: () => void 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}
} 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, }: { open: boolean onClose: () => void onCreated: (msg: string) => Promise }) { const [id, setId] = useState("") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (!open) { setId("") setError(null) } }, [open]) const submit = async () => { setError(null) setSaving(true) try { await searchAdmin.createTenant(id) await onCreated(`Created tenant ${id}`) } catch (err) { setError(err) } 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" />
{error ? : null}
) } 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, }: { editor: | { kind: "new-corpus"; tenant: string } | { kind: "edit-corpus"; tenant: string; corpus: string } | null tenants: TenantSummary[] onClose: () => void onSaved: (msg: string) => Promise }) { const [tenant, setTenant] = useState("") const [text, setText] = useState("") const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(false) // Covers both the hydrate-on-open failure and the save failure. Either way // the operator is looking at this dialog, so this is where it has to speak. const [error, setError] = useState(null) 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) setError(null) if (editor.kind === "edit-corpus") { setLoading(true) searchAdmin .getCorpus(editor.tenant, editor.corpus) .then((res) => { setText(JSON.stringify(res.config, null, 2)) }) .catch((err) => setError(err)) .finally(() => setLoading(false)) } else { setText(CORPUS_CONFIG_TEMPLATE) } }, [editor]) if (!editor) return null const submit = async () => { setError(null) 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) { // The JSON the operator just wrote stays in the textarea. setError(err) } 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}