Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
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>
This commit is contained in:
@@ -18,15 +18,13 @@ import {
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import {
|
||||
AlertBanner,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingOverlay,
|
||||
} from "@crema/feedback-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 {
|
||||
@@ -54,7 +52,6 @@ import {
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import {
|
||||
searchAdmin,
|
||||
SearchAdminError,
|
||||
type CorpusSummary,
|
||||
type TenantSummary,
|
||||
} from "~/lib/search-admin"
|
||||
@@ -74,12 +71,16 @@ type EditorState =
|
||||
|
||||
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)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
// 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,
|
||||
@@ -91,44 +92,42 @@ export default function SearchRoute() {
|
||||
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)
|
||||
setCorporaError(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 []
|
||||
}
|
||||
}),
|
||||
|
||||
// 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 flat: Row[] = cByT.flat().map((c) => ({
|
||||
...c,
|
||||
rowId: `${c.tenant}/${c.corpus}`,
|
||||
}))
|
||||
setCorpora(flat)
|
||||
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) {
|
||||
reportError(err, "Failed to load search admin state.")
|
||||
setError(err)
|
||||
setTenants([])
|
||||
setCorpora([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [reportError])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
@@ -163,20 +162,19 @@ export default function SearchRoute() {
|
||||
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()
|
||||
toast.success(`Rebuilt ${id}`, {
|
||||
description: `${out.chunk_count} chunks indexed.`,
|
||||
})
|
||||
} catch (err) {
|
||||
reportError(err, "Rebuild failed.")
|
||||
toast.error(errorMessage(err, `rebuild ${id}`))
|
||||
} finally {
|
||||
setRebuilding(null)
|
||||
}
|
||||
},
|
||||
[refresh, reportError],
|
||||
[refresh, toast],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -238,61 +236,69 @@ export default function SearchRoute() {
|
||||
</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 ? (
|
||||
<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>
|
||||
<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 ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner
|
||||
variant="success"
|
||||
dismissible
|
||||
onDismiss={() => setInfo(null)}
|
||||
>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
<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 */}
|
||||
@@ -301,10 +307,9 @@ export default function SearchRoute() {
|
||||
onClose={() => setEditor(null)}
|
||||
onCreated={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
toast.success(msg)
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* New / edit corpus */}
|
||||
@@ -318,10 +323,9 @@ export default function SearchRoute() {
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
toast.success(msg)
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* Delete tenant */}
|
||||
@@ -334,14 +338,15 @@ export default function SearchRoute() {
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDeleteTenant) return
|
||||
const id = pendingDeleteTenant
|
||||
try {
|
||||
await searchAdmin.deleteTenant(pendingDeleteTenant)
|
||||
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
|
||||
await searchAdmin.deleteTenant(id)
|
||||
setPendingDeleteTenant(null)
|
||||
await refresh()
|
||||
toast.success(`Deleted tenant ${id}`)
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteTenant(null)
|
||||
toast.error(errorMessage(err, `delete tenant ${id}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -363,12 +368,12 @@ export default function SearchRoute() {
|
||||
const { tenant, corpus } = pendingDeleteCorpus
|
||||
try {
|
||||
await searchAdmin.deleteCorpus(tenant, corpus)
|
||||
setInfo(`Deleted ${tenant}/${corpus}.`)
|
||||
setPendingDeleteCorpus(null)
|
||||
await refresh()
|
||||
toast.success(`Deleted ${tenant}/${corpus}`)
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteCorpus(null)
|
||||
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -385,9 +390,9 @@ export default function SearchRoute() {
|
||||
setRestartConfirm(false)
|
||||
try {
|
||||
await searchAdmin.restart()
|
||||
setInfo("Restart requested.")
|
||||
toast.success("Requested a restart of arcadia-search")
|
||||
} catch (err) {
|
||||
reportError(err, "Restart request failed.")
|
||||
toast.error(errorMessage(err, "restart arcadia-search"))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -399,9 +404,13 @@ export default function SearchRoute() {
|
||||
|
||||
function TenantsCard({
|
||||
tenants,
|
||||
loading,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}: {
|
||||
tenants: TenantSummary[]
|
||||
loading: boolean
|
||||
onRetry: () => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -409,14 +418,23 @@ function TenantsCard({
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
@@ -439,7 +457,7 @@ function TenantsCard({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -450,6 +468,8 @@ function TenantsCard({
|
||||
function CorporaCard({
|
||||
corpora,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
rebuildingId,
|
||||
onRebuild,
|
||||
onEdit,
|
||||
@@ -457,6 +477,8 @@ function CorporaCard({
|
||||
}: {
|
||||
corpora: Row[]
|
||||
loading: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
rebuildingId: string | null
|
||||
onRebuild: (tenant: string, corpus: string) => void
|
||||
onEdit: (tenant: string, corpus: string) => void
|
||||
@@ -593,39 +615,40 @@ function CorporaCard({
|
||||
</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"
|
||||
<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
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -637,31 +660,30 @@ function NewTenantDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => 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("")
|
||||
if (!open) {
|
||||
setId("")
|
||||
setError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await searchAdmin.createTenant(id)
|
||||
await onCreated(`Tenant ${id} created.`)
|
||||
await onCreated(`Created tenant ${id}`)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Create failed.",
|
||||
)
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -691,6 +713,9 @@ function NewTenantDialog({
|
||||
data-action="tenant-form-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="create the tenant" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -738,7 +763,6 @@ function CorpusEditor({
|
||||
tenants,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
editor:
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
@@ -746,13 +770,15 @@ function CorpusEditor({
|
||||
| null
|
||||
tenants: TenantSummary[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => 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 : ""
|
||||
@@ -761,6 +787,7 @@ function CorpusEditor({
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setTenant(editor.tenant)
|
||||
setError(null)
|
||||
if (editor.kind === "edit-corpus") {
|
||||
setLoading(true)
|
||||
searchAdmin
|
||||
@@ -768,22 +795,17 @@ function CorpusEditor({
|
||||
.then((res) => {
|
||||
setText(JSON.stringify(res.config, null, 2))
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Load failed.",
|
||||
)
|
||||
})
|
||||
.catch((err) => setError(err))
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setText(CORPUS_CONFIG_TEMPLATE)
|
||||
}
|
||||
}, [editor, onError])
|
||||
}, [editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const submit = async () => {
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
@@ -796,19 +818,14 @@ function CorpusEditor({
|
||||
throw new Error('config must have a string "corpus" field')
|
||||
}
|
||||
await searchAdmin.createCorpus(tenant, parsed)
|
||||
await onSaved(`Created ${tenant}/${corpus}.`)
|
||||
await onSaved(`Created ${tenant}/${corpus}`)
|
||||
} else {
|
||||
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
|
||||
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
|
||||
await onSaved(`Updated ${editor.tenant}/${editor.corpus}`)
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
// The JSON the operator just wrote stays in the textarea.
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -865,6 +882,13 @@ function CorpusEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError
|
||||
error={error}
|
||||
context={isEdit ? "save the corpus" : "create the corpus"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -891,3 +915,5 @@ function CorpusEditor({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||
|
||||
Reference in New Issue
Block a user