From the 2026-07-14 UI/UX audit (17/40). Four phases: P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never imported). Added imports + a shared route-level error boundary (components/route-error.tsx) re-exported from all shell routes, so one crashing panel degrades to an explained card with the nav intact instead of replacing the whole app with a stack trace. Corrected the stale CLAUDE.md claim that `npm run typecheck` crashes — it works, and would have caught the missing import. P2 — Error/empty/feedback foundations. Fixed useSession identity churn that fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially stable snapshot). New lib/errors.ts (describeError → plain-language + the fix) and components/data-state.tsx (DataState renders exactly one of error/loading/empty/content, so a failed load never shows as "empty"; DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15 list routes; every mutation now toasts. Surfaced+fixed two silent-failure bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules on save). P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent sections; collapsed the Apps/Plan/Entitlements stub triplication into one honest /billing page; deleted fabricated seeded notifications and the dead Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't exist); removed orphan /assistant + /library routes; gated dev-seed login hints behind DEV; renamed /activity → /audit-log with a redirect. P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*), closing the provision→configure gap. 8 tabs (Overview, Plan & quotas, Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound webhooks), each verified saving to the real backend. Row name links to detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
920 lines
27 KiB
TypeScript
920 lines
27 KiB
TypeScript
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<TenantSummary[]>([])
|
|
const [corpora, setCorpora] = useState<Row[]>([])
|
|
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<unknown>(null)
|
|
const [corporaError, setCorporaError] = useState<unknown>(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 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 (
|
|
<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>
|
|
|
|
{/* 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 ? (
|
|
<div
|
|
role="note"
|
|
className="rounded-md border bg-muted/40 px-3 py-2.5 text-sm"
|
|
data-action="search-token-missing"
|
|
>
|
|
<p className="font-medium">Admin token not configured</p>
|
|
<p className="text-muted-foreground">
|
|
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset, so every call below will
|
|
come back 401. Endpoint:{" "}
|
|
<code className="font-mono">{searchAdmin.baseUrl}</code>
|
|
</p>
|
|
</div>
|
|
) : 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 ? (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<ErrorState error={error} onRetry={refresh} />
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<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}
|
|
loading={loading}
|
|
onRetry={refresh}
|
|
onDelete={(id) => setPendingDeleteTenant(id)}
|
|
/>
|
|
|
|
<CorporaCard
|
|
corpora={corpora}
|
|
loading={loading}
|
|
error={corporaError}
|
|
onRetry={refresh}
|
|
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)
|
|
await refresh()
|
|
toast.success(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)
|
|
await refresh()
|
|
toast.success(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
|
|
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 */}
|
|
<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)
|
|
setPendingDeleteCorpus(null)
|
|
await refresh()
|
|
toast.success(`Deleted ${tenant}/${corpus}`)
|
|
} catch (err) {
|
|
setPendingDeleteCorpus(null)
|
|
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* 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()
|
|
toast.success("Requested a restart of arcadia-search")
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, "restart arcadia-search"))
|
|
}
|
|
}}
|
|
/>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
// --- Tenants card --------------------------------------------------------
|
|
|
|
function TenantsCard({
|
|
tenants,
|
|
loading,
|
|
onRetry,
|
|
onDelete,
|
|
}: {
|
|
tenants: TenantSummary[]
|
|
loading: boolean
|
|
onRetry: () => void
|
|
onDelete: (id: string) => void
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<h2 className="text-base font-semibold">Tenants</h2>
|
|
</CardHeader>
|
|
<CardContent className="relative p-4">
|
|
{/* 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. */}
|
|
<DataState
|
|
loading={loading}
|
|
error={null}
|
|
isEmpty={tenants.length === 0}
|
|
onRetry={onRetry}
|
|
loadingLabel="Loading tenants…"
|
|
empty={
|
|
<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>
|
|
</DataState>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// --- 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<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">
|
|
<DataState
|
|
loading={loading}
|
|
error={error}
|
|
isEmpty={table.total === 0}
|
|
onRetry={onRetry}
|
|
loadingLabel="Loading corpora…"
|
|
empty={
|
|
<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}
|
|
/>
|
|
</DataState>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// --- Dialogs -------------------------------------------------------------
|
|
|
|
function NewTenantDialog({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onCreated: (msg: string) => Promise<void>
|
|
}) {
|
|
const [id, setId] = useState("")
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<unknown>(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 (
|
|
<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>
|
|
|
|
{error ? <DialogError error={error} context="create the tenant" /> : null}
|
|
|
|
<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,
|
|
}: {
|
|
editor:
|
|
| { kind: "new-corpus"; tenant: string }
|
|
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
|
| null
|
|
tenants: TenantSummary[]
|
|
onClose: () => void
|
|
onSaved: (msg: string) => Promise<void>
|
|
}) {
|
|
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<unknown>(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 (
|
|
<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>
|
|
|
|
{error ? (
|
|
<DialogError
|
|
error={error}
|
|
context={isEdit ? "save the corpus" : "create the corpus"}
|
|
/>
|
|
) : null}
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|