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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
113
app/lib/search-admin.ts
Normal 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"),
|
||||||
|
}
|
||||||
@@ -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
874
app/routes/search.tsx
Normal 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/<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>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user