Explorar el Código

feat(webui): clickable collections, server-side filter/sort, accurate counts

UI
- Collection rows navigate to /documents?collection=<name> (URL-driven; pre-selects
  in Documents and RAG Tester). Detail drawer, summary bar, manual Refresh buttons
  (Collections, Documents, Dashboard), Documents pagination + server-side sort,
  copy id/JSON, empty-state CTAs.
- Delete now treats a 404 as already-removed: refresh the list instead of erroring.

API (server-side filter/sort/order; nothing client-side)
- GET /collections gains q / sort / desc query params (handled in the server).
- document_count now uses count() (getCollectionInfo().documentCount read back as 0).
- Updated openapi.json + llms.txt.

Tooling
- Fix the ESLint flat config (no TS parser was set) + react-hooks/react-refresh.
- e2e smoke: clickable-row flow, clipboard paste for Monaco, idempotent cleanup.

Bump VERSION 0.1.2 -> 0.1.3.
Fszontagh hace 1 mes
padre
commit
9fccf87292

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.1.2
+0.1.3

+ 1 - 1
api/llms.txt

@@ -30,7 +30,7 @@ All key-management endpoints require an admin key.
 Project-scoped. A key must be granted the project.
 
 - `POST   /api/v1/projects/{project}/collections` `{name, kind:"json"|"vector", vector_dimension?, embedding_model?}` — Create a collection. `vector_dimension` is required for `kind=vector`. `embedding_model` defaults to the server's `default_embedding_model` setting.
-- `GET    /api/v1/projects/{project}/collections` — List collections with metadata.
+- `GET    /api/v1/projects/{project}/collections` — List collections with metadata (incl. `document_count`, `size_bytes`). Query params (all applied server-side): `q` (case-insensitive name substring filter), `sort` (`name`|`kind`|`documents`|`size`|`created_at`, default `name`), `desc` (bool).
 - `GET    /api/v1/projects/{project}/collections/{name}` — Get collection metadata.
 - `DELETE /api/v1/projects/{project}/collections/{name}` — Drop a collection and all its documents.
 

+ 19 - 0
api/openapi.json

@@ -291,6 +291,25 @@
       "get": {
         "summary": "List collections in a project",
         "operationId": "listCollections",
+        "parameters": [
+          {
+            "name": "q",
+            "in": "query",
+            "schema": { "type": "string" },
+            "description": "Case-insensitive substring filter on collection name (applied server-side)."
+          },
+          {
+            "name": "sort",
+            "in": "query",
+            "schema": { "type": "string", "enum": ["name", "kind", "documents", "size", "created_at"], "default": "name" },
+            "description": "Field to sort by (applied server-side). Ties break by name."
+          },
+          {
+            "name": "desc",
+            "in": "query",
+            "schema": { "type": "boolean", "default": false }
+          }
+        ],
         "responses": {
           "200": {
             "description": "Collection list",

+ 47 - 8
src/handlers/collections.cpp

@@ -2,6 +2,8 @@
 #include "errors.hpp"
 #include "json_http.hpp"
 #include "server.hpp"
+#include <algorithm>
+#include <cctype>
 namespace svapi {
 void registerCollectionRoutes(ApiServer& s) {
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
@@ -37,14 +39,51 @@ void registerCollectionRoutes(ApiServer& s) {
     svr.Get(R"(/api/v1/projects/([^/]+)/collections)", [d](const httplib::Request& req, httplib::Response& res) {
         ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
         requireProjectAccess(k, project);
-        nlohmann::json out = nlohmann::json::array();
+
+        auto lower = [](std::string s) {
+            std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); });
+            return s;
+        };
+        const std::string q    = lower(req.has_param("q") ? req.get_param_value("q") : "");
+        const std::string sort = req.has_param("sort") ? req.get_param_value("sort") : "name";
+        const bool        desc = req.has_param("desc") && req.get_param_value("desc") == "true";
+
+        // Build the enriched list (metadata + live stats), filtering by name server-side.
+        std::vector<nlohmann::json> items;
         for (const auto& m : d->registry.list(project)) {
+            if (!q.empty() && lower(m.name).find(q) == std::string::npos) continue;
             nlohmann::json e = m.toJson();
-            if (auto info = d->db.client().getCollectionInfo(qualify(project, m.name))) {
-                e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
-            }
-            out.push_back(e);
+            const std::string qc = qualify(project, m.name);
+            // count() is the authoritative document count; getCollectionInfo()'s
+            // documentCount can read back as 0 on some backends.
+            e["document_count"] = d->db.client().count(qc);
+            if (auto info = d->db.client().getCollectionInfo(qc)) e["size_bytes"] = info->sizeBytes;
+            items.push_back(std::move(e));
         }
+
+        // Sort server-side. Unknown sort fields fall back to name; ties break by name.
+        auto num = [](const nlohmann::json& e, const char* key) -> uint64_t {
+            auto it = e.find(key);
+            return (it != e.end() && it->is_number()) ? it->get<uint64_t>() : 0;
+        };
+        auto str = [](const nlohmann::json& e, const char* key) -> std::string {
+            auto it = e.find(key);
+            return (it != e.end() && it->is_string()) ? it->get<std::string>() : std::string();
+        };
+        auto numCmp = [](uint64_t a, uint64_t b) { return a < b ? -1 : (a > b ? 1 : 0); };
+        std::sort(items.begin(), items.end(), [&](const nlohmann::json& a, const nlohmann::json& b) {
+            int cmp;
+            if      (sort == "documents")  cmp = numCmp(num(a, "document_count"), num(b, "document_count"));
+            else if (sort == "size")       cmp = numCmp(num(a, "size_bytes"),     num(b, "size_bytes"));
+            else if (sort == "created_at") cmp = numCmp(num(a, "created_at"),      num(b, "created_at"));
+            else if (sort == "kind")       cmp = str(a, "kind").compare(str(b, "kind"));
+            else                           cmp = str(a, "name").compare(str(b, "name"));
+            if (cmp == 0) cmp = str(a, "name").compare(str(b, "name"));
+            return desc ? cmp > 0 : cmp < 0;
+        });
+
+        nlohmann::json out = nlohmann::json::array();
+        for (auto& e : items) out.push_back(std::move(e));
         sendJson(res, 200, {{"collections", out}});
     });
 
@@ -54,9 +93,9 @@ void registerCollectionRoutes(ApiServer& s) {
         auto m = d->registry.get(project, name);
         if (!m) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
         nlohmann::json e = m->toJson();
-        if (auto info = d->db.client().getCollectionInfo(qualify(project, name))) {
-            e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
-        }
+        const std::string qc = qualify(project, name);
+        e["document_count"] = d->db.client().count(qc);
+        if (auto info = d->db.client().getCollectionInfo(qc)) e["size_bytes"] = info->sizeBytes;
         sendJson(res, 200, e);
     });
 

+ 1 - 1
src/handlers/stats.cpp

@@ -13,7 +13,7 @@ void registerStatsRoutes(ApiServer& s) {
         auto cols = d->registry.list(project);
         uint64_t docs = 0;
         for (const auto& m : cols)
-            if (auto info = d->db.client().getCollectionInfo(qualify(project, m.name))) docs += info->documentCount;
+            docs += d->db.client().count(qualify(project, m.name));
         sendJson(res, 200, {{"project", project}, {"collections", cols.size()}, {"documents", docs}});
     });
 

+ 5 - 0
webui/.gitignore

@@ -1,3 +1,8 @@
 node_modules/
 dist/
 *.tsbuildinfo
+
+# Playwright e2e artifacts
+test-results/
+playwright-report/
+.playwright-mcp/

+ 26 - 12
webui/e2e/smoke.spec.ts

@@ -20,7 +20,17 @@ import { test, expect } from '@playwright/test'
 
 const ADMIN_KEY = process.env['SVAPI_ADMIN_KEY'] ?? 'e2eadmin'
 
-test('login → collections → documents → logout smoke', async ({ page }) => {
+test('login → collections → documents → logout smoke', async ({ page, context }) => {
+  // Monaco mangles typed/inserted JSON via auto-closing brackets, so we paste
+  // the document body via the clipboard instead — which needs this permission.
+  await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+
+  // Best-effort cleanup so the test is idempotent: a prior failed run may have
+  // left the e2e_docs collection behind, which would make the create step fail.
+  await page.request.delete('/api/v1/projects/default/collections/e2e_docs', {
+    headers: { Authorization: `Bearer ${ADMIN_KEY}` },
+  })
+
   // ── 1. Navigate to / → should redirect to /login ─────────────────────────
   await page.goto('/')
   await expect(page).toHaveURL(/\/login/)
@@ -38,7 +48,10 @@ test('login → collections → documents → logout smoke', async ({ page }) =>
   await expect(projectSelector).toHaveValue('default')
 
   // ── 3. Navigate to Collections ────────────────────────────────────────────
-  await page.getByRole('link', { name: 'Collections' }).click()
+  // Scope to the sidebar — the Documents page also renders a "Collections"
+  // breadcrumb link, so an unscoped role query would match two elements.
+  const sidebar = page.locator('aside')
+  await sidebar.getByRole('link', { name: 'Collections' }).click()
   await expect(page).toHaveURL(/\/collections/)
 
   // Open "New collection" modal
@@ -55,16 +68,14 @@ test('login → collections → documents → logout smoke', async ({ page }) =>
   // Wait for modal to close and collection to appear
   await expect(page.locator('[data-testid="collection-row-e2e_docs"]')).toBeVisible({ timeout: 10_000 })
 
-  // ── 4. Navigate to Documents ──────────────────────────────────────────────
-  await page.getByRole('link', { name: 'Documents' }).click()
-  await expect(page).toHaveURL(/\/documents/)
+  // ── 4. Click the collection row → navigates to its documents ──────────────
+  await page.locator('[data-testid="collection-row-e2e_docs"]').click()
+  await expect(page).toHaveURL(/\/documents\?collection=e2e_docs/)
 
-  // Collection picker should auto-select e2e_docs (it will be in the list)
+  // Collection picker should be pre-selected from the `?collection=` param
   const collPicker = page.locator('[data-testid="collection-picker"]')
   await expect(collPicker).toBeVisible()
-
-  // Select e2e_docs in the collection picker
-  await collPicker.selectOption('e2e_docs')
+  await expect(collPicker).toHaveValue('e2e_docs')
 
   // Click "New document"
   const newDocBtn = page.locator('[data-testid="new-doc-btn"]')
@@ -76,10 +87,13 @@ test('login → collections → documents → logout smoke', async ({ page }) =>
   const monacoEditor = page.locator('.monaco-editor').first()
   await expect(monacoEditor).toBeVisible({ timeout: 15_000 })
 
-  // Click into the editor, select all and type our JSON
+  // Click into the editor, select all and replace with our JSON. Pasting via
+  // the clipboard avoids Monaco's auto-closing brackets, which corrupt the JSON
+  // when typing or inserting character data.
   await monacoEditor.click()
   await page.keyboard.press('Control+a')
-  await page.keyboard.type('{"name":"e2e"}')
+  await page.evaluate((t) => navigator.clipboard.writeText(t), '{"name":"e2e"}')
+  await page.keyboard.press('Control+v')
 
   // Save button
   const saveBtn = page.locator('[data-testid="doc-save-btn"]')
@@ -90,7 +104,7 @@ test('login → collections → documents → logout smoke', async ({ page }) =>
   await expect(page.locator('[data-testid="doc-row"]').first()).toBeVisible({ timeout: 10_000 })
 
   // ── 5. Delete the collection (cleanup) ────────────────────────────────────
-  await page.getByRole('link', { name: 'Collections' }).click()
+  await sidebar.getByRole('link', { name: 'Collections' }).click()
   await expect(page).toHaveURL(/\/collections/)
 
   // Click Delete button for e2e_docs row

+ 35 - 5
webui/eslint.config.js

@@ -1,12 +1,42 @@
 import js from '@eslint/js'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
 
-export default [
-  { ignores: ['dist'] },
+export default tseslint.config(
+  { ignores: ['dist', 'playwright-report', 'test-results'] },
+
+  // App source + tests: TypeScript + React, browser globals.
   {
     files: ['**/*.{ts,tsx}'],
-    ...js.configs.recommended,
+    extends: [js.configs.recommended, ...tseslint.configs.recommended],
+    languageOptions: {
+      ecmaVersion: 2022,
+      globals: globals.browser,
+    },
+    plugins: {
+      'react-hooks': reactHooks,
+      'react-refresh': reactRefresh,
+    },
+    rules: {
+      // Stable, battle-tested hooks rules (the newer plugin's `recommended`
+      // also ships experimental rules that false-positive on legitimate
+      // reset-state-on-open patterns, so enable the classic two explicitly).
+      'react-hooks/rules-of-hooks': 'error',
+      'react-hooks/exhaustive-deps': 'warn',
+      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+      // The codebase intentionally avoids `any` (see CLAUDE.md); keep that enforced.
+      '@typescript-eslint/no-explicit-any': 'error',
+      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+    },
+  },
+
+  // Config / Node-side files: Node globals (e.g. __dirname in vite.config.ts).
+  {
+    files: ['*.config.{ts,js}', 'vite.config.ts', 'playwright.config.ts'],
     languageOptions: {
-      ecmaVersion: 2020,
+      globals: globals.node,
     },
   },
-]
+)

+ 397 - 3
webui/package-lock.json

@@ -28,7 +28,11 @@
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.1.1",
         "eslint": "^9.39.1",
+        "eslint-plugin-react-hooks": "^7.1.1",
+        "eslint-plugin-react-refresh": "^0.5.2",
+        "globals": "^17.6.0",
         "typescript": "~5.9.3",
+        "typescript-eslint": "^8.60.0",
         "vite": "^7.2.4"
       }
     },
@@ -811,6 +815,18 @@
         "url": "https://opencollective.com/eslint"
       }
     },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/@eslint/js": {
       "version": "9.39.4",
       "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@@ -1679,6 +1695,285 @@
         "@types/react": "^19.2.0"
       }
     },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
+      "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.12.2",
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/type-utils": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
+        "ignore": "^7.0.5",
+        "natural-compare": "^1.4.0",
+        "ts-api-utils": "^2.5.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^8.60.0",
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
+      "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
+        "debug": "^4.4.3"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/project-service": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
+      "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.60.0",
+        "@typescript-eslint/types": "^8.60.0",
+        "debug": "^4.4.3"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
+      "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
+      "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
+      "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0",
+        "debug": "^4.4.3",
+        "ts-api-utils": "^2.5.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
+      "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
+      "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.60.0",
+        "@typescript-eslint/tsconfig-utils": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
+        "debug": "^4.4.3",
+        "minimatch": "^10.2.2",
+        "semver": "^7.7.3",
+        "tinyglobby": "^0.2.15",
+        "ts-api-utils": "^2.5.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+      "version": "10.2.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^5.0.5"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+      "version": "7.8.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+      "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
+      "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.9.1",
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
+      "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "8.60.0",
+        "eslint-visitor-keys": "^5.0.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+      "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+      "dev": true,
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
     "node_modules/@vitejs/plugin-react": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -2105,6 +2400,34 @@
         }
       }
     },
+    "node_modules/eslint-plugin-react-hooks": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+      "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.24.4",
+        "@babel/parser": "^7.24.4",
+        "hermes-parser": "^0.25.1",
+        "zod": "^3.25.0 || ^4.0.0",
+        "zod-validation-error": "^3.5.0 || ^4.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-react-refresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+      "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+      "dev": true,
+      "peerDependencies": {
+        "eslint": "^9 || ^10"
+      }
+    },
     "node_modules/eslint-scope": {
       "version": "8.4.0",
       "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@@ -2310,9 +2633,9 @@
       }
     },
     "node_modules/globals": {
-      "version": "14.0.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
-      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "version": "17.6.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
+      "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
       "dev": true,
       "engines": {
         "node": ">=18"
@@ -2336,6 +2659,21 @@
         "node": ">=8"
       }
     },
+    "node_modules/hermes-estree": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+      "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+      "dev": true
+    },
+    "node_modules/hermes-parser": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+      "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+      "dev": true,
+      "dependencies": {
+        "hermes-estree": "0.25.1"
+      }
+    },
     "node_modules/ignore": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3269,6 +3607,18 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/ts-api-utils": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+      "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3294,6 +3644,29 @@
         "node": ">=14.17"
       }
     },
+    "node_modules/typescript-eslint": {
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
+      "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "8.60.0",
+        "@typescript-eslint/parser": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "typescript": ">=4.8.4 <6.1.0"
+      }
+    },
     "node_modules/undici-types": {
       "version": "7.16.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -3455,6 +3828,27 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-validation-error": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+      "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "peerDependencies": {
+        "zod": "^3.25.0 || ^4.0.0"
+      }
+    },
     "node_modules/zustand": {
       "version": "5.0.14",
       "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",

+ 4 - 0
webui/package.json

@@ -31,7 +31,11 @@
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
     "eslint": "^9.39.1",
+    "eslint-plugin-react-hooks": "^7.1.1",
+    "eslint-plugin-react-refresh": "^0.5.2",
+    "globals": "^17.6.0",
     "typescript": "~5.9.3",
+    "typescript-eslint": "^8.60.0",
     "vite": "^7.2.4"
   }
 }

+ 8 - 1
webui/src/api/client.ts

@@ -43,7 +43,14 @@ export const api = {
   deleteKey: (key: string) => request<{ deleted: boolean }>(`/api/v1/keys/${enc(key)}`, { method: 'DELETE' }),
 
   // collections
-  listCollections: (project: string) => request<{ collections: CollectionMeta[] }>(`${P(project)}/collections`),
+  listCollections: (project: string, params?: { q?: string; sort?: string; desc?: boolean }) => {
+    const qs = new URLSearchParams()
+    if (params?.q) qs.set('q', params.q)
+    if (params?.sort) qs.set('sort', params.sort)
+    if (params?.desc) qs.set('desc', 'true')
+    const s = qs.toString()
+    return request<{ collections: CollectionMeta[] }>(`${P(project)}/collections${s ? `?${s}` : ''}`)
+  },
   getCollection: (project: string, name: string) => request<CollectionMeta>(`${P(project)}/collections/${enc(name)}`),
   createCollection: (project: string, body: { name: string; kind: 'json' | 'vector'; vector_dimension?: number; embedding_model?: string }) =>
     request<CollectionMeta>(`${P(project)}/collections`, { method: 'POST', body: JSON.stringify(body) }),

+ 310 - 23
webui/src/pages/Collections.tsx

@@ -1,6 +1,10 @@
-import { useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { Plus, Trash2 } from 'lucide-react'
+import {
+  Plus, Trash2, ChevronRight, RefreshCw, Search as SearchIcon,
+  FileText, Database, HardDrive, Layers, ArrowUp, ArrowDown, ArrowUpDown, Info,
+} from 'lucide-react'
 import { api } from '@/api/client'
 import { useAuthStore } from '@/stores/authStore'
 import { useToastStore } from '@/stores/toastStore'
@@ -18,6 +22,18 @@ function humanizeBytes(bytes: number): string {
   return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
 }
 
+function formatDate(ts: number): string {
+  if (!ts) return '—'
+  try {
+    return new Date(ts * 1000).toLocaleString()
+  } catch {
+    return String(ts)
+  }
+}
+
+// Sort fields understood by the server (GET /collections?sort=…&desc=…)
+type SortField = 'name' | 'kind' | 'documents' | 'size' | 'created_at'
+
 // ─── new collection form ──────────────────────────────────────────────────────
 
 interface NewCollectionFormProps {
@@ -188,18 +204,181 @@ function KindBadge({ kind }: { kind: CollectionMeta['kind'] }) {
   )
 }
 
+// ─── sortable column header ─────────────────────────────────────────────────────
+
+interface SortHeaderProps {
+  label: string
+  field: SortField
+  sort: SortField
+  desc: boolean
+  onSort: (field: SortField) => void
+  align?: 'left' | 'right'
+}
+
+function SortHeader({ label, field, sort, desc, onSort, align = 'left' }: SortHeaderProps) {
+  const active = sort === field
+  const Icon = active ? (desc ? ArrowDown : ArrowUp) : ArrowUpDown
+  return (
+    <th
+      className={[
+        'px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider',
+        align === 'right' ? 'text-right' : 'text-left',
+      ].join(' ')}
+    >
+      <button
+        onClick={() => onSort(field)}
+        className={[
+          'inline-flex items-center gap-1 hover:text-slate-200 transition',
+          align === 'right' ? 'flex-row-reverse' : '',
+          active ? 'text-indigo-300' : '',
+        ].join(' ')}
+        data-testid={`collection-sort-${field}`}
+      >
+        {label}
+        <Icon size={12} className={active ? '' : 'opacity-40'} />
+      </button>
+    </th>
+  )
+}
+
+// ─── stat chip + summary bar ────────────────────────────────────────────────────
+
+function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
+  return (
+    <div className="flex items-center gap-2.5 rounded-lg border border-slate-700/40 bg-slate-800/40 px-3.5 py-2.5">
+      <span className="text-slate-500">{icon}</span>
+      <div className="leading-tight">
+        <div className="text-sm font-semibold text-slate-200">{value}</div>
+        <div className="text-[11px] text-slate-500">{label}</div>
+      </div>
+    </div>
+  )
+}
+
+function SummaryBar({ collections }: { collections: CollectionMeta[] }) {
+  const totals = useMemo(() => {
+    let docs = 0, bytes = 0, vectors = 0
+    let hasDocs = false, hasBytes = false
+    for (const c of collections) {
+      if (c.document_count !== undefined) { docs += c.document_count; hasDocs = true }
+      if (c.size_bytes !== undefined) { bytes += c.size_bytes; hasBytes = true }
+      if (c.kind === 'vector') vectors++
+    }
+    return { docs, bytes, vectors, hasDocs, hasBytes }
+  }, [collections])
+
+  return (
+    <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
+      <StatChip icon={<Layers size={16} />} label="Collections" value={collections.length.toLocaleString()} />
+      <StatChip icon={<FileText size={16} />} label="Documents" value={totals.hasDocs ? totals.docs.toLocaleString() : '—'} />
+      <StatChip icon={<HardDrive size={16} />} label="Total size" value={totals.hasBytes ? humanizeBytes(totals.bytes) : '—'} />
+      <StatChip icon={<Database size={16} />} label="Vector collections" value={totals.vectors.toLocaleString()} />
+    </div>
+  )
+}
+
+// ─── detail drawer ──────────────────────────────────────────────────────────────
+
+function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
+  return (
+    <div className="flex items-center justify-between px-3 py-2">
+      <dt className="text-slate-500 text-xs uppercase tracking-wider">{label}</dt>
+      <dd className="text-slate-200 text-sm font-mono">{value}</dd>
+    </div>
+  )
+}
+
+interface DetailDrawerProps {
+  project: string
+  name: string | null
+  onClose: () => void
+}
+
+function CollectionDetailDrawer({ project, name, onClose }: DetailDrawerProps) {
+  const navigate = useNavigate()
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['collection', project, name],
+    queryFn: () => api.getCollection(project, name!),
+    enabled: !!name,
+  })
+
+  return (
+    <Modal open={name !== null} title={`Collection — ${name ?? ''}`} onClose={onClose}>
+      {isLoading && <p className="text-slate-400 text-sm">Loading…</p>}
+      {isError && <p className="text-red-400 text-sm">Failed to load collection metadata.</p>}
+      {data && (
+        <div className="flex flex-col gap-4">
+          {/* Stat strip */}
+          <div className="grid grid-cols-2 gap-3">
+            <StatChip
+              icon={<FileText size={16} />}
+              label="Documents"
+              value={data.document_count !== undefined ? data.document_count.toLocaleString() : '—'}
+            />
+            <StatChip
+              icon={<HardDrive size={16} />}
+              label="Size"
+              value={data.size_bytes !== undefined ? humanizeBytes(data.size_bytes) : '—'}
+            />
+          </div>
+
+          {/* Metadata */}
+          <dl className="divide-y divide-slate-700/40 rounded-lg border border-slate-700/40 overflow-hidden bg-slate-800/30">
+            <DetailRow label="Kind" value={<KindBadge kind={data.kind} />} />
+            {data.kind === 'vector' && <DetailRow label="Dimension" value={data.vector_dimension} />}
+            {data.kind === 'vector' && (
+              <DetailRow label="Embedding model" value={data.embedding_model || '—'} />
+            )}
+            <DetailRow label="Created" value={formatDate(data.created_at)} />
+          </dl>
+
+          {/* Quick actions */}
+          <div className="flex items-center justify-end gap-3 pt-1">
+            {data.kind === 'vector' && (
+              <button
+                onClick={() => navigate(`/rag?collection=${encodeURIComponent(data.name)}`)}
+                className="inline-flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium text-indigo-300 bg-indigo-500/10 hover:bg-indigo-500/20 transition"
+              >
+                <SearchIcon size={14} />
+                RAG tester
+              </button>
+            )}
+            <button
+              onClick={() => navigate(`/documents?collection=${encodeURIComponent(data.name)}`)}
+              className="inline-flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
+            >
+              View documents
+              <ChevronRight size={14} />
+            </button>
+          </div>
+        </div>
+      )}
+    </Modal>
+  )
+}
+
 // ─── collections table ────────────────────────────────────────────────────────
 
 interface CollectionsTableProps {
   collections: CollectionMeta[]
   project: string
+  query: string
+  sort: SortField
+  desc: boolean
+  onSort: (field: SortField) => void
 }
 
-function CollectionsTable({ collections, project }: CollectionsTableProps) {
+function CollectionsTable({ collections, project, query, sort, desc, onSort }: CollectionsTableProps) {
   const push = useToastStore((s) => s.push)
   const queryClient = useQueryClient()
+  const navigate = useNavigate()
 
   const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
+  const [detailTarget, setDetailTarget] = useState<string | null>(null)
+
+  const openDocuments = (name: string) => {
+    navigate(`/documents?collection=${encodeURIComponent(name)}`)
+  }
 
   const handleDelete = async (name: string) => {
     try {
@@ -215,7 +394,11 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
   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>
+        <p className="text-slate-400 text-sm">
+          {query
+            ? `No collections match “${query}”.`
+            : 'No collections yet. Create one to get started.'}
+        </p>
       </div>
     )
   }
@@ -226,12 +409,12 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
         <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>
+              <SortHeader label="Name" field="name" sort={sort} desc={desc} onSort={onSort} />
+              <SortHeader label="Kind" field="kind" sort={sort} desc={desc} onSort={onSort} />
               <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>
+              <SortHeader label="Documents" field="documents" sort={sort} desc={desc} onSort={onSort} align="right" />
+              <SortHeader label="Size" field="size" sort={sort} desc={desc} onSort={onSort} align="right" />
               <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
             </tr>
           </thead>
@@ -239,12 +422,19 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
             {collections.map((col, idx) => (
               <tr
                 key={col.name}
+                onClick={() => openDocuments(col.name)}
+                title={`View documents in ${col.name}`}
                 className={[
-                  'border-b border-slate-700/20 transition hover:bg-slate-800/40',
+                  'group border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
                   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" data-testid={`collection-row-${col.name}`}>{col.name}</td>
+                <td className="px-4 py-3 font-mono text-slate-200 text-xs" data-testid={`collection-row-${col.name}`}>
+                  <span className="inline-flex items-center gap-1.5">
+                    {col.name}
+                    <ChevronRight size={13} className="text-slate-600 opacity-0 group-hover:opacity-100 transition" />
+                  </span>
+                </td>
                 <td className="px-4 py-3">
                   <KindBadge kind={col.kind} />
                 </td>
@@ -261,14 +451,43 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
                   {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>
+                  <div className="flex items-center justify-end gap-1">
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        setDetailTarget(col.name)
+                      }}
+                      className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition"
+                      aria-label={`Details for ${col.name}`}
+                    >
+                      <Info size={13} />
+                      Details
+                    </button>
+                    {col.kind === 'vector' && (
+                      <button
+                        onClick={(e) => {
+                          e.stopPropagation()
+                          navigate(`/rag?collection=${encodeURIComponent(col.name)}`)
+                        }}
+                        className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-indigo-400 hover:text-indigo-300 hover:bg-indigo-500/10 transition"
+                        aria-label={`Open RAG tester for ${col.name}`}
+                      >
+                        <SearchIcon size={13} />
+                        RAG
+                      </button>
+                    )}
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        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>
+                  </div>
                 </td>
               </tr>
             ))}
@@ -276,6 +495,12 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
         </table>
       </div>
 
+      <CollectionDetailDrawer
+        project={project}
+        name={detailTarget}
+        onClose={() => setDetailTarget(null)}
+      />
+
       <ConfirmDialog
         open={deleteTarget !== null}
         title="Delete Collection"
@@ -319,9 +544,21 @@ export default function Collections() {
 
   const [newModalOpen, setNewModalOpen] = useState(false)
 
-  const { data, isLoading, isError, error } = useQuery({
-    queryKey: ['collections', currentProject],
-    queryFn: () => api.listCollections(currentProject!),
+  // Filter + sort are applied SERVER-SIDE. `filterInput` is the immediate text;
+  // `q` is debounced so we issue one request after the user pauses typing.
+  const [filterInput, setFilterInput] = useState('')
+  const [q, setQ] = useState('')
+  const [sort, setSort] = useState<SortField>('name')
+  const [desc, setDesc] = useState(false)
+
+  useEffect(() => {
+    const t = setTimeout(() => setQ(filterInput.trim()), 300)
+    return () => clearTimeout(t)
+  }, [filterInput])
+
+  const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
+    queryKey: ['collections', currentProject, q, sort, desc],
+    queryFn: () => api.listCollections(currentProject!, { q: q || undefined, sort, desc }),
     enabled: !!currentProject,
   })
 
@@ -330,6 +567,15 @@ export default function Collections() {
     push(`Collections error: ${error.message}`, 'error')
   }
 
+  const handleSort = (field: SortField) => {
+    if (field === sort) {
+      setDesc((d) => !d)
+    } else {
+      setSort(field)
+      setDesc(false)
+    }
+  }
+
   if (!currentProject) {
     return (
       <div className="flex flex-col gap-8">
@@ -347,6 +593,7 @@ export default function Collections() {
   }
 
   const collections = data?.collections ?? []
+  const showControls = !isLoading && !isError && (collections.length > 0 || q.length > 0)
 
   return (
     <div className="flex flex-col gap-6">
@@ -358,7 +605,8 @@ export default function Collections() {
             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'})
+                ({collections.length} {collections.length === 1 ? 'collection' : 'collections'}
+                {q ? ' matching' : ''})
               </span>
             )}
           </p>
@@ -373,6 +621,38 @@ export default function Collections() {
         </button>
       </div>
 
+      {/* Summary */}
+      {!isLoading && !isError && collections.length > 0 && (
+        <SummaryBar collections={collections} />
+      )}
+
+      {/* Controls: server-side filter + refresh */}
+      {showControls && (
+        <div className="flex items-center justify-between gap-3 flex-wrap">
+          <div className="relative">
+            <SearchIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none" />
+            <input
+              type="text"
+              value={filterInput}
+              onChange={(e) => setFilterInput(e.target.value)}
+              placeholder="Filter collections…"
+              data-testid="collection-filter-input"
+              className="w-64 rounded-md bg-slate-800 border border-slate-600 text-slate-100 pl-9 pr-3 py-1.5 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+            />
+          </div>
+          <button
+            onClick={() => void refetch()}
+            disabled={isFetching}
+            className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
+            data-testid="collection-refresh-btn"
+            title="Refresh stats"
+          >
+            <RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
+            Refresh
+          </button>
+        </div>
+      )}
+
       {/* Table */}
       {isLoading && <TableSkeleton />}
       {isError && (
@@ -383,7 +663,14 @@ export default function Collections() {
         </div>
       )}
       {!isLoading && !isError && (
-        <CollectionsTable collections={collections} project={currentProject} />
+        <CollectionsTable
+          collections={collections}
+          project={currentProject}
+          query={q}
+          sort={sort}
+          desc={desc}
+          onSort={handleSort}
+        />
       )}
 
       {/* New collection modal */}

+ 31 - 4
webui/src/pages/Dashboard.tsx

@@ -1,5 +1,5 @@
 import { useEffect } from 'react'
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, useQueryClient, useIsFetching } from '@tanstack/react-query'
 import {
   FolderOpen,
   Layers,
@@ -7,6 +7,7 @@ import {
   Database,
   Gauge,
   LayoutGrid,
+  RefreshCw,
 } from 'lucide-react'
 import { api } from '@/api/client'
 import { useAuthStore } from '@/stores/authStore'
@@ -247,12 +248,38 @@ function GlobalSection() {
 export default function Dashboard() {
   const currentProject = useAuthStore((s) => s.currentProject)
   const admin = useAuthStore((s) => s.admin)
+  const queryClient = useQueryClient()
+
+  // Stats are fetched on demand — this button re-fetches the project + global
+  // stats (no background polling).
+  const fetching = useIsFetching({
+    predicate: (query) => {
+      const key = query.queryKey[0]
+      return key === 'projectStats' || key === 'globalStats'
+    },
+  })
+  const refresh = () => {
+    void queryClient.invalidateQueries({ queryKey: ['projectStats'] })
+    void queryClient.invalidateQueries({ queryKey: ['globalStats'] })
+  }
 
   return (
     <div className="flex flex-col gap-8">
-      <div>
-        <h1 className="text-2xl font-semibold text-slate-100">Dashboard</h1>
-        <p className="text-slate-400 text-sm mt-1">Overview of your VectorAPI workspace.</p>
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-semibold text-slate-100">Dashboard</h1>
+          <p className="text-slate-400 text-sm mt-1">Overview of your VectorAPI workspace.</p>
+        </div>
+        <button
+          onClick={refresh}
+          disabled={fetching > 0}
+          className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
+          data-testid="dashboard-refresh-btn"
+          title="Refresh stats"
+        >
+          <RefreshCw size={14} className={fetching > 0 ? 'animate-spin' : ''} />
+          Refresh
+        </button>
       </div>
 
       {currentProject !== null

+ 267 - 51
webui/src/pages/Documents.tsx

@@ -1,6 +1,10 @@
 import { useState, useEffect } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { Plus, Search, Pencil } from 'lucide-react'
+import {
+  Plus, Search, Pencil, ChevronLeft, ChevronRight, Database,
+  Copy, ArrowUp, ArrowDown, ArrowUpDown, RefreshCw,
+} from 'lucide-react'
 import { api } from '@/api/client'
 import { useAuthStore } from '@/stores/authStore'
 import { useToastStore } from '@/stores/toastStore'
@@ -33,6 +37,8 @@ function buildQueryString(
   filterValue: string,
   limit: string,
   offset: string,
+  sortField: string,
+  sortDesc: boolean,
 ): string {
   const parts: string[] = []
 
@@ -42,6 +48,11 @@ function buildQueryString(
   }
   if (limit.trim()) parts.push(`limit=${encodeURIComponent(limit.trim())}`)
   if (offset.trim()) parts.push(`offset=${encodeURIComponent(offset.trim())}`)
+  // Ordering is applied server-side via the `sort`/`desc` query params.
+  if (sortField.trim()) {
+    parts.push(`sort=${encodeURIComponent(sortField.trim())}`)
+    if (sortDesc) parts.push('desc=true')
+  }
 
   return parts.length > 0 ? `?${parts.join('&')}` : ''
 }
@@ -282,9 +293,13 @@ function DocEditorModal({
 interface ResultsTableProps {
   docs: Record<string, unknown>[]
   onRowClick: (doc: Record<string, unknown>) => void
+  onCopy: (text: string, label: string) => void
+  sortField: string
+  sortDesc: boolean
+  onSortById: () => void
 }
 
-function ResultsTable({ docs, onRowClick }: ResultsTableProps) {
+function ResultsTable({ docs, onRowClick, onCopy, sortField, sortDesc, onSortById }: ResultsTableProps) {
   if (docs.length === 0) {
     return (
       <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
@@ -293,38 +308,83 @@ function ResultsTable({ docs, onRowClick }: ResultsTableProps) {
     )
   }
 
+  const idActive = sortField === 'id'
+  const IdSortIcon = idActive ? (sortDesc ? ArrowDown : ArrowUp) : ArrowUpDown
+
   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 w-40">ID</th>
+            <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-40">
+              <button
+                onClick={onSortById}
+                className={[
+                  'inline-flex items-center gap-1 hover:text-slate-200 transition',
+                  idActive ? 'text-indigo-300' : '',
+                ].join(' ')}
+                data-testid="doc-sort-id"
+                title="Sort by id (server-side)"
+              >
+                ID
+                <IdSortIcon size={12} className={idActive ? '' : 'opacity-40'} />
+              </button>
+            </th>
             <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Preview</th>
-            <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-16">Edit</th>
+            <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-28">Actions</th>
           </tr>
         </thead>
         <tbody>
-          {docs.map((doc, idx) => (
-            <tr
-              key={idx}
-              onClick={() => onRowClick(doc)}
-              className={[
-                'border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
-                idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
-              ].join(' ')}
-              data-testid="doc-row"
-            >
-              <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate max-w-xs">
-                {docId(doc)}
-              </td>
-              <td className="px-4 py-3 font-mono text-slate-400 text-xs truncate max-w-md">
-                {compactPreview(doc)}
-              </td>
-              <td className="px-4 py-3 text-right">
-                <Pencil size={13} className="inline text-slate-500" />
-              </td>
-            </tr>
-          ))}
+          {docs.map((doc, idx) => {
+            const id = docId(doc)
+            return (
+              <tr
+                key={idx}
+                onClick={() => onRowClick(doc)}
+                className={[
+                  'border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
+                  idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
+                ].join(' ')}
+                data-testid="doc-row"
+              >
+                <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate max-w-xs">
+                  {id}
+                </td>
+                <td className="px-4 py-3 font-mono text-slate-400 text-xs truncate max-w-md">
+                  {compactPreview(doc)}
+                </td>
+                <td className="px-4 py-3">
+                  <div className="flex items-center justify-end gap-0.5">
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        onCopy(id, 'Document ID')
+                      }}
+                      className="p-1.5 rounded-md text-slate-500 hover:text-slate-200 hover:bg-slate-700/50 transition"
+                      aria-label="Copy document ID"
+                      title="Copy ID"
+                    >
+                      <Copy size={13} />
+                    </button>
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        onCopy(JSON.stringify(doc, null, 2), 'Document JSON')
+                      }}
+                      className="p-1.5 rounded-md text-slate-500 hover:text-slate-200 hover:bg-slate-700/50 transition"
+                      aria-label="Copy document JSON"
+                      title="Copy JSON"
+                    >
+                      <Database size={13} />
+                    </button>
+                    <span className="p-1.5 text-slate-500" title="Click row to edit">
+                      <Pencil size={13} />
+                    </span>
+                  </div>
+                </td>
+              </tr>
+            )
+          })}
         </tbody>
       </table>
     </div>
@@ -346,8 +406,11 @@ export default function Documents() {
   const push = useToastStore((s) => s.push)
   const queryClient = useQueryClient()
 
-  // Collection selection
-  const [selectedColl, setSelectedColl] = useState('')
+  // Collection selection — driven by the `?collection=` query param so the
+  // selection is shareable/bookmarkable and links from the Collections page
+  // land directly on the right collection.
+  const [searchParams, setSearchParams] = useSearchParams()
+  const selectedColl = searchParams.get('collection') ?? ''
 
   // Filter state
   const [filterField, setFilterField] = useState('')
@@ -356,6 +419,10 @@ export default function Documents() {
   const [limit, setLimit] = useState('50')
   const [offset, setOffset] = useState('0')
 
+  // Sort state (applied server-side via `sort`/`desc`)
+  const [sortField, setSortField] = useState('')
+  const [sortDesc, setSortDesc] = useState(false)
+
   // The query string used for the last triggered search
   const [appliedQs, setAppliedQs] = useState('')
 
@@ -365,7 +432,7 @@ export default function Documents() {
   const [saving, setSaving] = useState(false)
 
   // Auto-select first collection on project change
-  const { data: collectionsData } = useQuery<{ collections: CollectionMeta[] }>({
+  const { data: collectionsData, isLoading: collectionsLoading } = useQuery<{ collections: CollectionMeta[] }>({
     queryKey: ['collections', currentProject],
     queryFn: () => api.listCollections(currentProject!),
     enabled: !!currentProject,
@@ -381,6 +448,8 @@ export default function Documents() {
     isLoading: findLoading,
     isError: findError,
     error: findErrorObj,
+    refetch: refetchDocs,
+    isFetching: docsFetching,
   } = useQuery({
     queryKey: findQueryKey,
     queryFn: () => api.findDocuments(currentProject!, effectiveCollection, appliedQs),
@@ -390,18 +459,50 @@ export default function Documents() {
   const docs = findData?.documents ?? []
   const docCount = findData?.count ?? null
 
+  // Pagination — the API returns a page of results (no grand total), so we use
+  // the full-page heuristic: a full page means there is probably a next page.
+  const limitNum = parseInt(limit, 10) || 50
+  const offsetNum = parseInt(offset, 10) || 0
+  const canPrev = offsetNum > 0
+  const canNext = docs.length >= limitNum
+
   const invalidateDocs = () => {
     void queryClient.invalidateQueries({ queryKey: ['documents', currentProject, effectiveCollection] })
   }
 
-  const handleSearch = () => {
-    const qs = buildQueryString(filterField, filterOp, filterValue, limit, offset)
-    setAppliedQs(qs)
+  // Single entry point for (re)issuing the server-side query. Optional overrides
+  // let pagination set a new offset and the sort controls change ordering; a sort
+  // change resets to the first page.
+  const applySearch = (opts: { offset?: string; sortField?: string; sortDesc?: boolean } = {}) => {
+    const sortChanged = opts.sortField !== undefined || opts.sortDesc !== undefined
+    const off = opts.offset ?? (sortChanged ? '0' : offset)
+    const sf = opts.sortField ?? sortField
+    const sd = opts.sortDesc ?? sortDesc
+    setOffset(off)
+    if (opts.sortField !== undefined) setSortField(opts.sortField)
+    if (opts.sortDesc !== undefined) setSortDesc(opts.sortDesc)
+    setAppliedQs(buildQueryString(filterField, filterOp, filterValue, limit, off, sf, sd))
+  }
+
+  // Toggle server-side sort by `id` from the ID column header.
+  const toggleSortById = () => {
+    if (sortField === 'id') applySearch({ sortDesc: !sortDesc })
+    else applySearch({ sortField: 'id', sortDesc: false })
+  }
+
+  const handleCopy = async (text: string, label: string) => {
+    try {
+      await navigator.clipboard.writeText(text)
+      push(`${label} copied to clipboard.`, 'success')
+    } catch {
+      push('Copy failed — clipboard unavailable.', 'error')
+    }
   }
 
   const handleCollectionSelect = (coll: string) => {
-    setSelectedColl(coll)
+    setSearchParams(coll ? { collection: coll } : {}, { replace: true })
     setAppliedQs('')
+    setOffset('0')
   }
 
   // Open existing doc for editing
@@ -450,12 +551,20 @@ export default function Documents() {
     try {
       await api.deleteDocument(currentProject, effectiveCollection, modalState.docId)
       push('Document deleted.', 'success')
-      setModalOpen(false)
-      invalidateDocs()
     } catch (err) {
-      const msg = err instanceof ApiError ? err.message : String(err)
-      push(`Delete failed: ${msg}`, 'error')
+      // A 404 means the document is already gone (e.g. removed via the API or an
+      // ingestion job since the list loaded). The desired end-state is reached,
+      // so treat it as success and just refresh the now-stale list.
+      if (err instanceof ApiError && err.status === 404) {
+        push('Document was already removed; refreshed the list.', 'info')
+      } else {
+        const msg = err instanceof ApiError ? err.message : String(err)
+        push(`Delete failed: ${msg}`, 'error')
+        return // real failure — keep the editor open
+      }
     }
+    setModalOpen(false)
+    invalidateDocs()
   }
 
   // ─── guard: no project selected ───────────────────────────────────────────
@@ -476,6 +585,31 @@ export default function Documents() {
     )
   }
 
+  // ─── guard: project has no collections yet ────────────────────────────────
+
+  if (!collectionsLoading && collections.length === 0) {
+    return (
+      <div className="flex flex-col gap-6">
+        <div>
+          <h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
+          <p className="text-slate-400 text-sm mt-1">
+            Project: <span className="text-slate-300 font-mono">{currentProject}</span>
+          </p>
+        </div>
+        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center flex flex-col items-center gap-4">
+          <p className="text-slate-400 text-sm">This project has no collections yet.</p>
+          <Link
+            to="/collections"
+            className="inline-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"
+          >
+            <Database size={16} />
+            Create your first collection
+          </Link>
+        </div>
+      </div>
+    )
+  }
+
   // ─── render ───────────────────────────────────────────────────────────────
 
   return (
@@ -483,28 +617,50 @@ export default function Documents() {
       {/* Page header */}
       <div className="flex items-center justify-between flex-wrap gap-3">
         <div>
+          {/* Breadcrumb */}
+          <nav className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
+            <Link to="/collections" className="inline-flex items-center gap-1 hover:text-indigo-300 transition">
+              <Database size={12} />
+              Collections
+            </Link>
+            <ChevronRight size={12} className="text-slate-600" />
+            <span className="font-mono text-slate-400">{effectiveCollection || '—'}</span>
+          </nav>
           <h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
           <p className="text-slate-400 text-sm mt-1">
             Project: <span className="text-slate-300 font-mono">{currentProject}</span>
             {docCount !== null && (
               <span className="ml-2 text-slate-500">
-                — {docCount.toLocaleString()} result{docCount !== 1 ? 's' : ''}
+                — showing {docCount.toLocaleString()}
+                {(canNext || offsetNum > 0) && ` (rows ${(offsetNum + 1).toLocaleString()}–${(offsetNum + docs.length).toLocaleString()})`}
               </span>
             )}
           </p>
         </div>
-        <button
-          onClick={() => {
-            setModalState({ mode: 'new', docId: '', json: '{}' })
-            setModalOpen(true)
-          }}
-          disabled={!effectiveCollection}
-          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 disabled:opacity-50"
-          data-testid="new-doc-btn"
-        >
-          <Plus size={16} />
-          New document
-        </button>
+        <div className="flex items-center gap-2">
+          <button
+            onClick={() => void refetchDocs()}
+            disabled={!effectiveCollection || docsFetching}
+            className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
+            data-testid="docs-refresh-btn"
+            title="Refresh documents"
+          >
+            <RefreshCw size={15} className={docsFetching ? 'animate-spin' : ''} />
+            Refresh
+          </button>
+          <button
+            onClick={() => {
+              setModalState({ mode: 'new', docId: '', json: '{}' })
+              setModalOpen(true)
+            }}
+            disabled={!effectiveCollection}
+            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 disabled:opacity-50"
+            data-testid="new-doc-btn"
+          >
+            <Plus size={16} />
+            New document
+          </button>
+        </div>
       </div>
 
       {/* Controls row */}
@@ -525,8 +681,32 @@ export default function Documents() {
           onValueChange={setFilterValue}
           onLimitChange={setLimit}
           onOffsetChange={setOffset}
-          onSearch={handleSearch}
+          onSearch={() => applySearch()}
         />
+        {/* Sort row — ordering is applied server-side */}
+        <div className="flex flex-wrap items-center gap-2">
+          <label className="text-xs font-medium text-slate-400 shrink-0">Sort by</label>
+          <input
+            type="text"
+            placeholder="field (e.g. id, name)"
+            value={sortField}
+            onChange={(e) => setSortField(e.target.value)}
+            onKeyDown={(e) => { if (e.key === 'Enter') applySearch() }}
+            className="rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 w-44 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+            data-testid="doc-sort-field"
+          />
+          <button
+            onClick={() => applySearch({ sortDesc: !sortDesc })}
+            disabled={!sortField.trim()}
+            className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40"
+            data-testid="doc-sort-dir"
+            title="Toggle sort direction"
+          >
+            {sortDesc ? <ArrowDown size={14} /> : <ArrowUp size={14} />}
+            {sortDesc ? 'Desc' : 'Asc'}
+          </button>
+          <span className="text-[11px] text-slate-600">applied server-side</span>
+        </div>
       </div>
 
       {/* Results */}
@@ -546,7 +726,43 @@ export default function Documents() {
       )}
 
       {!findLoading && !findError && (
-        <ResultsTable docs={docs} onRowClick={(doc) => void handleRowClick(doc)} />
+        <ResultsTable
+          docs={docs}
+          onRowClick={(doc) => void handleRowClick(doc)}
+          onCopy={(text, label) => void handleCopy(text, label)}
+          sortField={sortField}
+          sortDesc={sortDesc}
+          onSortById={toggleSortById}
+        />
+      )}
+
+      {/* Pagination */}
+      {!findLoading && !findError && (canPrev || canNext) && (
+        <div className="flex items-center justify-between text-sm">
+          <span className="text-slate-500 text-xs">
+            Page {Math.floor(offsetNum / limitNum) + 1}
+          </span>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={() => applySearch({ offset: String(Math.max(0, offsetNum - limitNum)) })}
+              disabled={!canPrev}
+              className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
+              data-testid="docs-prev-btn"
+            >
+              <ChevronLeft size={14} />
+              Prev
+            </button>
+            <button
+              onClick={() => applySearch({ offset: String(offsetNum + limitNum) })}
+              disabled={!canNext}
+              className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
+              data-testid="docs-next-btn"
+            >
+              Next
+              <ChevronRight size={14} />
+            </button>
+          </div>
+        </div>
       )}
 
       {/* Editor modal */}

+ 14 - 3
webui/src/pages/RagTester.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
 import { useQuery } from '@tanstack/react-query'
-import { Plus, Search } from 'lucide-react'
+import { Plus, Search, Database } from 'lucide-react'
 import { api } from '@/api/client'
 import { useAuthStore } from '@/stores/authStore'
 import { useToastStore } from '@/stores/toastStore'
@@ -340,7 +341,10 @@ function SearchSection({ project, collection }: SearchSectionProps) {
 export default function RagTester() {
   const currentProject = useAuthStore((s) => s.currentProject)
 
-  const [selectedColl, setSelectedColl] = useState('')
+  // Pre-select the collection from `?collection=` when arriving from a link
+  // (e.g. the RAG action on the Collections page).
+  const [searchParams] = useSearchParams()
+  const [selectedColl, setSelectedColl] = useState(searchParams.get('collection') ?? '')
 
   // Fetch all collections, filter to vector only
   const { data: collectionsData, isLoading: collectionsLoading } = useQuery<{ collections: CollectionMeta[] }>({
@@ -400,11 +404,18 @@ export default function RagTester() {
 
       {/* No vector collections notice */}
       {!collectionsLoading && vectorCollections.length === 0 && (
-        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
+        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center flex flex-col items-center gap-4">
           <p className="text-slate-400 text-sm">
             No vector collections in this project. Create a collection with kind&nbsp;
             <span className="font-mono text-indigo-300">vector</span> to use the RAG tester.
           </p>
+          <Link
+            to="/collections"
+            className="inline-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"
+          >
+            <Database size={16} />
+            Go to Collections
+          </Link>
         </div>
       )}