Ver Fonte

feat(webui): collections management page

Add Modal and ConfirmDialog reusable components, replace the stub
Collections page with full CRUD: list table with kind badge / dimension /
embedding_model / doc count / size, New Collection modal form (json|vector
radio, vector_dimension + embedding_model when vector), per-row Delete via
ConfirmDialog, react-query invalidation and toast feedback.
Fszontagh há 2 meses atrás
pai
commit
44502f80d2

+ 42 - 0
webui/src/components/ConfirmDialog.tsx

@@ -0,0 +1,42 @@
+import { Modal } from './Modal'
+
+interface ConfirmDialogProps {
+  open: boolean
+  title: string
+  message: string
+  confirmLabel?: string
+  onConfirm: () => void
+  onClose: () => void
+}
+
+export function ConfirmDialog({
+  open,
+  title,
+  message,
+  confirmLabel = 'Confirm',
+  onConfirm,
+  onClose,
+}: ConfirmDialogProps) {
+  return (
+    <Modal open={open} title={title} onClose={onClose}>
+      <p className="text-slate-300 text-sm mb-6">{message}</p>
+      <div className="flex items-center justify-end gap-3">
+        <button
+          onClick={onClose}
+          className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition"
+        >
+          Cancel
+        </button>
+        <button
+          onClick={() => {
+            onConfirm()
+            onClose()
+          }}
+          className="px-4 py-2 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-500 transition"
+        >
+          {confirmLabel}
+        </button>
+      </div>
+    </Modal>
+  )
+}

+ 77 - 0
webui/src/components/Modal.tsx

@@ -0,0 +1,77 @@
+import { useEffect, useRef } from 'react'
+import { X } from 'lucide-react'
+
+interface ModalProps {
+  open: boolean
+  title: string
+  onClose: () => void
+  children: React.ReactNode
+}
+
+export function Modal({ open, title, onClose, children }: ModalProps) {
+  const dialogRef = useRef<HTMLDivElement>(null)
+
+  // Close on Escape key
+  useEffect(() => {
+    if (!open) return
+    const handler = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose()
+    }
+    document.addEventListener('keydown', handler)
+    return () => document.removeEventListener('keydown', handler)
+  }, [open, onClose])
+
+  // Trap focus inside modal when open
+  useEffect(() => {
+    if (open && dialogRef.current) {
+      dialogRef.current.focus()
+    }
+  }, [open])
+
+  if (!open) return null
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="modal-title"
+    >
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+        aria-hidden="true"
+      />
+
+      {/* Card */}
+      <div
+        ref={dialogRef}
+        tabIndex={-1}
+        className="relative z-10 w-full max-w-lg mx-4 rounded-xl bg-slate-900 border border-slate-700/60 shadow-2xl outline-none"
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-slate-700/50">
+          <h2
+            id="modal-title"
+            className="text-base font-semibold text-slate-100"
+          >
+            {title}
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-slate-400 hover:text-slate-200 transition rounded-md p-1 hover:bg-slate-700/50"
+            aria-label="Close"
+          >
+            <X size={18} />
+          </button>
+        </div>
+
+        {/* Body */}
+        <div className="px-6 py-5">
+          {children}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 396 - 3
webui/src/pages/Collections.tsx

@@ -1,8 +1,401 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2 } from 'lucide-react'
+import { api } from '@/api/client'
+import { useAuthStore } from '@/stores/authStore'
+import { useToastStore } from '@/stores/toastStore'
+import { Modal } from '@/components/Modal'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { ApiError } from '@/types'
+import type { CollectionMeta } from '@/types'
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function humanizeBytes(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
+}
+
+// ─── new collection form ──────────────────────────────────────────────────────
+
+interface NewCollectionFormProps {
+  project: string
+  onSuccess: () => void
+  onCancel: () => void
+}
+
+type CollectionKind = 'json' | 'vector'
+
+function NewCollectionForm({ project, onSuccess, onCancel }: NewCollectionFormProps) {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+
+  const [name, setName] = useState('')
+  const [kind, setKind] = useState<CollectionKind>('json')
+  const [vectorDimension, setVectorDimension] = useState('')
+  const [embeddingModel, setEmbeddingModel] = useState('')
+  const [submitting, setSubmitting] = useState(false)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!name.trim()) return
+
+    if (kind === 'vector') {
+      const dim = parseInt(vectorDimension, 10)
+      if (!vectorDimension || isNaN(dim) || dim <= 0) {
+        push('Vector dimension must be a positive integer.', 'error')
+        return
+      }
+    }
+
+    setSubmitting(true)
+    try {
+      const body: {
+        name: string
+        kind: CollectionKind
+        vector_dimension?: number
+        embedding_model?: string
+      } = { name: name.trim(), kind }
+
+      if (kind === 'vector') {
+        body.vector_dimension = parseInt(vectorDimension, 10)
+        if (embeddingModel.trim()) {
+          body.embedding_model = embeddingModel.trim()
+        }
+      }
+
+      await api.createCollection(project, body)
+      await queryClient.invalidateQueries({ queryKey: ['collections', project] })
+      push(`Collection "${name.trim()}" created.`, 'success')
+      onSuccess()
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to create collection: ${msg}`, 'error')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
+      {/* Name */}
+      <div>
+        <label className="block text-xs font-medium text-slate-400 mb-1">
+          Name <span className="text-red-400">*</span>
+        </label>
+        <input
+          type="text"
+          value={name}
+          onChange={(e) => setName(e.target.value)}
+          required
+          placeholder="my_collection"
+          className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+        />
+      </div>
+
+      {/* Kind */}
+      <div>
+        <label className="block text-xs font-medium text-slate-400 mb-2">
+          Kind <span className="text-red-400">*</span>
+        </label>
+        <div className="flex gap-4">
+          {(['json', 'vector'] as CollectionKind[]).map((k) => (
+            <label key={k} className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="radio"
+                name="kind"
+                value={k}
+                checked={kind === k}
+                onChange={() => setKind(k)}
+                className="accent-indigo-500"
+              />
+              <span className="text-sm text-slate-300 capitalize">{k}</span>
+            </label>
+          ))}
+        </div>
+      </div>
+
+      {/* Vector-only fields */}
+      {kind === 'vector' && (
+        <>
+          <div>
+            <label className="block text-xs font-medium text-slate-400 mb-1">
+              Vector Dimension <span className="text-red-400">*</span>
+            </label>
+            <input
+              type="number"
+              value={vectorDimension}
+              onChange={(e) => setVectorDimension(e.target.value)}
+              required
+              min={1}
+              placeholder="1536"
+              className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+            />
+          </div>
+
+          <div>
+            <label className="block text-xs font-medium text-slate-400 mb-1">
+              Embedding Model <span className="text-slate-500">(optional)</span>
+            </label>
+            <input
+              type="text"
+              value={embeddingModel}
+              onChange={(e) => setEmbeddingModel(e.target.value)}
+              placeholder="text-embedding-3-small"
+              className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+            />
+          </div>
+        </>
+      )}
+
+      {/* Actions */}
+      <div className="flex items-center justify-end gap-3 pt-1">
+        <button
+          type="button"
+          onClick={onCancel}
+          disabled={submitting}
+          className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
+        >
+          Cancel
+        </button>
+        <button
+          type="submit"
+          disabled={submitting}
+          className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
+        >
+          {submitting ? 'Creating…' : 'Create'}
+        </button>
+      </div>
+    </form>
+  )
+}
+
+// ─── kind badge ───────────────────────────────────────────────────────────────
+
+function KindBadge({ kind }: { kind: CollectionMeta['kind'] }) {
+  const styles =
+    kind === 'vector'
+      ? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/30'
+      : 'bg-slate-600/30 text-slate-300 border border-slate-600/30'
+  return (
+    <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${styles}`}>
+      {kind}
+    </span>
+  )
+}
+
+// ─── collections table ────────────────────────────────────────────────────────
+
+interface CollectionsTableProps {
+  collections: CollectionMeta[]
+  project: string
+}
+
+function CollectionsTable({ collections, project }: CollectionsTableProps) {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+
+  const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
+
+  const handleDelete = async (name: string) => {
+    try {
+      await api.deleteCollection(project, name)
+      await queryClient.invalidateQueries({ queryKey: ['collections', project] })
+      push(`Collection "${name}" deleted.`, 'success')
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to delete collection: ${msg}`, 'error')
+    }
+  }
+
+  if (collections.length === 0) {
+    return (
+      <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center">
+        <p className="text-slate-400 text-sm">No collections yet. Create one to get started.</p>
+      </div>
+    )
+  }
+
+  return (
+    <>
+      <div className="rounded-xl border border-slate-700/40 overflow-hidden">
+        <table className="w-full text-sm">
+          <thead>
+            <tr className="border-b border-slate-700/40 bg-slate-800/60">
+              <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Name</th>
+              <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Kind</th>
+              <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Dimension</th>
+              <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Embedding Model</th>
+              <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Documents</th>
+              <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
+              <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
+            </tr>
+          </thead>
+          <tbody>
+            {collections.map((col, idx) => (
+              <tr
+                key={col.name}
+                className={[
+                  'border-b border-slate-700/20 transition hover:bg-slate-800/40',
+                  idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
+                ].join(' ')}
+              >
+                <td className="px-4 py-3 font-mono text-slate-200 text-xs">{col.name}</td>
+                <td className="px-4 py-3">
+                  <KindBadge kind={col.kind} />
+                </td>
+                <td className="px-4 py-3 text-slate-300">
+                  {col.kind === 'vector' ? col.vector_dimension : <span className="text-slate-600">—</span>}
+                </td>
+                <td className="px-4 py-3 text-slate-300 text-xs font-mono">
+                  {col.embedding_model || <span className="text-slate-600">—</span>}
+                </td>
+                <td className="px-4 py-3 text-right text-slate-300">
+                  {col.document_count !== undefined ? col.document_count.toLocaleString() : <span className="text-slate-600">—</span>}
+                </td>
+                <td className="px-4 py-3 text-right text-slate-300">
+                  {col.size_bytes !== undefined ? humanizeBytes(col.size_bytes) : <span className="text-slate-600">—</span>}
+                </td>
+                <td className="px-4 py-3 text-right">
+                  <button
+                    onClick={() => setDeleteTarget(col.name)}
+                    className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
+                    aria-label={`Delete ${col.name}`}
+                  >
+                    <Trash2 size={13} />
+                    Delete
+                  </button>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      <ConfirmDialog
+        open={deleteTarget !== null}
+        title="Delete Collection"
+        message={`Are you sure you want to delete "${deleteTarget ?? ''}"? This will permanently remove all documents in this collection.`}
+        confirmLabel="Delete"
+        onConfirm={() => {
+          if (deleteTarget) handleDelete(deleteTarget)
+        }}
+        onClose={() => setDeleteTarget(null)}
+      />
+    </>
+  )
+}
+
+// ─── skeleton ─────────────────────────────────────────────────────────────────
+
+function TableSkeleton() {
+  return (
+    <div className="rounded-xl border border-slate-700/40 overflow-hidden animate-pulse">
+      <div className="bg-slate-800/60 px-4 py-3 flex gap-8">
+        {[80, 60, 60, 120, 60, 60, 60].map((w, i) => (
+          <div key={i} className="h-3 rounded bg-slate-700/60" style={{ width: w }} />
+        ))}
+      </div>
+      {[1, 2, 3].map((i) => (
+        <div key={i} className="border-t border-slate-700/20 bg-slate-900/20 px-4 py-4 flex gap-8">
+          {[80, 60, 60, 120, 60, 60, 60].map((w, j) => (
+            <div key={j} className="h-3 rounded bg-slate-700/30" style={{ width: w }} />
+          ))}
+        </div>
+      ))}
+    </div>
+  )
+}
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
 export default function Collections() {
+  const currentProject = useAuthStore((s) => s.currentProject)
+  const push = useToastStore((s) => s.push)
+
+  const [newModalOpen, setNewModalOpen] = useState(false)
+
+  const { data, isLoading, isError, error } = useQuery({
+    queryKey: ['collections', currentProject],
+    queryFn: () => api.listCollections(currentProject!),
+    enabled: !!currentProject,
+  })
+
+  // Surface query errors as toasts
+  if (isError && error instanceof Error) {
+    push(`Collections error: ${error.message}`, 'error')
+  }
+
+  if (!currentProject) {
+    return (
+      <div className="flex flex-col gap-8">
+        <div>
+          <h1 className="text-2xl font-semibold text-slate-100">Collections</h1>
+          <p className="text-slate-400 text-sm mt-1">Manage collections in the current project.</p>
+        </div>
+        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
+          <p className="text-slate-400 text-sm">
+            No project selected — choose a project from the selector above.
+          </p>
+        </div>
+      </div>
+    )
+  }
+
+  const collections = data?.collections ?? []
+
   return (
-    <div>
-      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Collections</h1>
-      <p className="text-slate-400">Collection management — coming in W6.</p>
+    <div className="flex flex-col gap-6">
+      {/* Page header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-semibold text-slate-100">Collections</h1>
+          <p className="text-slate-400 text-sm mt-1">
+            Project: <span className="text-slate-300 font-mono">{currentProject}</span>
+            {!isLoading && !isError && (
+              <span className="ml-2 text-slate-500">
+                ({collections.length} {collections.length === 1 ? 'collection' : 'collections'})
+              </span>
+            )}
+          </p>
+        </div>
+        <button
+          onClick={() => setNewModalOpen(true)}
+          className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
+          data-testid="new-collection-btn"
+        >
+          <Plus size={16} />
+          New collection
+        </button>
+      </div>
+
+      {/* Table */}
+      {isLoading && <TableSkeleton />}
+      {isError && (
+        <div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
+          <p className="text-red-400 text-sm">
+            Failed to load collections: {error instanceof Error ? error.message : 'Unknown error'}
+          </p>
+        </div>
+      )}
+      {!isLoading && !isError && (
+        <CollectionsTable collections={collections} project={currentProject} />
+      )}
+
+      {/* New collection modal */}
+      <Modal
+        open={newModalOpen}
+        title="New Collection"
+        onClose={() => setNewModalOpen(false)}
+      >
+        <NewCollectionForm
+          project={currentProject}
+          onSuccess={() => setNewModalOpen(false)}
+          onCancel={() => setNewModalOpen(false)}
+        />
+      </Modal>
     </div>
   )
 }