From eb7bc62d1423a7fd544134ecaa7848a3699a1155 Mon Sep 17 00:00:00 2001 From: jules Date: Mon, 4 May 2026 16:26:34 +1000 Subject: [PATCH] search: add Search section calling arcadia-search admin sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 1 + app/components/layout/app-shell.tsx | 2 + app/lib/search-admin.ts | 113 ++++ app/routes.ts | 1 + app/routes/search.tsx | 874 ++++++++++++++++++++++++++++ 5 files changed, 991 insertions(+) create mode 100644 app/lib/search-admin.ts create mode 100644 app/routes/search.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 077989c..87d12bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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(path)` is the generic escape hatch for spec-incomplete endpoints. - **Login** — `app/routes/login.tsx` renders `` 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. +- **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 diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index efed172..6eaa7f0 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -31,6 +31,7 @@ import { ShieldCheck, Megaphone, AlertOctagon, + SearchCode, // CREMA:NAV-ICONS } from "lucide-react" @@ -112,6 +113,7 @@ const navItems: NavItem[] = [ { to: "/announcements", icon: Megaphone, label: "Announcements" }, { to: "/status-page", icon: AlertOctagon, label: "Status page" }, { to: "/activity", icon: Activity, label: "Audit log" }, + { to: "/search", icon: SearchCode, label: "Search" }, { to: "/ai", icon: Bot, label: "AI" }, { to: "/settings", icon: Settings, label: "Settings" }, // CREMA:NAV-ITEMS diff --git a/app/lib/search-admin.ts b/app/lib/search-admin.ts new file mode 100644 index 0000000..74edec0 --- /dev/null +++ b/app/lib/search-admin.ts @@ -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( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + body?: unknown, +): Promise { + const headers: Record = {} + 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 + 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("POST", "/admin/tenants", { id }), + deleteTenant: (id: string) => + call("DELETE", `/admin/tenants/${encodeURIComponent(id)}`), + listCorpora: (tenant: string) => + call<{ corpora: CorpusSummary[] }>( + "GET", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora`, + ), + createCorpus: (tenant: string, body: Record) => + call( + "POST", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora`, + body, + ), + getCorpus: (tenant: string, corpus: string) => + call( + "GET", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`, + ), + updateCorpus: ( + tenant: string, + corpus: string, + body: Record, + ) => + call( + "PUT", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`, + body, + ), + deleteCorpus: (tenant: string, corpus: string) => + call( + "DELETE", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`, + ), + rebuild: (tenant: string, corpus: string) => + call( + "POST", + `/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}/rebuild`, + ), + restart: () => call("POST", "/admin/restart"), +} diff --git a/app/routes.ts b/app/routes.ts index eecf4cc..66e4f16 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -26,5 +26,6 @@ export default [ route("sso", "routes/sso.tsx"), route("announcements", "routes/announcements.tsx"), route("status-page", "routes/status-page.tsx"), + route("search", "routes/search.tsx"), // CREMA:ROUTES ] satisfies RouteConfig diff --git a/app/routes/search.tsx b/app/routes/search.tsx new file mode 100644 index 0000000..ff44c45 --- /dev/null +++ b/app/routes/search.tsx @@ -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([]) + const [corpora, setCorpora] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [info, setInfo] = useState(null) + const [editor, setEditor] = useState(null) + const [pendingDeleteTenant, setPendingDeleteTenant] = useState( + null, + ) + const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{ + tenant: string + corpus: string + } | null>(null) + const [restartConfirm, setRestartConfirm] = useState(false) + const [rebuilding, setRebuilding] = useState(null) + + const 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 ( + +
+
+
+

Search

+

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

+
+
+ + + + +
+
+ + {!searchAdmin.hasToken ? ( + + VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will + return 401 until the bearer token is configured. Endpoint:{" "} + {searchAdmin.baseUrl} + + ) : null} + {error ? ( + setError(null)} + > + {error} + + ) : null} + {info ? ( + setInfo(null)} + > + {info} + + ) : null} + + + +
+ + + +
+
+
+ + setPendingDeleteTenant(id)} + /> + + setEditor({ kind: "edit-corpus", tenant: t, corpus: c })} + onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })} + /> +
+ + {/* New tenant */} + setEditor(null)} + onCreated={async (msg) => { + setEditor(null) + if (msg) setInfo(msg) + await refresh() + }} + onError={(msg) => setError(msg)} + /> + + {/* New / edit corpus */} + setEditor(null)} + onSaved={async (msg) => { + setEditor(null) + if (msg) setInfo(msg) + await refresh() + }} + onError={(msg) => setError(msg)} + /> + + {/* Delete tenant */} + !o && setPendingDeleteTenant(null)} + title={`Delete tenant ${pendingDeleteTenant ?? ""}?`} + description="Removes the tenant's config directory AND its entire index directory. This cannot be undone." + confirmLabel="Delete tenant" + variant="danger" + onConfirm={async () => { + if (!pendingDeleteTenant) return + try { + await searchAdmin.deleteTenant(pendingDeleteTenant) + setInfo(`Tenant ${pendingDeleteTenant} deleted.`) + setPendingDeleteTenant(null) + await refresh() + } catch (err) { + reportError(err, "Delete failed.") + setPendingDeleteTenant(null) + } + }} + /> + + {/* Delete corpus */} + !o && setPendingDeleteCorpus(null)} + title={ + pendingDeleteCorpus + ? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?` + : "" + } + description="Removes the corpus config and its index directory. The tenant is preserved." + confirmLabel="Delete corpus" + variant="danger" + onConfirm={async () => { + if (!pendingDeleteCorpus) return + const { tenant, corpus } = pendingDeleteCorpus + try { + await searchAdmin.deleteCorpus(tenant, corpus) + setInfo(`Deleted ${tenant}/${corpus}.`) + setPendingDeleteCorpus(null) + await refresh() + } catch (err) { + reportError(err, "Delete failed.") + setPendingDeleteCorpus(null) + } + }} + /> + + {/* Restart confirm */} + !o && setRestartConfirm(false)} + title="Restart arcadia-search admin?" + description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted." + confirmLabel="Restart" + variant="danger" + onConfirm={async () => { + setRestartConfirm(false) + try { + await searchAdmin.restart() + setInfo("Restart requested.") + } catch (err) { + reportError(err, "Restart request failed.") + } + }} + /> +
+ ) +} + +// --- Tenants card -------------------------------------------------------- + +function TenantsCard({ + tenants, + onDelete, +}: { + tenants: TenantSummary[] + onDelete: (id: string) => void +}) { + return ( + + +

Tenants

+
+ + {tenants.length === 0 ? ( + + ) : ( +
    + {tenants.map((t) => ( +
  • + {t.id} + + {t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"} + + +
  • + ))} +
+ )} +
+
+ ) +} + +// --- 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[]>( + () => [ + { + id: "tenant", + header: "Tenant", + accessor: "tenant", + sortable: true, + cell: (r) => ( + + {r.tenant} + + ), + }, + { + id: "corpus", + header: "Corpus", + accessor: "corpus", + sortable: true, + cell: (r) => ( + + + {r.corpus} + + ), + }, + { + id: "indexed", + header: "Status", + sortable: true, + accessor: (r) => (r.indexed ? 1 : 0), + cell: (r) => + r.indexed ? ( + + Indexed + + ) : ( + + Not built + + ), + }, + { + id: "docs", + header: "Docs", + sortable: true, + accessor: (r) => r.num_docs ?? -1, + cell: (r) => + r.num_docs != null ? ( + + {r.num_docs.toLocaleString()} + + ) : ( + + ), + }, + { + id: "actions", + header: "", + align: "right", + cell: (r) => { + const id = `${r.tenant}/${r.corpus}` + const isRebuilding = rebuildingId === id + const items: ActionItem[] = [ + { + id: "rebuild", + label: isRebuilding ? "Rebuilding…" : "Rebuild", + icon: ( + + ), + dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`, + onSelect: () => + isRebuilding ? undefined : onRebuild(r.tenant, r.corpus), + }, + { + id: "edit", + label: "Edit config", + icon: , + dataAction: `corpus-${r.tenant}-${r.corpus}-edit`, + onSelect: () => onEdit(r.tenant, r.corpus), + }, + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + dataAction: `corpus-${r.tenant}-${r.corpus}-delete`, + onSelect: () => onDelete(r.tenant, r.corpus), + }, + ] + return ( + + ) + }, + }, + ], + [rebuildingId, onRebuild, onEdit, onDelete], + ) + + const table = useTable({ + data: corpora, + columns, + getRowId: (r) => r.rowId, + initialPageSize: 25, + initialSearch: search, + }) + useEffect(() => { + table.setSearch(search) + }, [search, table]) + + return ( + + + +
+ {table.total} of {corpora.length} +
+
+ + + {table.total === 0 && !loading ? ( + } + title={search ? "No matches." : "No corpora yet."} + description={ + search ? "Try a different search." : "Create one above." + } + className="py-12" + /> + ) : ( + <> + r.rowId} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && corpora.length > 0} + stickyHeader + /> + + + )} + +
+ ) +} + +// --- Dialogs ------------------------------------------------------------- + +function NewTenantDialog({ + open, + onClose, + onCreated, + onError, +}: { + open: boolean + onClose: () => void + onCreated: (msg?: string) => Promise + 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 ( + !o && onClose()}> + + + New tenant + + Creates an empty config dir at{" "} + + $INDEX_CONFIG_DIR/<id>/ + + . Add corpora separately. Names are alphanumeric, dash, or + underscore. + + +
+ + setId(e.target.value)} + placeholder="acme" + data-action="tenant-form-id" + /> +
+ + + + +
+
+ ) +} + +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 + 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 ( + !o && onClose()}> + + + + {isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"} + + + JSON config matching arcadia-search's IndexerConfig schema. The{" "} + tenant field is set + from the URL — your value is overwritten. + + + +
+ {!isEdit ? ( +
+ + +
+ ) : null} + +
+ +