From 2d8a02f257bbc056098713fa414b147e145aa5b9 Mon Sep 17 00:00:00 2001 From: Henry Estela Date: Tue, 28 Apr 2026 05:26:46 +0000 Subject: [PATCH] fix(RAG): add start button in kb modal and ensure restart policy exists (#700) Adds a check to RAG health to make sure nomad_qdrant is online, if not then the user will be blocked from clicking any buttons in the KB modal until they click the start qdrant button and let the container start There is a new file qdrant_restart_policy_provider.ts, which tries to ensure that the restart policy always exists for the nomad_qdrant container even though the policy should have been there when the container is created. --- admin/adonisrc.ts | 1 + admin/app/controllers/rag_controller.ts | 5 ++ admin/app/services/rag_service.ts | 23 ++++++- .../components/chat/KnowledgeBaseModal.tsx | 49 +++++++++++++-- admin/inertia/lib/api.ts | 7 +++ .../qdrant_restart_policy_provider.ts | 62 +++++++++++++++++++ admin/start/routes.ts | 1 + 7 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 admin/providers/qdrant_restart_policy_provider.ts diff --git a/admin/adonisrc.ts b/admin/adonisrc.ts index 37046d2..a091ce2 100644 --- a/admin/adonisrc.ts +++ b/admin/adonisrc.ts @@ -55,6 +55,7 @@ export default defineConfig({ () => import('@adonisjs/transmit/transmit_provider'), () => import('#providers/map_static_provider'), () => import('#providers/kiwix_migration_provider'), + () => import('#providers/qdrant_restart_policy_provider'), ], /* diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 149ba7e..c836393 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -97,4 +97,9 @@ export default class RagController { return response.status(500).json({ error: 'Error scanning and syncing storage' }) } } + + public async health({ response }: HttpContext) { + const result = await this.ragService.checkQdrantHealth() + return response.status(200).json(result) + } } diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 81145f8..bd5371d 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -52,14 +52,33 @@ export class RagService { this.qdrantInitPromise = (async () => { const qdrantUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT) if (!qdrantUrl) { - throw new Error('Qdrant service is not installed or running.') + throw new Error('Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.') } this.qdrant = new QdrantClient({ url: qdrantUrl }) - })() + })().catch((err) => { + this.qdrantInitPromise = null + this.qdrant = null + throw err + }) } return this.qdrantInitPromise } + public async checkQdrantHealth(): Promise<{ online: boolean; message?: string }> { + try { + await this._ensureDependencies() + await this.qdrant!.getCollections() + return { online: true } + } catch { + this.qdrant = null + this.qdrantInitPromise = null + return { + online: false, + message: 'Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.', + } + } + } + private async _ensureDependencies() { if (!this.qdrant) { await this._initializeQdrantClient() diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index e77a0c9..6230398 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import FileUploader from '~/components/file-uploader' import StyledButton from '~/components/StyledButton' import StyledSectionHeader from '~/components/StyledSectionHeader' @@ -10,6 +10,7 @@ import { IconX } from '@tabler/icons-react' import { useModals } from '~/context/ModalContext' import StyledModal from '../StyledModal' import ActiveEmbedJobs from '~/components/ActiveEmbedJobs' +import { SERVICE_NAMES } from '../../../constants/service_names' interface KnowledgeBaseModalProps { aiAssistantName?: string @@ -30,6 +31,19 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const { openModal, closeModal } = useModals() const queryClient = useQueryClient() + const [isStartingQdrant, setIsStartingQdrant] = useState(false) + + const { data: healthStatus } = useQuery({ + queryKey: ['qdrantHealth'], + queryFn: () => api.checkRAGHealth(), + refetchInterval: isStartingQdrant ? 3_000 : 30_000, + }) + const qdrantOffline = healthStatus?.online === false + + useEffect(() => { + if (!qdrantOffline) setIsStartingQdrant(false) + }, [qdrantOffline]) + const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({ queryKey: ['storedFiles'], queryFn: () => api.getStoredRAGFiles(), @@ -64,6 +78,17 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o }, }) + const startQdrantMutation = useMutation({ + mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'), + onSuccess: () => { + setIsStartingQdrant(true) + queryClient.invalidateQueries({ queryKey: ['qdrantHealth'] }) + }, + onError: (error: any) => { + addNotification({ type: 'error', message: error?.message || 'Failed to start Qdrant.' }) + }, + }) + const syncMutation = useMutation({ mutationFn: () => api.syncRAGStorage(), onSuccess: (data) => { @@ -149,6 +174,22 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
+ {qdrantOffline && ( +
+ + Knowledge Base unavailable: The Qdrant vector database is offline. + + startQdrantMutation.mutate()} + loading={startQdrantMutation.isPending || isStartingQdrant} + disabled={startQdrantMutation.isPending || isStartingQdrant} + > + {isStartingQdrant ? 'Starting…' : 'Start Qdrant'} + +
+ )}
Upload @@ -236,7 +277,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o icon="IconTrash" onClick={() => cleanupFailedMutation.mutate()} loading={cleanupFailedMutation.isPending} - disabled={cleanupFailedMutation.isPending} + disabled={cleanupFailedMutation.isPending || qdrantOffline} > Clean Up Failed @@ -252,7 +293,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o size="md" icon='IconRefresh' onClick={handleConfirmSync} - disabled={syncMutation.isPending || isUploading} + disabled={syncMutation.isPending || isUploading || qdrantOffline} loading={syncMutation.isPending || isUploading} > Sync Storage diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index dc1c7ed..0df95ae 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -451,6 +451,13 @@ class API { })() } + async checkRAGHealth() { + return catchInternal(async () => { + const response = await this.client.get<{ online: boolean; message?: string }>('/rag/health') + return response.data + })() + } + async getStoredRAGFiles() { return catchInternal(async () => { const response = await this.client.get<{ files: string[] }>('/rag/files') diff --git a/admin/providers/qdrant_restart_policy_provider.ts b/admin/providers/qdrant_restart_policy_provider.ts new file mode 100644 index 0000000..b909242 --- /dev/null +++ b/admin/providers/qdrant_restart_policy_provider.ts @@ -0,0 +1,62 @@ +import logger from '@adonisjs/core/services/logger' +import type { ApplicationService } from '@adonisjs/core/types' + +/** + * Ensures the nomad_qdrant container has the `unless-stopped` restart policy. + * + * Existing installations may have been created before this policy was enforced + * in the service seeder. Docker allows updating a container's restart policy + * without recreating it via the container.update() API. + * + * This provider runs once on every admin startup. If the policy is already + * correct, the check is a no-op. + */ +export default class QdrantRestartPolicyProvider { + constructor(protected app: ApplicationService) {} + + async boot() { + if (this.app.getEnvironment() !== 'web') return + + setImmediate(async () => { + try { + const Service = (await import('#models/service')).default + const { SERVICE_NAMES } = await import('../constants/service_names.js') + const Docker = (await import('dockerode')).default + + const qdrantService = await Service.query() + .where('service_name', SERVICE_NAMES.QDRANT) + .first() + + if (!qdrantService?.installed) { + logger.info('[QdrantRestartPolicyProvider] Qdrant not installed — skipping restart policy check.') + return + } + + const docker = new Docker({ socketPath: '/var/run/docker.sock' }) + const containers = await docker.listContainers({ all: true }) + const containerInfo = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.QDRANT}`)) + + if (!containerInfo) { + logger.warn('[QdrantRestartPolicyProvider] Qdrant container not found — skipping restart policy check.') + return + } + + const container = docker.getContainer(containerInfo.Id) + const inspected = await container.inspect() + const currentPolicy = inspected.HostConfig?.RestartPolicy?.Name + + if (currentPolicy === 'unless-stopped') { + logger.info('[QdrantRestartPolicyProvider] Qdrant already has unless-stopped restart policy — no update needed.') + return + } + + logger.info(`[QdrantRestartPolicyProvider] Qdrant restart policy is "${currentPolicy ?? 'none'}" — updating to unless-stopped.`) + await container.update({ RestartPolicy: { Name: 'unless-stopped', MaximumRetryCount: 0 } }) + logger.info('[QdrantRestartPolicyProvider] Qdrant restart policy updated successfully.') + } catch (err: any) { + logger.error(`[QdrantRestartPolicyProvider] Failed to update Qdrant restart policy: ${err.message}`) + // Non-fatal: the container will still run, just without auto-restart on crash. + } + }) + } +} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index d201174..07ee6b9 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -143,6 +143,7 @@ router router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs']) router.get('/job-status', [RagController, 'getJobStatus']) router.post('/sync', [RagController, 'scanAndSync']) + router.get('/health', [RagController, 'health']) }) .prefix('/api/rag')