Browse Source

feat: collection settings, deletion and a database page that lists anything again

The database page had been showing "No collections" since the client was
upgraded, with all the data sitting there untouched. The server changed
how it answers: your own project's collections now come back unqualified
and everybody else's carry their project prefix. The adapter only
accepted the prefixed form, so it matched nothing.

Accepting every unqualified name instead would have been worse - it put
another service's collections on our page, because the shared default
project's collections arrive unqualified too, as do entries for
collections that have been dropped. So a bare name is now asked about
rather than guessed at: a lookup is project-scoped and answers only for a
collection that really is ours.

The listing carries sizes and document counts with it, so the page no
longer has to ask about each collection separately to show anything.

The page gains a settings panel per collection: how many documents, how
much disk, TTL, encryption, how many versions are kept, whether version
history is on, and the timestamp precision - the last two changeable.
Protected collections say why they cannot be changed instead of offering
controls that fail.

Deleting a collection is there too, behind typing its name, with the
count of documents that will go with it stated on the button's own terms.

Two bugs found while testing it, both of which made a working action look
broken:

- The delete invalidated a query key that does not exist ('collections'
  rather than 'database-collections'), so a deleted collection stayed in
  the sidebar until a reload.
- The panel could not be closed with Escape or by clicking away, which is
  how every other panel here behaves.

Verified in the browser: an ordinary collection shows its details, its
version history can be switched off and the change sticks, the delete
button stays refused until the name matches exactly, and afterwards the
collection is gone from the sidebar and the selection is cleared. A
protected collection offers no delete and its settings are disabled.
fszontagh 1 month ago
parent
commit
d172ccb401

+ 29 - 2
lib/storage/storage_client.cpp

@@ -237,6 +237,19 @@ Result<void> StorageClient::createCollection(const std::string& name,
     return {};
 }
 
+std::vector<CollectionInfo> StorageClient::listCollectionInfos() {
+    std::vector<CollectionInfo> out;
+    for (const auto& name : listCollections()) {
+        auto info = getCollectionInfo(name);
+        if (info.ok()) {
+            // The name as this project knows it, not as the server spelled it.
+            info.value().name = name;
+            out.push_back(info.value());
+        }
+    }
+    return out;
+}
+
 Result<CollectionInfo> StorageClient::getCollectionInfo(const std::string& name) {
     auto upstream = impl_->client_->getCollectionInfo(name);
     if (!upstream.has_value()) {
@@ -299,11 +312,25 @@ std::vector<std::string> StorageClient::listCollections() {
     for (const auto& name : impl_->client_->listCollections()) {
         if (name.rfind(prefix, 0) == 0) {
             out.push_back(name.substr(prefix.size()));
-        } else if (is_default && name.find(':') == std::string::npos) {
-            // Legacy unqualified collections belong to the default project.
+            continue;
+        }
+        if (name.find(':') != std::string::npos) {
+            continue;  // qualified with somebody else's project
+        }
+        // From client 2.4.5 the server answers with a mixture: some names
+        // qualified, some bare. A bare name is NOT reliably ours - the shared
+        // default project's collections arrive bare too, as do entries for
+        // collections that have been dropped. Accepting them all put another
+        // service's data on our database page; accepting none showed "No
+        // collections" while the data was there the whole time.
+        //
+        // So the name is asked about rather than guessed at: a lookup is
+        // project-scoped, and answers only for a collection that is really ours.
+        if (getCollectionInfo(name).ok()) {
             out.push_back(name);
         }
     }
+    (void)is_default;
 
     std::sort(out.begin(), out.end());
     out.erase(std::unique(out.begin(), out.end()), out.end());

+ 5 - 0
lib/storage/storage_client.hpp

@@ -187,6 +187,11 @@ public:
     std::vector<std::string> listCollections();
     common::Result<CollectionInfo> getCollectionInfo(const std::string& name);
 
+    // Every collection in this project, with its size and document count.
+    // Costs one lookup per candidate name, so it is for an admin screen rather
+    // than a hot path.
+    std::vector<CollectionInfo> listCollectionInfos();
+
     common::Result<nlohmann::json> getVersion(const std::string& collection,
                                               const std::string& id,
                                               int64_t version);

+ 21 - 2
src/webserver/api/database_controller.cpp

@@ -244,9 +244,28 @@ void DatabaseController::updateCollectionConfig(const httplib::Request& req, htt
 
 void DatabaseController::listCollections(const httplib::Request& req, httplib::Response& res,
                                           const auth::AuthContext& ctx) {
-    auto collections = storage_.listCollections();
+    (void)req;
+    (void)ctx;
+
+    // Names alone meant the page had to ask about each collection separately to
+    // show anything about it. The sizes come from the same walk.
+    nlohmann::json details = nlohmann::json::array();
+    nlohmann::json names = nlohmann::json::array();
+    for (const auto& info : storage_.listCollectionInfos()) {
+        names.push_back(info.name);
+        details.push_back({
+            {"name", info.name},
+            {"documentCount", info.document_count},
+            {"sizeBytes", info.size_bytes},
+            {"defaultTtlMs", info.default_ttl_ms},
+            {"encrypted", info.encrypted},
+            {"maxVersions", info.max_versions},
+            {"protection", protectionFor(info.name) == Protection::Secret ? "secret"
+                         : protectionFor(info.name) == Protection::Structural ? "structural" : "none"},
+        });
+    }
 
-    sendJson(res, {{"collections", collections}});
+    sendJson(res, {{"collections", names}, {"details", details}});
 }
 
 void DatabaseController::createCollection(const httplib::Request& req, httplib::Response& res,

+ 38 - 0
webui/src/api/workflows.ts

@@ -260,12 +260,50 @@ export interface CollectionSettings {
   keepOnDelete?: boolean
 }
 
+/** What the database knows about a collection itself, rather than its documents. */
+export interface CollectionInfo {
+  name: string
+  documentCount: number
+  sizeBytes: number
+  defaultTtlMs: number
+  encrypted: boolean
+  maxVersions: number
+  createdAt: number
+  updatedAt: number
+  timestampPrecision?: string
+  versioningEnabled?: boolean
+  /** "secret" and "structural" collections belong to SmartBotic, not to you. */
+  protection: 'none' | 'structural' | 'secret'
+}
+
 export const databaseApi = {
   listCollections: async () => {
     const response = await api.get('/database/collections')
     return response.data
   },
 
+  getCollection: async (collection: string): Promise<CollectionInfo> => {
+    const response = await api.get(`/database/collections/${collection}`)
+    return response.data
+  },
+
+  configureCollection: async (
+    collection: string,
+    config: { versioningEnabled?: boolean; timestampPrecision?: 'ms' | 'ns' }
+  ): Promise<CollectionInfo> => {
+    const response = await api.put(`/database/collections/${collection}/config`, config)
+    return response.data
+  },
+
+  // The name is repeated as ?confirm= on purpose: this deletes every document
+  // in the collection and cannot be undone.
+  deleteCollection: async (collection: string) => {
+    const response = await api.delete(`/database/collections/${collection}`, {
+      params: { confirm: collection },
+    })
+    return response.data
+  },
+
   createCollection: async (name: string, settings?: CollectionSettings) => {
     const response = await api.post('/database/collections', {
       name,

+ 206 - 1
webui/src/pages/DatabasePage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { databaseApi, CollectionSettings } from '../api/workflows'
+import { databaseApi, CollectionSettings, CollectionInfo } from '../api/workflows'
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
 import Editor from '@monaco-editor/react'
 import {
@@ -51,6 +51,19 @@ const SYSTEM_COLLECTIONS = [
 
 const PAGE_SIZE = 25
 
+// Bytes as something a person can read at a glance.
+function formatBytes(bytes: number): string {
+  if (!bytes || bytes < 0) return '0 B'
+  const units = ['B', 'KB', 'MB', 'GB', 'TB']
+  let value = bytes
+  let unit = 0
+  while (value >= 1024 && unit < units.length - 1) {
+    value /= 1024
+    unit++
+  }
+  return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
+}
+
 export default function DatabasePage() {
   const queryClient = useQueryClient()
   const { resolvedTheme } = useTheme()
@@ -73,6 +86,17 @@ export default function DatabasePage() {
   const documentListRef = useRef<HTMLDivElement>(null)
 
   const [justCreated, setJustCreated] = useState<string[]>([])
+  const [showCollectionSettings, setShowCollectionSettings] = useState(false)
+  const [dropConfirmText, setDropConfirmText] = useState('')
+
+  useEffect(() => {
+    if (!showCollectionSettings) return
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setShowCollectionSettings(false)
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [showCollectionSettings])
 
   const { data: collectionsData, isLoading: collectionsLoading } = useQuery({
     queryKey: ['database-collections'],
@@ -317,6 +341,42 @@ export default function DatabasePage() {
   // list comes back as an error rather than an empty page.
   const isEmptyNewCollection =
     !!selectedCollection && justCreated.includes(selectedCollection) && documents.length === 0
+  // What the database says about the collection itself. Only fetched when one
+  // is open, because it is a per-collection call.
+  const { data: collectionInfo } = useQuery<CollectionInfo>({
+    queryKey: ['collection-info', selectedCollection],
+    queryFn: () => databaseApi.getCollection(selectedCollection!),
+    enabled: !!selectedCollection,
+  })
+
+  const configureMutation = useMutation({
+    mutationFn: (config: { versioningEnabled?: boolean; timestampPrecision?: 'ms' | 'ns' }) =>
+      databaseApi.configureCollection(selectedCollection!, config),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['collection-info', selectedCollection] })
+    },
+  })
+
+  const dropCollectionMutation = useMutation({
+    mutationFn: (name: string) => databaseApi.deleteCollection(name),
+    onSuccess: (_res, name) => {
+      // The list is keyed 'database-collections'; invalidating 'collections'
+      // matched nothing, so a deleted collection stayed in the sidebar until a
+      // reload and looked like the delete had failed.
+      queryClient.invalidateQueries({ queryKey: ['database-collections'] })
+      // It was also remembered as one created in this session, which would put
+      // it straight back even after a refetch.
+      setJustCreated((current) => current.filter((c) => c !== name))
+      setShowCollectionSettings(false)
+      setDropConfirmText('')
+      // Whatever was on screen belonged to a collection that no longer exists.
+      if (selectedCollection === name) {
+        setSelectedCollection(null)
+        setEditingDocument(null)
+      }
+    },
+  })
+
   const totalDocuments = documentsData?.total || 0
   const totalPages = Math.ceil(totalDocuments / PAGE_SIZE)
 
@@ -503,6 +563,13 @@ export default function DatabasePage() {
               >
                 <Plus className="w-4 h-4" />
               </button>
+              <button
+                onClick={() => { setDropConfirmText(''); setShowCollectionSettings(true) }}
+                className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
+                title="Collection settings"
+              >
+                <Settings className="w-4 h-4" />
+              </button>
             </div>
           )}
         </div>
@@ -832,6 +899,144 @@ export default function DatabasePage() {
         </div>
       )}
 
+      {/* Collection settings: what the database knows about the collection
+          itself, and the two things about it that can be changed. */}
+      {showCollectionSettings && selectedCollection && (
+        <div
+          className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+          // Escape and a click on the backdrop both close it, because a panel
+          // that traps you until you find the right button is the kind of thing
+          // people stop opening.
+          onClick={() => setShowCollectionSettings(false)}
+        >
+          <div
+            className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
+              <h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
+                {selectedCollection}
+              </h3>
+              <button
+                onClick={() => setShowCollectionSettings(false)}
+                className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            <div className="p-5 space-y-5 text-sm">
+              {!collectionInfo ? (
+                <p className="text-gray-500 dark:text-gray-400">Reading collection details...</p>
+              ) : (
+                <>
+                  <dl className="grid grid-cols-2 gap-x-4 gap-y-2">
+                    <dt className="text-gray-500 dark:text-gray-400">Documents</dt>
+                    <dd className="text-gray-900 dark:text-gray-100">{collectionInfo.documentCount.toLocaleString()}</dd>
+                    <dt className="text-gray-500 dark:text-gray-400">Size on disk</dt>
+                    <dd className="text-gray-900 dark:text-gray-100">{formatBytes(collectionInfo.sizeBytes)}</dd>
+                    <dt className="text-gray-500 dark:text-gray-400">Default TTL</dt>
+                    <dd className="text-gray-900 dark:text-gray-100">
+                      {collectionInfo.defaultTtlMs > 0
+                        ? `${Math.round(collectionInfo.defaultTtlMs / 3600000)} hours`
+                        : 'kept forever'}
+                    </dd>
+                    <dt className="text-gray-500 dark:text-gray-400">Encrypted</dt>
+                    <dd className="text-gray-900 dark:text-gray-100">{collectionInfo.encrypted ? 'yes' : 'no'}</dd>
+                    <dt className="text-gray-500 dark:text-gray-400">Versions kept</dt>
+                    <dd className="text-gray-900 dark:text-gray-100">
+                      {collectionInfo.maxVersions > 0 ? collectionInfo.maxVersions : 'unlimited'}
+                    </dd>
+                  </dl>
+
+                  {collectionInfo.protection !== 'none' && (
+                    <div className="flex items-start gap-2 px-3 py-2 rounded bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300">
+                      <Shield className="w-4 h-4 mt-0.5 shrink-0" />
+                      <span>
+                        {collectionInfo.protection === 'secret'
+                          ? 'This collection holds credentials or session material. It is not readable or changeable here.'
+                          : 'SmartBotic itself depends on this collection. It can be read here, but changing it has to go through the parts of the app that own it.'}
+                      </span>
+                    </div>
+                  )}
+
+                  <div className="space-y-3">
+                    <label className="flex items-start gap-3">
+                      <input
+                        type="checkbox"
+                        className="mt-1"
+                        checked={collectionInfo.versioningEnabled === true}
+                        disabled={collectionInfo.protection !== 'none' || configureMutation.isPending}
+                        onChange={(e) => configureMutation.mutate({ versioningEnabled: e.target.checked })}
+                      />
+                      <span>
+                        <span className="text-gray-900 dark:text-gray-100">Keep a version history</span>
+                        <span className="block text-xs text-gray-500 dark:text-gray-400">
+                          Turning this off stops new versions being recorded. History already written
+                          stays readable, so it can be switched back on.
+                        </span>
+                      </span>
+                    </label>
+
+                    <div>
+                      <span className="text-gray-900 dark:text-gray-100">Timestamp precision</span>
+                      <span className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
+                        Changing this affects newly written documents only - the ones already stored
+                        keep whatever they had.
+                      </span>
+                      <select
+                        value={collectionInfo.timestampPrecision || 'ms'}
+                        disabled={collectionInfo.protection !== 'none' || configureMutation.isPending}
+                        onChange={(e) => configureMutation.mutate({ timestampPrecision: e.target.value as 'ms' | 'ns' })}
+                        className="px-2 py-1 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
+                      >
+                        <option value="ms">milliseconds</option>
+                        <option value="ns">nanoseconds</option>
+                      </select>
+                    </div>
+                  </div>
+
+                  {configureMutation.isError && (
+                    <p className="text-red-600 dark:text-red-400">
+                      {(configureMutation.error as any)?.response?.data?.error || 'That change was refused'}
+                    </p>
+                  )}
+
+                  {collectionInfo.protection === 'none' && (
+                    <div className="pt-4 border-t border-gray-200 dark:border-slate-700 space-y-2">
+                      <p className="text-gray-900 dark:text-gray-100 font-medium">Delete this collection</p>
+                      <p className="text-xs text-gray-500 dark:text-gray-400">
+                        Removes the collection and all {collectionInfo.documentCount.toLocaleString()} of
+                        its documents. There is no undo, so the name has to be typed to confirm.
+                      </p>
+                      <input
+                        type="text"
+                        value={dropConfirmText}
+                        onChange={(e) => setDropConfirmText(e.target.value)}
+                        placeholder={selectedCollection}
+                        className="w-full px-2 py-1 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+                      />
+                      <button
+                        onClick={() => dropCollectionMutation.mutate(selectedCollection)}
+                        disabled={dropConfirmText !== selectedCollection || dropCollectionMutation.isPending}
+                        className="px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
+                      >
+                        {dropCollectionMutation.isPending ? 'Deleting...' : 'Delete collection'}
+                      </button>
+                      {dropCollectionMutation.isError && (
+                        <p className="text-red-600 dark:text-red-400">
+                          {(dropCollectionMutation.error as any)?.response?.data?.error || 'Could not delete it'}
+                        </p>
+                      )}
+                    </div>
+                  )}
+                </>
+              )}
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* New Collection Modal */}
       {showNewCollectionModal && (
         <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">