/** * Search page — exact port of Angular search.component. * RediSearch: index selection, query with AI fallback, results with paging, * index info, create/drop index. */ import { useState, useEffect, useCallback } from 'react' import { Box, TextField, MenuItem, Select, FormControl, InputLabel, Button, Tooltip, List, ListItem, Divider, useMediaQuery, useTheme, } from '@mui/material' import { Search, Delete, Add, Remove, SkipPrevious, SkipNext, KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore } from '../../stores/redis-state.store' import { useCommonStore } from '../../stores/common.store' import { useOverlayStore } from '../../stores/overlay.store' import { request } from '../../stores/socket.service' import P3xrAccordion from '../../components/P3xrAccordion' import P3xrButton from '../../components/P3xrButton' interface SchemaField { name: string; type: string; sortable: boolean } export default function SearchPage() { const strings = useI18nStore(s => s.strings) const connection = useRedisStateStore(s => s.connection) const { toast, confirm, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isGtSm = useMediaQuery('(min-width: 960px)') const isReadonly = connection?.readonly === true const [indexes, setIndexes] = useState([]) const [selectedIndex, setSelectedIndex] = useState('') const [query, setQuery] = useState('*') const [offset, setOffset] = useState(0) const limit = 20 const [total, setTotal] = useState(0) const [results, setResults] = useState([]) const [indexInfo, setIndexInfo] = useState(null) const [searchDone, setSearchDone] = useState(false) const [aiLoading, setAiLoading] = useState(false) // Create index const [newIndexName, setNewIndexName] = useState('') const [newIndexPrefix, setNewIndexPrefix] = useState('') const [newIndexFields, setNewIndexFields] = useState([{ name: '', type: 'TEXT', sortable: false }]) const pages = Math.ceil(total / limit) const currentPage = Math.floor(offset / limit) + 1 const getDocKeys = (doc: any) => Object.keys(doc).filter(k => k !== '_key') // --- Load indexes --- const loadIndexes = useCallback(async () => { try { const resp = await request({ action: 'search-list', payload: {} }) setIndexes(resp.data) return resp.data as string[] } catch { return [] } }, []) const loadIndexInfo = useCallback(async (idx?: string) => { const index = idx || selectedIndex if (!index) return try { const resp = await request({ action: 'search-index-info', payload: { index } }) setIndexInfo(resp.data) } catch (e) { generalHandleError(e) } }, [selectedIndex, generalHandleError]) const doSearch = useCallback(async (off?: number) => { if (!selectedIndex || !query) return try { const resp = await request({ action: 'search-query', payload: { index: selectedIndex, query, offset: off ?? offset, limit }, }) setTotal(resp.data.total) setResults(resp.data.docs) } catch (e) { generalHandleError(e) setResults([]); setTotal(0) } finally { setSearchDone(true) } }, [selectedIndex, query, offset, generalHandleError]) const handleAiQuery = useCallback(async (prompt: string) => { if (!prompt) return setAiLoading(true) try { const resp = await request({ action: 'ai-redis-query', payload: { prompt, context: { indexes, schema: indexInfo } }, }) setQuery(resp.command || '*') if (resp.explanation) toast(resp.explanation) setOffset(0) // Search with new query const sr = await request({ action: 'search-query', payload: { index: selectedIndex, query: resp.command || '*', offset: 0, limit } }) setTotal(sr.data.total); setResults(sr.data.docs); setSearchDone(true) await loadIndexInfo() } catch (e) { generalHandleError(e) } finally { setAiLoading(false) } }, [indexes, indexInfo, selectedIndex, toast, loadIndexInfo, generalHandleError]) const handleSearchEnter = useCallback(async () => { const q = (query || '').trim() if (/^ai:\s*/i.test(q)) { await handleAiQuery(q.replace(/^ai:\s*/i, '').trim()); return } try { await Promise.all([doSearch(0), loadIndexInfo()]) } catch { if (q.length > 2 && q !== '*' && /\s/.test(q)) { overlay.show() try { await handleAiQuery(q) } finally { overlay.hide() } } } }, [query, doSearch, loadIndexInfo, handleAiQuery, overlay]) const pageAction = useCallback((action: string) => { let newOffset = offset switch (action) { case 'first': newOffset = 0; break case 'prev': newOffset = Math.max(0, offset - limit); break case 'next': newOffset = Math.min((pages - 1) * limit, offset + limit); break case 'last': newOffset = (pages - 1) * limit; break } setOffset(newOffset) doSearch(newOffset) }, [offset, pages, doSearch]) const dropIndex = useCallback(async () => { if (!selectedIndex) return try { await confirm({ message: strings?.confirm?.dropIndex || 'Are you sure to drop this index?' }) await request({ action: 'search-index-drop', payload: { index: selectedIndex } }) toast(strings?.status?.indexDropped || 'Index dropped') setSelectedIndex(''); setResults([]); setTotal(0); setSearchDone(false); setIndexInfo(null) await loadIndexes() } catch (e: any) { if (e !== undefined) generalHandleError(e) } }, [selectedIndex, strings, confirm, toast, loadIndexes, generalHandleError]) const addField = () => setNewIndexFields(f => [...f, { name: '', type: 'TEXT', sortable: false }]) const confirmRemoveField = useCallback(async (index: number) => { try { await confirm({ message: (strings?.intention?.delete || 'Delete') + '?' }) setNewIndexFields(f => f.filter((_, i) => i !== index)) } catch (e: any) { if (e !== undefined) generalHandleError(e) } }, [strings, confirm, generalHandleError]) const createIndex = useCallback(async () => { if (!newIndexName.trim()) return const schema = newIndexFields.filter(f => f.name.trim()) if (schema.length === 0) return try { await request({ action: 'search-index-create', payload: { name: newIndexName.trim(), prefix: newIndexPrefix.trim() || undefined, schema }, }) toast(strings?.status?.indexCreated || 'Index created') setNewIndexName(''); setNewIndexPrefix('') setNewIndexFields([{ name: '', type: 'TEXT', sortable: false }]) await loadIndexes() } catch (e) { generalHandleError(e) } }, [newIndexName, newIndexPrefix, newIndexFields, strings, toast, loadIndexes, generalHandleError]) // --- Init + refresh on connection change --- const connectionId = connection?.id useEffect(() => { setSelectedIndex('') setResults([]) setTotal(0) setSearchDone(false) setIndexInfo(null) loadIndexes().then(idxs => { if (idxs.length > 0) { setSelectedIndex(idxs[0]) loadIndexInfo(idxs[0]) } }) }, [connectionId]) // eslint-disable-line react-hooks/exhaustive-deps const onIndexChange = (idx: string) => { setSelectedIndex(idx) setOffset(0); setIndexInfo(null) loadIndexInfo(idx) } // --- Render --- const s = strings?.page?.search || {} as any const ActionBtn = ({ icon, label, color = 'primary' as const, onClick, disabled }: { icon: React.ReactNode; label: string; color?: 'primary' | 'secondary'; onClick: () => void; disabled?: boolean }) => isGtSm ? ( ) : ( ) return ( {/* Search Query */} {indexes.length === 0 && ( {s.noIndex || 'No indexes found'} )} {indexes.length > 0 && ( <> {s.index || 'Index'} {!isReadonly && selectedIndex && ( )} setQuery(e.target.value)} disabled={aiLoading} onKeyDown={e => { if (e.key === 'Enter') { setOffset(0); handleSearchEnter() } }} /> } label={aiLoading ? (strings?.label?.aiTranslating || 'Translating...') : (s.title || 'Search')} onClick={() => { setOffset(0); handleSearchEnter() }} disabled={aiLoading} /> )} {/* Results - empty */} {searchDone && total === 0 && ( <> {strings?.label?.noResults || 'No results'} )} {/* Results - with data */} {(results.length > 0 || total > 0) && ( <> 1 ? (<> } label="" color="inherit" onClick={(e) => { e.stopPropagation(); pageAction('first') }} /> } label="" color="inherit" onClick={(e) => { e.stopPropagation(); pageAction('prev') }} /> {currentPage} / {pages} } label="" color="inherit" onClick={(e) => { e.stopPropagation(); pageAction('next') }} /> } label="" color="inherit" onClick={(e) => { e.stopPropagation(); pageAction('last') }} /> ) : undefined}> {results.map((doc: any) => ( {doc._key} {getDocKeys(doc).map((field, i) => ( {field}: {doc[field]} {i < getDocKeys(doc).length - 1 && ' \u00B7 '} ))} ))} )} {/* Index Info */} {selectedIndex && indexInfo && ( <> } label={s.dropIndex || 'Drop'} color="inherit" onClick={(e) => { e.stopPropagation(); dropIndex() }} /> ) : undefined}> {getDocKeys(indexInfo).map(key => ( {key} {JSON.stringify(indexInfo[key])} ))} )} {/* Create Index */} {!isReadonly && ( <> setNewIndexName(e.target.value)} /> setNewIndexPrefix(e.target.value)} /> Schema {newIndexFields.map((field, i) => ( { const f = [...newIndexFields]; f[i] = { ...f[i], name: e.target.value }; setNewIndexFields(f) }} /> {strings?.label?.type || 'Type'} ))} } label={s.createIndex || 'Create Index'} color="secondary" onClick={createIndex} disabled={!newIndexName.trim()} /> )} ) }