|
@@ -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>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|