|
|
@@ -0,0 +1,334 @@
|
|
|
+import { ApiService } from '../../services/ApiService';
|
|
|
+import { ToastService } from '../../services/ToastService';
|
|
|
+import { QdrantStats, QdrantHealth } from '../../types';
|
|
|
+import { getIcon } from '../../utils/icons';
|
|
|
+import { formatNumber, formatRelativeTime } from '../../utils/helpers';
|
|
|
+
|
|
|
+export class QdrantPage {
|
|
|
+ private element: HTMLElement | null = null;
|
|
|
+ private stats: QdrantStats | null = null;
|
|
|
+ private health: QdrantHealth | null = null;
|
|
|
+ private eventsbound: boolean = false;
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private apiService: ApiService,
|
|
|
+ private toastService: ToastService
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ render(): HTMLElement {
|
|
|
+ this.element = document.createElement('div');
|
|
|
+ this.element.className = 'space-y-6';
|
|
|
+
|
|
|
+ this.element.innerHTML = `
|
|
|
+ <!-- Loading state -->
|
|
|
+ <div class="flex items-center justify-center py-12" id="loading">
|
|
|
+ <div class="w-8 h-8 spinner"></div>
|
|
|
+ <span class="ml-3 text-gray-600 dark:text-gray-400">Loading Qdrant information...</span>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ this.loadQdrantData();
|
|
|
+ return this.element;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadQdrantData(): Promise<void> {
|
|
|
+ try {
|
|
|
+ const [statsResponse, healthResponse] = await Promise.all([
|
|
|
+ this.apiService.getQdrantStats(),
|
|
|
+ this.apiService.getQdrantHealth()
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (statsResponse.success && statsResponse.data) {
|
|
|
+ this.stats = statsResponse.data;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (healthResponse.success && healthResponse.data) {
|
|
|
+ this.health = healthResponse.data;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.renderQdrantOverview();
|
|
|
+ } catch (error) {
|
|
|
+ this.toastService.error('Error', 'Failed to load Qdrant information');
|
|
|
+ this.renderError();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private renderError(): void {
|
|
|
+ if (!this.element) return;
|
|
|
+
|
|
|
+ this.element.innerHTML = `
|
|
|
+ <div class="text-center py-12">
|
|
|
+ <div class="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center mx-auto mb-3">
|
|
|
+ <div class="w-6 h-6 text-red-600 dark:text-red-400">${getIcon('error')}</div>
|
|
|
+ </div>
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Failed to Load Qdrant Data</h3>
|
|
|
+ <p class="text-gray-500 dark:text-gray-400 mb-4">Unable to connect to Qdrant service</p>
|
|
|
+ <button class="btn btn-primary" id="retry-load">
|
|
|
+ <div class="w-4 h-4 mr-2">${getIcon('refresh')}</div>
|
|
|
+ Retry
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ this.element.querySelector('#retry-load')?.addEventListener('click', () => {
|
|
|
+ this.loadQdrantData();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private renderQdrantOverview(): void {
|
|
|
+ if (!this.element) return;
|
|
|
+
|
|
|
+ this.element.innerHTML = `
|
|
|
+ <!-- Header -->
|
|
|
+ <div class="md:flex md:items-center md:justify-between">
|
|
|
+ <div class="flex-1 min-w-0">
|
|
|
+ <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
|
|
|
+ Vector Search (Qdrant)
|
|
|
+ </h2>
|
|
|
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
|
+ Manage and monitor the Qdrant vector database integration
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
|
|
|
+ <button type="button" class="btn btn-secondary" id="refresh-data">
|
|
|
+ <div class="w-4 h-4 mr-2">${getIcon('refresh')}</div>
|
|
|
+ Refresh
|
|
|
+ </button>
|
|
|
+ <button type="button" class="btn btn-primary" id="trigger-cleanup">
|
|
|
+ <div class="w-4 h-4 mr-2">${getIcon('cleanup')}</div>
|
|
|
+ Run Cleanup
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Health Status -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">Service Health</h3>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400">Current status of Qdrant services</p>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ ${this.renderHealthStatus()}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Statistics -->
|
|
|
+ ${this.stats ? `
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-body text-center">
|
|
|
+ <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.total_collections)}</div>
|
|
|
+ <div class="text-sm text-gray-500 dark:text-gray-400">Total Collections</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-body text-center">
|
|
|
+ <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.total_vectors)}</div>
|
|
|
+ <div class="text-sm text-gray-500 dark:text-gray-400">Total Vectors</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-body text-center">
|
|
|
+ <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.enabled_shops)}</div>
|
|
|
+ <div class="text-sm text-gray-500 dark:text-gray-400">Enabled Shops</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-body text-center">
|
|
|
+ <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.recent_embeddings)}</div>
|
|
|
+ <div class="text-sm text-gray-500 dark:text-gray-400">Recent Embeddings</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+
|
|
|
+ <!-- Configuration Note -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">Configuration</h3>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400">Qdrant and embedding service settings</p>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
|
+ <div class="flex">
|
|
|
+ <div class="flex-shrink-0">
|
|
|
+ <div class="w-5 h-5 text-blue-400">${getIcon('info')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="ml-3">
|
|
|
+ <h4 class="text-sm font-medium text-blue-800 dark:text-blue-300">Environment Configuration</h4>
|
|
|
+ <div class="text-sm text-blue-700 dark:text-blue-400 mt-1 space-y-1">
|
|
|
+ <p>• Qdrant API URL and credentials are configured via environment variables</p>
|
|
|
+ <p>• OpenRouter API is used for text embeddings (text-embedding-3-large model)</p>
|
|
|
+ <p>• Collections follow the naming pattern: {shop_custom_id}-{category}_{iterator}</p>
|
|
|
+ <p>• Vector dimension: 3072 (OpenAI text-embedding-3-large)</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Actions -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">Management Actions</h3>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400">Administrative operations for Qdrant</p>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
|
+ <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Cleanup Operations</h4>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
|
|
+ Remove orphaned collections and process deletion queue
|
|
|
+ </p>
|
|
|
+ <button type="button" class="btn btn-sm btn-secondary" id="run-cleanup">
|
|
|
+ <div class="w-4 h-4 mr-2">${getIcon('cleanup')}</div>
|
|
|
+ Run Cleanup
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
|
+ <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Health Check</h4>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
|
|
+ Test connectivity and service availability
|
|
|
+ </p>
|
|
|
+ <button type="button" class="btn btn-sm btn-secondary" id="run-health-check">
|
|
|
+ <div class="w-4 h-4 mr-2">${getIcon('health')}</div>
|
|
|
+ Run Health Check
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ this.bindEvents();
|
|
|
+ }
|
|
|
+
|
|
|
+ private renderHealthStatus(): string {
|
|
|
+ if (!this.health) {
|
|
|
+ return `
|
|
|
+ <div class="text-center py-6">
|
|
|
+ <div class="w-8 h-8 spinner mx-auto mb-3"></div>
|
|
|
+ <p class="text-gray-500 dark:text-gray-400">Checking health status...</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ const overallStatus = this.health.healthy;
|
|
|
+ const services = this.health.services;
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div class="space-y-4">
|
|
|
+ <!-- Overall Status -->
|
|
|
+ <div class="flex items-center justify-between p-4 ${overallStatus ? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' : 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'} rounded-lg">
|
|
|
+ <div class="flex items-center">
|
|
|
+ <div class="w-6 h-6 ${overallStatus ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} mr-3">
|
|
|
+ ${getIcon(overallStatus ? 'check' : 'error')}
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <h4 class="text-sm font-medium ${overallStatus ? 'text-green-800 dark:text-green-300' : 'text-red-800 dark:text-red-300'}">
|
|
|
+ System ${overallStatus ? 'Healthy' : 'Unhealthy'}
|
|
|
+ </h4>
|
|
|
+ <p class="text-sm ${overallStatus ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
|
|
+ ${overallStatus ? 'All services are operational' : 'Some services are experiencing issues'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <span class="badge ${overallStatus ? 'badge-success' : 'badge-danger'}">
|
|
|
+ ${overallStatus ? 'Healthy' : 'Unhealthy'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Individual Services -->
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
+ ${Object.entries(services).map(([serviceName, healthy]) => `
|
|
|
+ <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
|
+ <div class="flex items-center">
|
|
|
+ <div class="w-4 h-4 ${healthy ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} mr-2">
|
|
|
+ ${getIcon(healthy ? 'check' : 'error')}
|
|
|
+ </div>
|
|
|
+ <span class="text-sm font-medium text-gray-900 dark:text-white capitalize">${serviceName}</span>
|
|
|
+ </div>
|
|
|
+ <span class="badge badge-sm ${healthy ? 'badge-success' : 'badge-danger'}">
|
|
|
+ ${healthy ? 'OK' : 'Error'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Errors -->
|
|
|
+ ${this.health.errors.length > 0 ? `
|
|
|
+ <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
|
+ <h4 class="text-sm font-medium text-red-800 dark:text-red-300 mb-2">Error Details</h4>
|
|
|
+ <ul class="space-y-1">
|
|
|
+ ${this.health.errors.map(error => `
|
|
|
+ <li class="text-sm text-red-600 dark:text-red-400">• ${error}</li>
|
|
|
+ `).join('')}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ private bindEvents(): void {
|
|
|
+ if (!this.element || this.eventsbound) return;
|
|
|
+ this.eventsbound = true;
|
|
|
+
|
|
|
+ // Refresh data
|
|
|
+ this.element.querySelector('#refresh-data')?.addEventListener('click', () => {
|
|
|
+ this.loadQdrantData();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Trigger cleanup
|
|
|
+ this.element.querySelector('#trigger-cleanup')?.addEventListener('click', async () => {
|
|
|
+ await this.runCleanup();
|
|
|
+ });
|
|
|
+
|
|
|
+ this.element.querySelector('#run-cleanup')?.addEventListener('click', async () => {
|
|
|
+ await this.runCleanup();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Run health check
|
|
|
+ this.element.querySelector('#run-health-check')?.addEventListener('click', async () => {
|
|
|
+ await this.runHealthCheck();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async runCleanup(): Promise<void> {
|
|
|
+ try {
|
|
|
+ const response = await this.apiService.triggerQdrantCleanup();
|
|
|
+
|
|
|
+ if (response.success) {
|
|
|
+ this.toastService.success('Cleanup Started', 'Qdrant cleanup process has been initiated');
|
|
|
+ // Refresh data after cleanup
|
|
|
+ setTimeout(() => this.loadQdrantData(), 2000);
|
|
|
+ } else {
|
|
|
+ this.toastService.error('Cleanup Failed', response.error || 'Failed to start cleanup process');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ this.toastService.error('Error', 'Failed to start cleanup process');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async runHealthCheck(): Promise<void> {
|
|
|
+ try {
|
|
|
+ const response = await this.apiService.getQdrantHealth();
|
|
|
+
|
|
|
+ if (response.success && response.data) {
|
|
|
+ this.health = response.data;
|
|
|
+ this.renderQdrantOverview();
|
|
|
+
|
|
|
+ if (this.health.healthy) {
|
|
|
+ this.toastService.success('Health Check Passed', 'All Qdrant services are operational');
|
|
|
+ } else {
|
|
|
+ this.toastService.warning('Health Check Issues', 'Some services are experiencing problems');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.toastService.error('Health Check Failed', response.error || 'Unable to perform health check');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ this.toastService.error('Error', 'Failed to perform health check');
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|