|
|
@@ -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">
|