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:
jules
2026-05-04 16:26:34 +10:00
parent 20c592dfa7
commit eb7bc62d14
5 changed files with 991 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ This file is a quick map, not a duplication of upstream docs.
- **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints. - **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints.
- **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`. - **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`.
- **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in. - **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in.
- **Search admin sidecar** — the `/search` route (`app/routes/search.tsx`) calls arcadia-search's privileged `/admin/*` surface (default `127.0.0.1:7801`) via `app/lib/search-admin.ts`. Configured by `VITE_ARCADIA_SEARCH_ADMIN_URL` + `VITE_ARCADIA_SEARCH_ADMIN_TOKEN`; the token must match `ADMIN_TOKEN` on the search box. See `arcadia-search/README.md` § *Admin sidecar*.
## Scripts ## Scripts

View File

@@ -31,6 +31,7 @@ import {
ShieldCheck, ShieldCheck,
Megaphone, Megaphone,
AlertOctagon, AlertOctagon,
SearchCode,
// CREMA:NAV-ICONS // CREMA:NAV-ICONS
} from "lucide-react" } from "lucide-react"
@@ -112,6 +113,7 @@ const navItems: NavItem[] = [
{ to: "/announcements", icon: Megaphone, label: "Announcements" }, { to: "/announcements", icon: Megaphone, label: "Announcements" },
{ to: "/status-page", icon: AlertOctagon, label: "Status page" }, { to: "/status-page", icon: AlertOctagon, label: "Status page" },
{ to: "/activity", icon: Activity, label: "Audit log" }, { to: "/activity", icon: Activity, label: "Audit log" },
{ to: "/search", icon: SearchCode, label: "Search" },
{ to: "/ai", icon: Bot, label: "AI" }, { to: "/ai", icon: Bot, label: "AI" },
{ to: "/settings", icon: Settings, label: "Settings" }, { to: "/settings", icon: Settings, label: "Settings" },
// CREMA:NAV-ITEMS // CREMA:NAV-ITEMS

113
app/lib/search-admin.ts Normal file
View 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"),
}

View File

@@ -26,5 +26,6 @@ export default [
route("sso", "routes/sso.tsx"), route("sso", "routes/sso.tsx"),
route("announcements", "routes/announcements.tsx"), route("announcements", "routes/announcements.tsx"),
route("status-page", "routes/status-page.tsx"), route("status-page", "routes/status-page.tsx"),
route("search", "routes/search.tsx"),
// CREMA:ROUTES // CREMA:ROUTES
] satisfies RouteConfig ] satisfies RouteConfig

874
app/routes/search.tsx Normal file
View 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/&lt;id&gt;/
</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>
)
}