Prechádzať zdrojové kódy

feat(webui): index management panel with duplicate reporting

Add listIndexes/createIndex/dropIndex/indexValues to the API client, extend
the client-side ApiError with a details field populated from
error.details in failed responses, and mount an IndexPanel in the
collection detail drawer. The panel lets an admin create/drop per-field
indexes, browse indexed values inline (treating an empty result as
"not indexed", not an error, per the backend contract), and surfaces the
409 duplicate_examples document ids when a unique index creation fails.
fszontagh 1 mesiac pred
rodič
commit
36b7bdc447

+ 15 - 2
webui/src/api/client.ts

@@ -1,7 +1,7 @@
 import type {
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
-  KeyScope,
+  KeyScope, IndexDef, IndexValue, CreateIndexResult,
 } from '@/types'
 import { ApiError } from '@/types'
 import { serverUrl } from '@/api/base'
@@ -23,7 +23,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
   }
   const text = await res.text()
   const body = text ? JSON.parse(text) : {}
-  if (!res.ok) throw new ApiError(res.status, body?.error?.message || res.statusText)
+  if (!res.ok) throw new ApiError(res.status, body?.error?.message || res.statusText, body?.error?.details)
   return body as T
 }
 
@@ -62,6 +62,19 @@ export const api = {
   deleteCollection: (project: string, name: string) =>
     request<{ deleted: string }>(`${P(project)}/collections/${enc(name)}`, { method: 'DELETE' }),
 
+  // indexes
+  listIndexes: (project: string, coll: string) =>
+    request<{ indexes: IndexDef[] }>(`${P(project)}/collections/${enc(coll)}/indexes`),
+  createIndex: (project: string, coll: string, field: string, unique: boolean) =>
+    request<CreateIndexResult>(`${P(project)}/collections/${enc(coll)}/indexes`,
+      { method: 'POST', body: JSON.stringify({ field, unique }) }),
+  dropIndex: (project: string, coll: string, field: string) =>
+    request<{ dropped: string }>(`${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}`,
+      { method: 'DELETE' }),
+  indexValues: (project: string, coll: string, field: string, limit = 20, order?: 'asc' | 'desc') =>
+    request<{ values: IndexValue[] }>(
+      `${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}/values?limit=${limit}${order ? `&order=${order}` : ''}`),
+
   // documents
   findDocuments: (project: string, coll: string, qs = '') =>
     request<FindResult>(`${P(project)}/collections/${enc(coll)}/documents${qs}`),

+ 225 - 0
webui/src/components/IndexPanel.tsx

@@ -0,0 +1,225 @@
+import { Fragment, useState } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2, ChevronDown, ChevronRight, Eye } from 'lucide-react'
+import { api } from '@/api/client'
+import { ApiError } from '@/types'
+import { useToastStore } from '@/stores/toastStore'
+
+interface IndexPanelProps {
+  project: string
+  collection: string
+}
+
+// ─── values sub-panel (expand-to-load) ─────────────────────────────────────────
+
+function IndexValues({ project, collection, field }: { project: string; collection: string; field: string }) {
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['index-values', project, collection, field],
+    queryFn: () => api.indexValues(project, collection, field, 20),
+  })
+
+  if (isLoading) return <p className="text-xs text-slate-500 py-2">Loading values…</p>
+  if (isError) return <p className="text-xs text-red-400 py-2">Failed to load values.</p>
+
+  const values = data?.values ?? []
+  if (values.length === 0) {
+    return (
+      <p className="text-xs text-slate-500 py-2">
+        No values to show (field is not indexed, or holds array values).
+      </p>
+    )
+  }
+
+  return (
+    <ul className="divide-y divide-slate-700/30 py-1">
+      {values.map((v, i) => (
+        <li key={i} className="flex items-center justify-between px-2 py-1 text-xs">
+          <span className="font-mono text-slate-300 truncate">{JSON.stringify(v.value)}</span>
+          <span className="text-slate-500">{v.count.toLocaleString()}</span>
+        </li>
+      ))}
+    </ul>
+  )
+}
+
+// ─── panel ──────────────────────────────────────────────────────────────────────
+
+export function IndexPanel({ project, collection }: IndexPanelProps) {
+  const qc = useQueryClient()
+  const push = useToastStore((s) => s.push)
+
+  const [field, setField] = useState('')
+  const [unique, setUnique] = useState(false)
+  const [duplicates, setDuplicates] = useState<string[]>([])
+  const [expanded, setExpanded] = useState<string | null>(null)
+
+  const key = ['indexes', project, collection]
+  const { data, isLoading } = useQuery({
+    queryKey: key,
+    queryFn: () => api.listIndexes(project, collection),
+  })
+
+  const create = useMutation({
+    mutationFn: () => api.createIndex(project, collection, field.trim(), unique),
+    onSuccess: (res) => {
+      setField('')
+      setUnique(false)
+      setDuplicates([])
+      void qc.invalidateQueries({ queryKey: key })
+      push(
+        res.already_existed
+          ? `Index on "${res.field}" already existed.`
+          : `Index created on "${res.field}" (${res.rows_indexed.toLocaleString()} rows indexed).`,
+        'success',
+      )
+    },
+    onError: (err: unknown) => {
+      if (err instanceof ApiError && err.status === 409) {
+        const det = err.details
+        const examples =
+          det && typeof det === 'object' && 'duplicate_examples' in det
+            ? (det as { duplicate_examples: unknown }).duplicate_examples
+            : undefined
+        setDuplicates(Array.isArray(examples) ? examples.map((v) => String(v)) : [])
+        return
+      }
+      setDuplicates([])
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to create index: ${msg}`, 'error')
+    },
+  })
+
+  const drop = useMutation({
+    mutationFn: (f: string) => api.dropIndex(project, collection, f),
+    onSuccess: (res) => {
+      void qc.invalidateQueries({ queryKey: key })
+      if (expanded === res.dropped) setExpanded(null)
+      push(`Index on "${res.dropped}" dropped.`, 'success')
+    },
+    onError: (err: unknown) => {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to drop index: ${msg}`, 'error')
+    },
+  })
+
+  const indexes = data?.indexes ?? []
+
+  return (
+    <div className="rounded-lg border border-slate-700/40 bg-slate-800/30 p-4">
+      <h3 className="text-sm font-semibold text-slate-200 mb-3">Indexes</h3>
+
+      {isLoading ? (
+        <p className="text-sm text-slate-500">Loading…</p>
+      ) : (
+        <div className="rounded-lg border border-slate-700/40 overflow-hidden mb-4">
+          <table className="w-full text-sm">
+            <thead>
+              <tr className="border-b border-slate-700/40 bg-slate-800/60 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">
+                <th className="px-3 py-2" />
+                <th className="px-3 py-2">Field</th>
+                <th className="px-3 py-2">Unique</th>
+                <th className="px-3 py-2">Distinct</th>
+                <th className="px-3 py-2">Entries</th>
+                <th className="px-3 py-2 text-right">Actions</th>
+              </tr>
+            </thead>
+            <tbody>
+              {indexes.map((idx) => (
+                <Fragment key={idx.field}>
+                  <tr className="border-t border-slate-700/20">
+                    <td className="px-3 py-2">
+                      <button
+                        onClick={() => setExpanded((e) => (e === idx.field ? null : idx.field))}
+                        className="text-slate-500 hover:text-slate-300"
+                        aria-label={`Toggle values for ${idx.field}`}
+                      >
+                        {expanded === idx.field ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+                      </button>
+                    </td>
+                    <td className="px-3 py-2 font-mono text-slate-200 text-xs">{idx.field}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.unique ? 'yes' : 'no'}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.distinct_values.toLocaleString()}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.entries.toLocaleString()}</td>
+                    <td className="px-3 py-2 text-right">
+                      <div className="flex items-center justify-end gap-1">
+                        <button
+                          onClick={() => setExpanded((e) => (e === idx.field ? null : idx.field))}
+                          className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition"
+                        >
+                          <Eye size={12} />
+                          Values
+                        </button>
+                        <button
+                          onClick={() => drop.mutate(idx.field)}
+                          disabled={drop.isPending}
+                          className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition disabled:opacity-50"
+                        >
+                          <Trash2 size={12} />
+                          Drop
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                  {expanded === idx.field && (
+                    <tr className="border-t border-slate-700/10 bg-slate-900/30">
+                      <td />
+                      <td colSpan={5} className="px-3">
+                        <IndexValues project={project} collection={collection} field={idx.field} />
+                      </td>
+                    </tr>
+                  )}
+                </Fragment>
+              ))}
+              {indexes.length === 0 && (
+                <tr>
+                  <td colSpan={6} className="px-3 py-4 text-center text-slate-500">
+                    No indexes declared.
+                  </td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      <div className="flex items-center gap-2 flex-wrap">
+        <input
+          value={field}
+          onChange={(e) => setField(e.target.value)}
+          placeholder="field name"
+          className="rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-1.5 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+        />
+        <label className="flex items-center gap-1.5 text-sm text-slate-300">
+          <input
+            type="checkbox"
+            checked={unique}
+            onChange={(e) => setUnique(e.target.checked)}
+            className="accent-indigo-500"
+          />
+          unique
+        </label>
+        <button
+          disabled={!field.trim() || create.isPending}
+          onClick={() => create.mutate()}
+          className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
+        >
+          <Plus size={14} />
+          Create index
+        </button>
+      </div>
+
+      {duplicates.length > 0 && (
+        <div className="mt-3 rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm">
+          <p className="text-red-400">
+            Cannot make this field unique - these documents hold duplicate values:
+          </p>
+          <ul className="mt-1.5 list-disc pl-5 font-mono text-xs text-red-300 space-y-0.5">
+            {duplicates.map((id) => (
+              <li key={id}>{id}</li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </div>
+  )
+}

+ 4 - 0
webui/src/pages/Collections.tsx

@@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores/authStore'
 import { useToastStore } from '@/stores/toastStore'
 import { Modal } from '@/components/Modal'
 import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { IndexPanel } from '@/components/IndexPanel'
 import { ApiError } from '@/types'
 import type { CollectionMeta } from '@/types'
 
@@ -332,6 +333,9 @@ function CollectionDetailDrawer({ project, name, onClose }: DetailDrawerProps) {
             <DetailRow label="Created" value={formatDate(data.created_at)} />
           </dl>
 
+          {/* Indexes */}
+          <IndexPanel project={project} collection={data.name} />
+
           {/* Quick actions */}
           <div className="flex items-center justify-end gap-3 pt-1">
             {data.kind === 'vector' && (

+ 19 - 1
webui/src/types/index.ts

@@ -47,9 +47,27 @@ export interface SettingsView {
 }
 export class ApiError extends Error {
   status: number
-  constructor(status: number, message: string) {
+  details?: unknown
+  constructor(status: number, message: string, details?: unknown) {
     super(message)
     this.name = 'ApiError'
     this.status = status
+    this.details = details
   }
 }
+
+export type IndexDef = {
+  field: string
+  distinct_values: number
+  entries: number
+  unique: boolean
+}
+
+export type IndexValue = { value: unknown; count: number }
+
+export type CreateIndexResult = {
+  field: string
+  unique: boolean
+  rows_indexed: number
+  already_existed: boolean
+}