'use client' import * as React from 'react' import { useT } from '@open-mercato/shared/lib/i18n/context' import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { Button } from '@open-mercato/ui/primitives/button' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primitives/tabs' // Types type FulltextStats = { numberOfDocuments: number isIndexing: boolean fieldDistribution: Record } type ReindexLock = { type: 'fulltext' | 'vector' action: string startedAt: string elapsedMinutes: number } type FulltextEnvVarStatus = { set: boolean hint: string } type FulltextOptionalEnvVarStatus = { set: boolean value?: string | boolean default?: string | boolean hint: string } type FulltextConfigResponse = { driver: 'meilisearch' | null configured: boolean envVars: { MEILISEARCH_HOST: FulltextEnvVarStatus MEILISEARCH_API_KEY: FulltextEnvVarStatus } optionalEnvVars: { MEILISEARCH_INDEX_PREFIX: FulltextOptionalEnvVarStatus SEARCH_EXCLUDE_ENCRYPTED_FIELDS: FulltextOptionalEnvVarStatus } } type ReindexResponse = { ok: boolean action: string entityId?: string | null result?: { entitiesProcessed: number recordsIndexed: number errors?: Array<{ entityId: string; error: string }> } stats?: FulltextStats | null error?: string } type ReindexAction = 'clear' | 'recreate' | 'reindex' type ActivityLog = { id: string source: string handler: string level: 'info' | 'error' | 'warn' entityType: string | null recordId: string | null message: string details: unknown occurredAt: string } export type FulltextSearchSectionProps = { fulltextConfig: FulltextConfigResponse | null fulltextConfigLoading: boolean fulltextStats: FulltextStats | null fulltextReindexLock: ReindexLock | null loading: boolean onStatsUpdate: (stats: FulltextStats | null) => void onRefresh: () => Promise } const normalizeErrorMessage = (error: unknown, fallback: string): string => { if (typeof error === 'string' && error.trim().length) return error.trim() if (error instanceof Error && error.message.trim().length) return error.message.trim() return fallback } export function FulltextSearchSection({ fulltextConfig, fulltextConfigLoading, fulltextStats, fulltextReindexLock, loading, onStatsUpdate, onRefresh, }: FulltextSearchSectionProps) { const t = useT() const [reindexing, setReindexing] = React.useState(null) const [showReindexDialog, setShowReindexDialog] = React.useState(null) const [activityLogs, setActivityLogs] = React.useState([]) const [activityLoading, setActivityLoading] = React.useState(true) // Fetch activity logs const fetchActivityLogs = React.useCallback(async () => { setActivityLoading(true) try { const response = await fetch('/api/query_index/status') if (response.ok) { const body = await response.json() as { logs?: ActivityLog[]; errors?: ActivityLog[] } // Combine logs and errors const allLogs: ActivityLog[] = [] if (body.logs) { allLogs.push(...body.logs) } if (body.errors) { allLogs.push(...body.errors.map(err => ({ ...err, level: 'error' as const }))) } // Filter for fulltext-related logs (exclude vector/embedding related) const fulltextLogs = allLogs.filter(log => { const lowerSource = log.source?.toLowerCase() ?? '' const lowerMessage = log.message?.toLowerCase() ?? '' const lowerHandler = log.handler?.toLowerCase() ?? '' const isVector = lowerSource.includes('vector') || lowerMessage.includes('vector') || lowerMessage.includes('embedding') || lowerHandler.includes('vector') return !isVector }) // Sort by occurredAt descending fulltextLogs.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()) setActivityLogs(fulltextLogs.slice(0, 50)) } } catch { // Silently fail } finally { setActivityLoading(false) } }, []) React.useEffect(() => { fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('progress.job.updated', () => { void fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('progress.job.completed', () => { void fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('om:bridge:reconnected', () => { void fetchActivityLogs() }, [fetchActivityLogs]) const handleReindexClick = (action: ReindexAction) => { setShowReindexDialog(action) } const handleReindexCancel = () => { setShowReindexDialog(null) } const handleReindexConfirm = React.useCallback(async () => { const action = showReindexDialog if (!action) return setShowReindexDialog(null) setReindexing(action) try { const response = await fetch('/api/search/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, useQueue: action === 'reindex' }), }) const body = await response.json() as ReindexResponse if (!response.ok || body.error) { throw new Error(body.error || t('search.settings.reindexErrorLabel', 'Failed to reindex')) } if (body.stats) { onStatsUpdate(body.stats) } const successLabel = t('search.settings.reindexSuccessLabel', 'Operation completed successfully') const successMessage = action === 'reindex' && body.result ? `${successLabel}: ${body.result.recordsIndexed} documents indexed` : successLabel flash(successMessage, 'success') await onRefresh() await fetchActivityLogs() } catch (err) { const message = normalizeErrorMessage(err, t('search.settings.reindexErrorLabel', 'Failed to reindex')) flash(message, 'error') } finally { setReindexing(null) } }, [showReindexDialog, t, onStatsUpdate, onRefresh, fetchActivityLogs]) const getDialogContent = (action: ReindexAction) => { switch (action) { case 'clear': return { title: t('search.settings.clearIndexDialogTitle', 'Clear Index'), description: t('search.settings.clearIndexDialogDescription', 'This will remove all documents from the Meilisearch index but keep the index settings.'), warning: t('search.settings.clearIndexDialogWarning', 'Search will not work until documents are re-indexed.'), confirmLabel: t('search.settings.clearIndexLabel', 'Clear Index'), } case 'recreate': return { title: t('search.settings.recreateIndexDialogTitle', 'Recreate Index'), description: t('search.settings.recreateIndexDialogDescription', 'This will delete the index completely and recreate it with fresh settings.'), warning: t('search.settings.recreateIndexDialogWarning', 'All indexed documents will be permanently removed.'), confirmLabel: t('search.settings.recreateIndexLabel', 'Recreate Index'), } case 'reindex': return { title: t('search.settings.fullReindexDialogTitle', 'Full Reindex'), description: t('search.settings.fullReindexDialogDescription', 'This will recreate the index and re-index all data from the database.'), warning: t('search.settings.fullReindexDialogWarning', 'This operation may take a while depending on the amount of data.'), confirmLabel: t('search.settings.fullReindexLabel', 'Full Reindex'), } } } const getStrategyIcon = () => ( ) return (

{t('search.settings.fulltext.sectionTitle', 'Full-Text Search')}

{t('search.settings.fulltext.sectionDescription', 'Fast, typo-tolerant search using Meilisearch.')}

{t('search.settings.tabs.configuration', 'Configuration')} {t('search.settings.tabs.indexManagement', 'Index Management')} {t('search.settings.tabs.activity', 'Activity')} {/* Configuration Tab */} {fulltextConfigLoading ? (
{t('search.settings.loadingLabel', 'Loading settings...')}
) : (
{/* Driver Status */}
{getStrategyIcon()}

{t('search.settings.fulltext.driver', 'Current Driver')}: {fulltextConfig?.driver ? 'Meilisearch' : t('search.settings.fulltext.noDriver', 'None')}

{fulltextConfig?.configured ? t('search.settings.fulltext.ready', 'Ready to use') : t('search.settings.fulltext.notReady', 'Not configured - set environment variables below')}

{/* Required Environment Variables */}

{t('search.settings.fulltext.envVars', 'Required Environment Variables')}

{fulltextConfig?.envVars && Object.entries(fulltextConfig.envVars).map(([key, status]) => (
{status.set ? ( ) : ( )}
{key} {status.set ? t('search.settings.fulltext.envSet', 'Set') : t('search.settings.fulltext.envMissing', 'Missing')}

{status.hint}

))}
{/* Optional Settings */}

{t('search.settings.fulltext.optional', 'Optional Settings')}

{fulltextConfig?.optionalEnvVars && Object.entries(fulltextConfig.optionalEnvVars).map(([key, status]) => (
{key}
{status.hint} {status.set ? ( ({t('search.settings.fulltext.currentValue', 'Current')}: {String(status.value)}) ) : ( ({t('search.settings.fulltext.defaultValue', 'Default')}: {String(status.default)}) )}
))}
{/* Setup Instructions */}

{t('search.settings.fulltext.howTo', 'How to set up')}

{t('search.settings.fulltext.howToDescription', 'Add these variables to your .env file or deployment environment. You can use a hosted Meilisearch instance or run it locally with Docker.')}

{t('search.settings.fulltext.learnMore', 'Learn more: Meilisearch Quick Start')} →
)}
{/* Index Management Tab */} {(loading || fulltextConfigLoading) ? (
{t('search.settings.loadingLabel', 'Loading settings...')}
) : !fulltextConfig?.configured ? (

{t('search.settings.fulltextNotConfigured', 'Full-text search driver not configured')}

{t('search.settings.fulltextNotConfiguredHint', 'Configure the required environment variables in the Configuration tab to enable indexing.')}

) : (
{/* Stats */} {fulltextStats ? (

{t('search.settings.documentsLabel', 'Documents')}

{fulltextStats.numberOfDocuments.toLocaleString()}

) : (

{t('search.settings.noIndexMessage', "No index found for this tenant. Click 'Full Reindex' to create one.")}

)} {/* Active reindex lock banner */} {fulltextReindexLock && (

{t('search.settings.reindexInProgress', 'Reindex operation in progress')}

{t('search.settings.reindexInProgressDetails', 'Action: {{action}} | Started {{minutes}} minutes ago', { action: fulltextReindexLock.action, minutes: fulltextReindexLock.elapsedMinutes, })}

)} {/* Actions */}
{fulltextStats && ( <>
{t('search.settings.clearIndexDescription', 'Remove all documents but keep index settings')}
{t('search.settings.recreateIndexDescription', 'Delete and recreate the index with fresh settings')}
)}
{t('search.settings.fullReindexDescription', 'Recreate index and re-index all data from database')}
)}
{/* Activity Tab */} {activityLoading ? (
{t('search.settings.loadingLabel', 'Loading...')}
) : activityLogs.length === 0 ? (

{t('search.settings.activity.noLogs', 'No recent indexing activity')}

) : (
{activityLogs.map((log) => (
{log.level === 'error' && ( )}

{log.message}

{(() => { const d = new Date(log.occurredAt) const pad = (n: number) => n.toString().padStart(2, '0') return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}` })()} {log.entityType && ` · ${log.entityType}`}

))}
)}
{/* Reindex Confirmation Dialog */} {showReindexDialog && (

{getDialogContent(showReindexDialog).title}

{getDialogContent(showReindexDialog).description}

{getDialogContent(showReindexDialog).warning}

)}
) } export default FulltextSearchSection