UNPKG

mcp-web-ui

Version:

Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size

580 lines (509 loc) β€’ 18.6 kB
/** * StatsComponent - Simple Statistics Display Implementation * * This component provides a clean statistics interface with: * - Automatic metric calculation and formatting * - Real-time updates with smooth animations * - Responsive card-based layout * - Trend indicators and comparisons * - Customizable metric displays * - Mobile-friendly responsive design * * SECURITY FEATURES: * - All data is sanitized through BaseComponent * - XSS protection for all displayed values * - Safe numeric formatting and display * - Input validation for custom metrics * * AI INTEGRATION READY: * - Handles dynamic metrics from LLM sources * - Flexible configuration for any type of statistics * - Clear error handling for invalid data * - Extensive logging for debugging * * Usage: * const stats = new StatsComponent(element, data, config); * * Data format: * { * total: 150, * completed: 75, * pending: 75, * high_priority: 10 * } * * Config options: * - metrics: Array of metric definitions * - showTrends: Show trend indicators (default: false) * - animate: Enable animations (default: true) * - layout: 'grid' or 'horizontal' (default: 'grid') */ class StatsComponent extends BaseComponent { /** * Initialize StatsComponent with stats-specific state * @param {HTMLElement} element - DOM element to attach to * @param {Object} data - Initial statistics data * @param {Object} config - Configuration options */ constructor(element, data, config) { console.log('=== STATS COMPONENT DEBUG ==='); console.log('StatsComponent constructor called'); console.log('Element:', element); console.log('Data:', JSON.stringify(data, null, 2)); console.log('Config received:', JSON.stringify(config, null, 2)); console.log('Config type:', typeof config); console.log('Config.stats:', config?.stats); console.log('Config.stats type:', typeof config?.stats); console.log('==============================='); // Call super() first (required in JavaScript) super(element, data, config); // Now set stats-specific configuration AFTER super() this.statsConfig = { metrics: [], showTrends: false, animate: true, layout: 'grid', // 'grid' or 'horizontal' defaultMetrics: [ { key: 'total', label: 'Total', icon: 'πŸ“Š', color: 'blue' }, { key: 'completed', label: 'Completed', icon: 'βœ…', color: 'green' }, { key: 'pending', label: 'Pending', icon: '⏳', color: 'yellow' }, { key: 'high_priority', label: 'High Priority', icon: 'πŸ”΄', color: 'red' } ], ...config.stats }; console.log('=== STATS COMPONENT DEBUG ==='); console.log('Post-super statsConfig:', JSON.stringify(this.statsConfig, null, 2)); console.log('statsConfig.metrics:', this.statsConfig.metrics); console.log('==============================='); // Previous data for trend calculation this.previousData = {}; // Animation state this.animationEnabled = this.statsConfig.animate; // Re-render now that config is properly set this.render(); this.log('INFO', 'StatsComponent initialized'); } /** * Override init to prevent premature rendering during construction */ init() { if (this.isDestroyed) return; try { // Don't render here - let constructor handle it after statsConfig is set this.bindEvents(); this.startPolling(); this.log('INFO', `Component initialized on element: ${this.element.id || this.element.className}`); } catch (error) { this.log('ERROR', `Failed to initialize component: ${error.message}`); this.handleError(error); } } /** * Render the complete statistics interface */ render() { console.log('=== STATS COMPONENT DEBUG ==='); console.log('render() called'); console.log('this.isDestroyed:', this.isDestroyed); console.log('About to call getMetricsToDisplay()'); console.log('==============================='); if (this.isDestroyed) return; const metrics = this.getMetricsToDisplay(); this.element.innerHTML = this.html` <div class="component component-stats"> ${this.trustedHtml(this.renderHeader())} ${this.trustedHtml(this.renderStatsGrid(metrics))} ${this.trustedHtml(this.renderErrorMessage())} </div> `; // Apply animations if enabled if (this.animationEnabled) { this.animateStatCards(); } } /** * Render component header */ renderHeader() { return this.html` <div class="stats-header"> <h2>${this.config.title || 'Statistics'}</h2> <div class="stats-refresh"> <button class="btn-refresh-stats" data-action="refresh" title="Refresh statistics"> ↻ </button> </div> </div> `; } /** * Render the statistics grid * @param {Array} metrics - Metrics to display */ renderStatsGrid(metrics) { const gridClass = this.statsConfig.layout === 'horizontal' ? 'stats-horizontal' : 'stats-grid'; return this.html` <div class="${gridClass}"> ${this.trustedHtml(metrics.map(metric => this.renderStatCard(metric)).join(''))} </div> `; } /** * Render a single statistic card * @param {Object} metric - Metric definition */ renderStatCard(metric) { const value = this.getMetricValue(metric); const formattedValue = this.formatValue(value, metric); const trend = this.statsConfig.showTrends ? this.calculateTrend(metric.key, value) : null; return this.html` <div class="stat-card stat-${metric.color || 'default'}" data-metric="${metric.key}"> <div class="stat-card-content"> <div class="stat-icon"> ${metric.icon || 'πŸ“ˆ'} </div> <div class="stat-details"> <div class="stat-value" data-value="${value}"> ${formattedValue} </div> <div class="stat-label"> ${metric.label || this.formatLabel(metric.key)} </div> ${trend ? this.renderTrend(trend) : ''} </div> </div> ${metric.description ? this.html` <div class="stat-description"> ${metric.description} </div> ` : ''} </div> `; } /** * Render trend indicator * @param {Object} trend - Trend data */ renderTrend(trend) { if (!trend || trend.change === 0) return ''; const isPositive = trend.change > 0; const isNegative = trend.change < 0; const trendClass = isPositive ? 'trend-up' : isNegative ? 'trend-down' : 'trend-neutral'; const trendIcon = isPositive ? 'β†—' : isNegative ? 'β†˜' : 'β†’'; return this.html` <div class="stat-trend ${trendClass}"> <span class="trend-icon">${trendIcon}</span> <span class="trend-value">${Math.abs(trend.change)}</span> <span class="trend-label">${trend.label}</span> </div> `; } /** * Render error message area */ renderErrorMessage() { return this.html` <div class="error-message" style="display: none;"></div> `; } /** * Bind event listeners */ bindEvents() { // Refresh button this.on('click', '[data-action="refresh"]', () => { this.fetchData(); }); // Card click for details (if configured) this.on('click', '.stat-card', (e) => { const metricKey = e.target.closest('.stat-card').dataset.metric; this.handleStatCardClick(metricKey); }); } /** * Handle stat card click * @param {string} metricKey - Metric key that was clicked */ handleStatCardClick(metricKey) { // Emit event for external handling if (this.config.onStatClick) { try { this.config.onStatClick(metricKey, this.getMetricValue({ key: metricKey })); } catch (error) { this.log('ERROR', `Stat click handler error: ${error.message}`); } } this.log('INFO', `Stat card clicked: ${metricKey}`); } /** * Get metrics to display (use configured or default) */ getMetricsToDisplay() { console.log('=== STATS COMPONENT DEBUG ==='); console.log('getMetricsToDisplay called'); console.log('this.statsConfig:', this.statsConfig); console.log('this.statsConfig type:', typeof this.statsConfig); try { if (!this.statsConfig) { console.error('CRITICAL: this.statsConfig is undefined!'); console.error('Constructor may have failed to initialize properly'); return []; } console.log('this.statsConfig.metrics:', this.statsConfig.metrics); console.log('this.statsConfig.defaultMetrics:', this.statsConfig.defaultMetrics); console.log('==============================='); if (this.statsConfig.metrics && this.statsConfig.metrics.length > 0) { // Convert string metrics to proper metric objects return this.statsConfig.metrics.map(metric => { if (typeof metric === 'string') { // Convert string to metric object return { key: metric, label: this.formatLabel(metric), icon: this.getDefaultIcon(metric), color: this.getDefaultColor(metric) }; } // Already a proper metric object return metric; }); } // Filter default metrics based on available data return this.statsConfig.defaultMetrics.filter(metric => { const value = this.getMetricValue(metric); return value !== undefined && value !== null; }); } catch (error) { console.error('=== STATS COMPONENT ERROR ==='); console.error('Error in getMetricsToDisplay:', error); console.error('Stack trace:', error.stack); console.error('=============================='); return []; } } /** * Get value for a metric * @param {Object} metric - Metric definition * @returns {any} Metric value */ getMetricValue(metric) { if (!metric || !metric.key) return 0; // Support nested keys (e.g., 'stats.completed') if (metric.key.includes('.')) { return metric.key.split('.').reduce((obj, key) => obj?.[key], this.data); } return this.data?.[metric.key] ?? 0; } /** * Format value for display * @param {any} value - Value to format * @param {Object} metric - Metric definition * @returns {string} Formatted value */ formatValue(value, metric) { if (value === null || value === undefined) { return 'β€”'; } // Use custom formatter if provided if (metric.formatter && typeof metric.formatter === 'function') { try { return metric.formatter(value); } catch (error) { this.log('ERROR', `Custom formatter error for ${metric.key}: ${error.message}`); return String(value); } } // Auto-detect formatting based on value type if (typeof value === 'number') { // Large numbers get comma formatting if (value >= 1000) { return value.toLocaleString(); } // Percentages if (metric.type === 'percentage') { return `${value}%`; } // Currency if (metric.type === 'currency') { return new Intl.NumberFormat('en-US', { style: 'currency', currency: metric.currency || 'USD' }).format(value); } return String(value); } // String values return this.sanitize(String(value)); } /** * Format metric label for display * @param {string} key - Metric key * @returns {string} Formatted label */ formatLabel(key) { return key .replace(/_/g, ' ') .replace(/\b\w/g, char => char.toUpperCase()); } /** * Get default icon for a metric key * @param {string} key - Metric key * @returns {string} Default icon */ getDefaultIcon(key) { const iconMap = { 'total': 'πŸ“Š', 'active': '🟒', 'scheduled': '⏰', 'pending': '⏳', 'failed': '❌', 'completed': 'βœ…', 'running': '▢️', 'paused': '⏸️', 'success': 'βœ…', 'error': '❌', 'warning': '⚠️', 'count': 'πŸ”’', 'users': 'πŸ‘₯', 'tasks': 'πŸ“‹', 'items': 'πŸ“„' }; return iconMap[key.toLowerCase()] || 'πŸ“ˆ'; } /** * Get default color for a metric key * @param {string} key - Metric key * @returns {string} Default color */ getDefaultColor(key) { const colorMap = { 'total': 'blue', 'active': 'green', 'scheduled': 'blue', 'pending': 'yellow', 'failed': 'red', 'completed': 'green', 'running': 'orange', 'paused': 'gray', 'success': 'green', 'error': 'red', 'warning': 'yellow' }; return colorMap[key.toLowerCase()] || 'blue'; } /** * Calculate trend compared to previous data * @param {string} key - Metric key * @param {number} currentValue - Current value * @returns {Object|null} Trend data */ calculateTrend(key, currentValue) { if (!this.previousData || typeof currentValue !== 'number') { return null; } const previousValue = this.previousData[key]; if (typeof previousValue !== 'number') { return null; } const change = currentValue - previousValue; if (change === 0) { return { change: 0, label: 'no change' }; } const percentChange = previousValue !== 0 ? Math.round((change / previousValue) * 100) : 100; return { change: change, percent: percentChange, label: change > 0 ? 'increase' : 'decrease' }; } /** * Animate stat cards on render */ animateStatCards() { const cards = this.element.querySelectorAll('.stat-card'); cards.forEach((card, index) => { // Stagger animations card.style.opacity = '0'; card.style.transform = 'translateY(20px)'; setTimeout(() => { card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; card.style.opacity = '1'; card.style.transform = 'translateY(0)'; }, index * 100); }); // Animate values counting up this.animateValues(); } /** * Animate numeric values counting up */ animateValues() { const valueElements = this.element.querySelectorAll('.stat-value[data-value]'); valueElements.forEach(element => { const targetValue = parseInt(element.dataset.value) || 0; if (targetValue === 0) return; let currentValue = 0; const increment = Math.ceil(targetValue / 20); // 20 steps const duration = 1000; // 1 second const stepTime = duration / 20; const animate = () => { currentValue += increment; if (currentValue >= targetValue) { currentValue = targetValue; element.textContent = this.formatValue(currentValue, {}); return; } element.textContent = this.formatValue(currentValue, {}); setTimeout(animate, stepTime); }; setTimeout(animate, 200); // Delay start }); } /** * Update with new data and store previous for trends * @param {Object} newData - New statistics data */ update(newData) { // Store previous data for trend calculation this.previousData = { ...this.data }; // Call parent update super.update(newData); } /** * Get component statistics (meta-statistics) */ getComponentStats() { const metrics = this.getMetricsToDisplay(); return { totalMetrics: metrics.length, hasData: Object.keys(this.data || {}).length > 0, animationsEnabled: this.animationEnabled, layout: this.statsConfig.layout }; } /** * Enhanced cleanup for stats-specific resources */ destroy() { // Clear animation timers const cards = this.element?.querySelectorAll('.stat-card'); cards?.forEach(card => { card.style.transition = 'none'; }); // Clear previous data this.previousData = null; this.statsConfig = null; // Call parent cleanup super.destroy(); } } // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = StatsComponent; } // Make available globally for vanilla JS usage if (typeof window !== 'undefined') { window.StatsComponent = StatsComponent; }