| |
| |
| |
| |
| |
|
|
| class StatusDrawer { |
| constructor(options = {}) { |
| this.options = { |
| apiEndpoint: options.apiEndpoint || '/api/system/status', |
| updateInterval: options.updateInterval || 3000, |
| ...options |
| }; |
| |
| this.isOpen = false; |
| this.pollTimer = null; |
| this.lastData = null; |
| this.drawerElement = null; |
| this.buttonElement = null; |
| |
| this.createDrawer(); |
| this.createFloatingButton(); |
| } |
| |
| |
| |
| |
| createFloatingButton() { |
| const button = document.createElement('button'); |
| button.id = 'status-drawer-btn'; |
| button.className = 'status-drawer-floating-btn'; |
| button.setAttribute('aria-label', 'Open status panel'); |
| button.innerHTML = ` |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <circle cx="12" cy="12" r="10"/> |
| <path d="M12 6v6l4 2"/> |
| </svg> |
| `; |
| |
| button.addEventListener('click', () => this.toggle()); |
| |
| document.body.appendChild(button); |
| this.buttonElement = button; |
| } |
| |
| |
| |
| |
| createDrawer() { |
| const drawer = document.createElement('div'); |
| drawer.id = 'status-drawer'; |
| drawer.className = 'status-drawer status-drawer-enhanced'; |
| drawer.innerHTML = ` |
| <div class="status-drawer-header"> |
| <h3>System Status</h3> |
| <div class="header-actions"> |
| <button class="refresh-btn" id="refresh-status" aria-label="Refresh"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="23 4 23 10 17 10"></polyline> |
| <polyline points="1 20 1 14 7 14"></polyline> |
| <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path> |
| </svg> |
| </button> |
| <button class="drawer-close" aria-label="Close"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M9 18l6-6-6-6"/> |
| </svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="status-drawer-body"> |
| <!-- ALL PROVIDER STATUS --> |
| <div class="status-section providers-detailed"> |
| <div class="section-title collapsible" data-target="providers-list"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <rect x="2" y="3" width="20" height="14" rx="2"/> |
| <line x1="8" y1="21" x2="16" y2="21"/> |
| <line x1="12" y1="17" x2="12" y2="21"/> |
| </svg> |
| <span>All Providers</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content" id="providers-list"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- AI MODELS --> |
| <div class="status-section ai-models"> |
| <div class="section-title collapsible" data-target="ai-models-list"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/> |
| </svg> |
| <span>AI Models</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content" id="ai-models-list"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- INFRASTRUCTURE --> |
| <div class="status-section infrastructure"> |
| <div class="section-title collapsible" data-target="infrastructure-list"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect> |
| <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect> |
| <line x1="6" y1="6" x2="6" y2="6"></line> |
| <line x1="6" y1="18" x2="6" y2="18"></line> |
| </svg> |
| <span>Infrastructure</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content" id="infrastructure-list"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- RESOURCE BREAKDOWN --> |
| <div class="status-section resource-breakdown"> |
| <div class="section-title collapsible" data-target="resources-breakdown"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <line x1="8" y1="6" x2="21" y2="6"></line> |
| <line x1="8" y1="12" x2="21" y2="12"></line> |
| <line x1="8" y1="18" x2="21" y2="18"></line> |
| <line x1="3" y1="6" x2="3" y2="6"></line> |
| <line x1="3" y1="12" x2="3" y2="12"></line> |
| <line x1="3" y1="18" x2="3" y2="18"></line> |
| </svg> |
| <span>Resource Breakdown</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content" id="resources-breakdown"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- ERROR DETAILS --> |
| <div class="status-section error-details"> |
| <div class="section-title collapsible" data-target="error-list"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <circle cx="12" cy="12" r="10"></circle> |
| <line x1="12" y1="8" x2="12" y2="12"></line> |
| <line x1="12" y1="16" x2="12" y2="16"></line> |
| </svg> |
| <span>Recent Errors</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content collapsed" id="error-list"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- PERFORMANCE --> |
| <div class="status-section performance"> |
| <div class="section-title collapsible" data-target="performance-metrics"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> |
| </svg> |
| <span>Performance</span> |
| <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </div> |
| <div class="collapsible-content" id="performance-metrics"> |
| <div class="summary-loading">Loading...</div> |
| </div> |
| </div> |
| |
| <!-- Last Update --> |
| <div class="drawer-footer"> |
| <span class="last-update-label">Last update:</span> |
| <span class="last-update-time" id="last-update-time">--</span> |
| </div> |
| </div> |
| `; |
| |
| document.body.appendChild(drawer); |
| this.drawerElement = drawer; |
| |
| |
| drawer.querySelector('.drawer-close').addEventListener('click', () => this.close()); |
| |
| |
| drawer.querySelector('#refresh-status').addEventListener('click', () => this.fetchStatus()); |
| |
| |
| drawer.querySelectorAll('.section-title.collapsible').forEach(title => { |
| title.addEventListener('click', (e) => { |
| const target = title.dataset.target; |
| const content = document.getElementById(target); |
| if (content) { |
| content.classList.toggle('collapsed'); |
| title.classList.toggle('collapsed'); |
| } |
| }); |
| }); |
| } |
| |
| |
| |
| |
| toggle() { |
| if (this.isOpen) { |
| this.close(); |
| } else { |
| this.open(); |
| } |
| } |
| |
| |
| |
| |
| open() { |
| if (this.isOpen) return; |
| |
| this.isOpen = true; |
| this.drawerElement.classList.add('open'); |
| this.buttonElement.classList.add('hidden'); |
| |
| |
| this.startPolling(); |
| } |
| |
| |
| |
| |
| close() { |
| if (!this.isOpen) return; |
| |
| this.isOpen = false; |
| this.drawerElement.classList.remove('open'); |
| this.buttonElement.classList.remove('hidden'); |
| |
| |
| this.stopPolling(); |
| } |
| |
| |
| |
| |
| startPolling() { |
| if (!this.isOpen) return; |
| |
| this.fetchStatus(); |
| this.pollTimer = setTimeout(() => this.startPolling(), this.options.updateInterval); |
| } |
| |
| |
| |
| |
| stopPolling() { |
| if (this.pollTimer) { |
| clearTimeout(this.pollTimer); |
| this.pollTimer = null; |
| } |
| } |
| |
| |
| |
| |
| async fetchStatus() { |
| if (!this.isOpen) return; |
| |
| try { |
| const response = await fetch(this.options.apiEndpoint); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| |
| const data = await response.json(); |
| this.updateUI(data); |
| |
| } catch (error) { |
| console.error('Status Drawer: Failed to fetch:', error); |
| this.showError(); |
| } |
| } |
| |
| |
| |
| |
| updateUI(data) { |
| this.lastData = data; |
| |
| |
| this.updateProvidersDetailed(data.providers_detailed || data.services || []); |
| |
| |
| this.updateAIModels(data.ai_models || {}); |
| |
| |
| this.updateInfrastructure(data.infrastructure || {}); |
| |
| |
| this.updateResourceBreakdown(data.resource_breakdown || {}); |
| |
| |
| this.updateErrorDetails(data.error_details || []); |
| |
| |
| this.updatePerformance(data.performance || {}); |
| |
| |
| this.updateTimestamp(data.timestamp); |
| } |
| |
| |
| |
| |
| updateProvidersDetailed(providers) { |
| const container = document.getElementById('providers-list'); |
| if (!container) return; |
| |
| if (!providers.length) { |
| container.innerHTML = '<div class="empty-state">No providers configured</div>'; |
| return; |
| } |
| |
| container.innerHTML = providers.map(provider => { |
| const isOnline = provider.status === 'online' || provider.status === 'active'; |
| const statusEmoji = isOnline ? '🟢' : |
| provider.status === 'rate_limited' ? '🔴' : |
| provider.status === 'degraded' ? '🟡' : '⚫'; |
| |
| let statusText = ''; |
| if (isOnline) { |
| statusText = `${provider.response_time_ms || 0}ms | Success: ${provider.success_rate || 100}%`; |
| if (provider.last_check) { |
| const elapsed = Math.floor((Date.now() / 1000) - new Date(provider.last_check).getTime() / 1000); |
| statusText += ` | Last: ${elapsed}s ago`; |
| } |
| } else if (provider.status === 'rate_limited') { |
| statusText = `Rate Limited (${provider.status_code || 429})`; |
| if (provider.cached_until) { |
| statusText += ` | Cached ${provider.cached_until}`; |
| } |
| } else if (provider.status === 'degraded') { |
| statusText = provider.error || 'Degraded performance'; |
| } else { |
| statusText = provider.error || 'Offline'; |
| } |
| |
| const resourceInfo = provider.resource_count ? ` | ${provider.resource_count} resources` : ''; |
| |
| return ` |
| <div class="provider-item ${isOnline ? 'online' : 'offline'}"> |
| <div class="provider-status"> |
| <span class="status-emoji">${statusEmoji}</span> |
| <span class="provider-name">${provider.name}</span> |
| </div> |
| <div class="provider-metrics">${statusText}${resourceInfo}</div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| |
| |
| |
| updateAIModels(aiModels) { |
| const container = document.getElementById('ai-models-list'); |
| if (!container) return; |
| |
| const transformersStatus = aiModels.transformers_loaded ? '🟢 Loaded (CPU mode)' : '🔴 Not loaded'; |
| const sentimentModels = aiModels.sentiment_models || 0; |
| const hfApiStatus = aiModels.hf_api_active ? '🟢 Active' : '🔴 Inactive'; |
| |
| container.innerHTML = ` |
| <div class="metric-item"> |
| <span class="metric-label">Transformers:</span> |
| <span class="metric-value">${transformersStatus}</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">Sentiment Models:</span> |
| <span class="metric-value">${sentimentModels} available</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">HuggingFace API:</span> |
| <span class="metric-value">${hfApiStatus}</span> |
| </div> |
| `; |
| } |
| |
| |
| |
| |
| updateInfrastructure(infrastructure) { |
| const container = document.getElementById('infrastructure-list'); |
| if (!container) return; |
| |
| const dbStatus = infrastructure.database_status || 'unknown'; |
| const dbEntries = infrastructure.database_entries || 0; |
| const workerStatus = infrastructure.background_worker || 'unknown'; |
| const workerNextRun = infrastructure.worker_next_run || 'N/A'; |
| const wsStatus = infrastructure.websocket_active ? '🟢 Active' : '⚫ Inactive'; |
| |
| container.innerHTML = ` |
| <div class="metric-item"> |
| <span class="metric-label">Database:</span> |
| <span class="metric-value">${dbStatus === 'online' ? '🟢' : '🔴'} SQLite (${dbEntries} cached)</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">Background Worker:</span> |
| <span class="metric-value">${workerStatus === 'active' ? '🟢' : '⚫'} ${workerNextRun}</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">WebSocket:</span> |
| <span class="metric-value">${wsStatus}</span> |
| </div> |
| `; |
| } |
| |
| |
| |
| |
| updateResourceBreakdown(breakdown) { |
| const container = document.getElementById('resources-breakdown'); |
| if (!container) return; |
| |
| const total = breakdown.total || 0; |
| const bySource = breakdown.by_source || {}; |
| const byCategory = breakdown.by_category || {}; |
| |
| let sourceHTML = ''; |
| for (const [source, count] of Object.entries(bySource)) { |
| sourceHTML += ` |
| <div class="breakdown-item"> |
| <span class="breakdown-label">${source}:</span> |
| <span class="breakdown-value">${count}</span> |
| </div> |
| `; |
| } |
| |
| let categoryHTML = ''; |
| for (const [category, count] of Object.entries(byCategory)) { |
| categoryHTML += ` |
| <div class="breakdown-item"> |
| <span class="breakdown-label">${category}:</span> |
| <span class="breakdown-value">${count} online</span> |
| </div> |
| `; |
| } |
| |
| container.innerHTML = ` |
| <div class="breakdown-section"> |
| <div class="breakdown-title">Total: ${total}+ resources</div> |
| ${sourceHTML} |
| </div> |
| <div class="breakdown-section"> |
| <div class="breakdown-title">By Category:</div> |
| ${categoryHTML} |
| </div> |
| `; |
| } |
| |
| |
| |
| |
| updateErrorDetails(errors) { |
| const container = document.getElementById('error-list'); |
| if (!container) return; |
| |
| if (!errors || errors.length === 0) { |
| container.innerHTML = '<div class="empty-state">No recent errors</div>'; |
| return; |
| } |
| |
| container.innerHTML = errors.map(error => ` |
| <div class="error-item"> |
| <div class="error-provider">${error.provider || 'Unknown'}: ${error.count || 1}x ${error.type || 'error'}</div> |
| <div class="error-message">${error.message || 'Unknown error'}</div> |
| ${error.action ? `<div class="error-action">Action: ${error.action}</div>` : ''} |
| </div> |
| `).join(''); |
| } |
| |
| |
| |
| |
| updatePerformance(performance) { |
| const container = document.getElementById('performance-metrics'); |
| if (!container) return; |
| |
| const avgResponse = performance.avg_response_ms || 0; |
| const fastest = performance.fastest_provider || 'N/A'; |
| const fastestTime = performance.fastest_time_ms || 0; |
| const cacheHit = performance.cache_hit_rate || 0; |
| |
| container.innerHTML = ` |
| <div class="metric-item"> |
| <span class="metric-label">Avg Response:</span> |
| <span class="metric-value">${avgResponse}ms</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">Fastest:</span> |
| <span class="metric-value">${fastest} (${fastestTime}ms)</span> |
| </div> |
| <div class="metric-item"> |
| <span class="metric-label">Cache Hit:</span> |
| <span class="metric-value">${cacheHit}%</span> |
| </div> |
| `; |
| } |
| |
| |
| |
| |
| updateEndpoints(endpoints) { |
| const container = document.getElementById('endpoints-status'); |
| if (!container) return; |
| |
| if (!endpoints.length) { |
| container.innerHTML = '<div class="empty-state">No endpoints</div>'; |
| return; |
| } |
| |
| container.innerHTML = endpoints.map(endpoint => { |
| const statusClass = endpoint.status === 'online' ? 'status-online' : 'status-offline'; |
| return ` |
| <div class="status-item ${statusClass}"> |
| <div class="status-dot"></div> |
| <div class="status-info"> |
| <div class="status-name">${endpoint.path}</div> |
| <div class="status-meta"> |
| ${endpoint.avg_response_ms ? `${endpoint.avg_response_ms.toFixed(0)}ms` : '--'} • |
| ${endpoint.success_rate ? `${endpoint.success_rate.toFixed(1)}%` : '--'} |
| </div> |
| </div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| |
| |
| |
| updateProviders(services) { |
| const container = document.getElementById('providers-status'); |
| if (!container) return; |
| |
| if (!services.length) { |
| container.innerHTML = '<div class="empty-state">No providers</div>'; |
| return; |
| } |
| |
| container.innerHTML = services.map(service => { |
| const statusClass = service.status === 'online' ? 'status-online' : 'status-offline'; |
| return ` |
| <div class="status-item ${statusClass}"> |
| <div class="status-dot"></div> |
| <div class="status-info"> |
| <div class="status-name">${service.name}</div> |
| <div class="status-meta"> |
| ${service.response_time_ms ? `${service.response_time_ms.toFixed(0)}ms` : 'Offline'} |
| </div> |
| </div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| |
| |
| |
| updateCoins(coins) { |
| const container = document.getElementById('coins-status'); |
| if (!container) return; |
| |
| if (!coins.length) { |
| container.innerHTML = '<div class="empty-state">No coins</div>'; |
| return; |
| } |
| |
| container.innerHTML = coins.map(coin => { |
| const statusClass = coin.status === 'online' ? 'status-online' : 'status-offline'; |
| return ` |
| <div class="status-item ${statusClass}"> |
| <div class="status-dot"></div> |
| <div class="status-info"> |
| <div class="status-name">${coin.symbol}</div> |
| <div class="status-meta"> |
| ${coin.price ? `$${coin.price.toLocaleString()}` : 'Unavailable'} |
| </div> |
| </div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| |
| |
| |
| updateTimestamp(timestamp) { |
| const element = document.getElementById('last-update-time'); |
| if (element) { |
| const date = new Date(timestamp * 1000); |
| element.textContent = date.toLocaleTimeString(); |
| } |
| } |
| |
| |
| |
| |
| showError() { |
| const sections = ['resources-summary', 'endpoints-status', 'providers-status', 'coins-status']; |
| sections.forEach(id => { |
| const element = document.getElementById(id); |
| if (element) { |
| element.innerHTML = '<div class="error-state">Failed to load</div>'; |
| } |
| }); |
| } |
| |
| |
| |
| |
| destroy() { |
| this.stopPolling(); |
| if (this.drawerElement) this.drawerElement.remove(); |
| if (this.buttonElement) this.buttonElement.remove(); |
| } |
| } |
|
|
| |
| if (typeof window !== 'undefined') { |
| window.StatusDrawer = StatusDrawer; |
| } |
|
|