From a914821d492ba6cbd39cb450a3c5f789107cbb8f Mon Sep 17 00:00:00 2001 From: Wha1eChai Date: Fri, 9 Jan 2026 17:58:09 +0800 Subject: [PATCH] perf(webui): refactor dashboard modules and optimize API performance --- public/index.html | 10 +- public/js/app-init.js | 4 +- public/js/components/claude-config.js | 13 +- public/js/components/dashboard.js | 598 ++++------------------ public/js/components/dashboard/charts.js | 493 ++++++++++++++++++ public/js/components/dashboard/filters.js | 275 ++++++++++ public/js/components/dashboard/stats.js | 43 ++ public/js/components/logs-viewer.js | 2 +- public/js/components/model-manager.js | 26 +- public/js/components/models.js | 32 +- public/js/components/server-config.js | 51 +- public/js/config/constants.js | 82 +++ public/js/data-store.js | 11 +- public/js/utils.js | 18 + public/js/utils/error-handler.js | 107 ++++ public/js/utils/model-config.js | 42 ++ public/js/utils/validators.js | 168 ++++++ public/views/models.html | 14 +- src/modules/usage-stats.js | 16 +- src/server.js | 14 +- 20 files changed, 1420 insertions(+), 599 deletions(-) create mode 100644 public/js/components/dashboard/charts.js create mode 100644 public/js/components/dashboard/filters.js create mode 100644 public/js/components/dashboard/stats.js create mode 100644 public/js/config/constants.js create mode 100644 public/js/utils/error-handler.js create mode 100644 public/js/utils/model-config.js create mode 100644 public/js/utils/validators.js diff --git a/public/index.html b/public/index.html index a21305f..06b750a 100644 --- a/public/index.html +++ b/public/index.html @@ -346,13 +346,21 @@ - + + + + + + + + + diff --git a/public/js/app-init.js b/public/js/app-init.js index 030d86c..1a5da04 100644 --- a/public/js/app-init.js +++ b/public/js/app-init.js @@ -4,7 +4,7 @@ */ document.addEventListener('alpine:init', () => { - console.log('Registering app component...'); + // App component registration // Main App Controller Alpine.data('app', () => ({ @@ -17,7 +17,7 @@ document.addEventListener('alpine:init', () => { }, init() { - console.log('App component initializing...'); + // App component initialization // Theme setup document.documentElement.setAttribute('data-theme', 'black'); diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js index 0d1614c..355be9b 100644 --- a/public/js/components/claude-config.js +++ b/public/js/components/claude-config.js @@ -10,7 +10,18 @@ window.Components.claudeConfig = () => ({ loading: false, init() { - this.fetchConfig(); + // Only fetch config if this is the active sub-tab + if (this.activeTab === 'claude') { + this.fetchConfig(); + } + + // Watch local activeTab (from parent settings scope, skip initial trigger) + this.$watch('activeTab', (tab, oldTab) => { + if (tab === 'claude' && oldTab !== undefined) { + this.fetchConfig(); + } + }); + this.$watch('$store.data.models', (val) => { this.models = val || []; }); diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js index 4a0dc74..3757e19 100644 --- a/public/js/components/dashboard.js +++ b/public/js/components/dashboard.js @@ -1,53 +1,41 @@ /** - * Dashboard Component + * Dashboard Component (Refactored) + * Orchestrates stats, charts, and filters modules * Registers itself to window.Components for Alpine.js to consume */ window.Components = window.Components || {}; -// Helper to get CSS variable values (alias to window.utils.getThemeColor) -const getThemeColor = (name) => window.utils.getThemeColor(name); - -// Color palette for different families and models -const FAMILY_COLORS = { - get claude() { return getThemeColor('--color-neon-purple'); }, - get gemini() { return getThemeColor('--color-neon-green'); }, - get other() { return getThemeColor('--color-neon-cyan'); } -}; - -const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => getThemeColor(`--color-chart-${i + 1}`)); - window.Components.dashboard = () => ({ + // Core state stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false }, charts: { quotaDistribution: null, usageTrend: null }, - - // Usage stats usageStats: { total: 0, today: 0, thisHour: 0 }, historyData: {}, - - // Hierarchical model tree: { claude: ['opus-4-5', 'sonnet-4-5'], gemini: ['3-flash'] } modelTree: {}, - families: [], // ['claude', 'gemini'] + families: [], - // Display mode: 'family' or 'model' - displayMode: 'model', + // Filter state (from module) + ...window.DashboardFilters.getInitialState(), - // Selection state - selectedFamilies: [], - selectedModels: {}, // { claude: ['opus-4-5'], gemini: ['3-flash'] } - - showModelFilter: false, + // Debounced chart update to prevent rapid successive updates + _debouncedUpdateTrendChart: null, init() { - // Load saved preferences from localStorage - this.loadPreferences(); + // Create debounced version of updateTrendChart (300ms delay for stability) + this._debouncedUpdateTrendChart = window.utils.debounce(() => { + window.DashboardCharts.updateTrendChart(this); + }, 300); - // Update stats when dashboard becomes active - this.$watch('$store.global.activeTab', (val) => { - if (val === 'dashboard') { + // Load saved preferences from localStorage + window.DashboardFilters.loadPreferences(this); + + // Update stats when dashboard becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'dashboard' && oldVal !== undefined) { this.$nextTick(() => { this.updateStats(); this.updateCharts(); - this.fetchHistory(); + this.updateTrendChart(); }); } }); @@ -60,65 +48,30 @@ window.Components.dashboard = () => ({ } }); + // Watch for history updates from data-store (automatically loaded with account data) + this.$watch('$store.data.usageHistory', (newHistory) => { + if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) { + this.historyData = newHistory; + this.processHistory(newHistory); + this.stats.hasTrendData = true; + } + }); + // Initial update if already on dashboard if (this.$store.global.activeTab === 'dashboard') { this.$nextTick(() => { this.updateStats(); this.updateCharts(); - this.fetchHistory(); + + // Load history if already in store + const history = Alpine.store('data').usageHistory; + if (history && Object.keys(history).length > 0) { + this.historyData = history; + this.processHistory(history); + this.stats.hasTrendData = true; + } }); } - - // Refresh history every 5 minutes - setInterval(() => { - if (this.$store.global.activeTab === 'dashboard') { - this.fetchHistory(); - } - }, 300000); - }, - - loadPreferences() { - try { - const saved = localStorage.getItem('dashboard_chart_prefs'); - if (saved) { - const prefs = JSON.parse(saved); - this.displayMode = prefs.displayMode || 'model'; - this.selectedFamilies = prefs.selectedFamilies || []; - this.selectedModels = prefs.selectedModels || {}; - } - } catch (e) { - console.error('Failed to load dashboard preferences:', e); - } - }, - - savePreferences() { - try { - localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ - displayMode: this.displayMode, - selectedFamilies: this.selectedFamilies, - selectedModels: this.selectedModels - })); - } catch (e) { - console.error('Failed to save dashboard preferences:', e); - } - }, - - async fetchHistory() { - try { - const password = Alpine.store('global').webuiPassword; - const { response, newPassword } = await window.utils.request('/api/stats/history', {}, password); - if (newPassword) Alpine.store('global').webuiPassword = newPassword; - - if (response.ok) { - const history = await response.json(); - this.historyData = history; - this.processHistory(history); - this.stats.hasTrendData = true; - } - } catch (error) { - console.error('Failed to fetch usage history:', error); - this.stats.hasTrendData = true; - } }, processHistory(history) { @@ -150,7 +103,6 @@ window.Components.dashboard = () => ({ } }); } - // Skip old flat format keys (claude, gemini as numbers) }); // Calculate totals @@ -180,458 +132,80 @@ window.Components.dashboard = () => ({ this.updateTrendChart(); }, - autoSelectNew() { - // If no preferences saved, select all - if (this.selectedFamilies.length === 0 && Object.keys(this.selectedModels).length === 0) { - this.selectedFamilies = [...this.families]; - this.families.forEach(family => { - this.selectedModels[family] = [...(this.modelTree[family] || [])]; - }); - this.savePreferences(); - return; - } - - // Add new families/models that appeared - this.families.forEach(family => { - if (!this.selectedFamilies.includes(family)) { - this.selectedFamilies.push(family); - } - if (!this.selectedModels[family]) { - this.selectedModels[family] = []; - } - (this.modelTree[family] || []).forEach(model => { - if (!this.selectedModels[family].includes(model)) { - this.selectedModels[family].push(model); - } - }); - }); + // Delegation methods for stats + updateStats() { + window.DashboardStats.updateStats(this); }, - autoSelectTopN(n = 5) { - // Calculate usage for each model over past 24 hours - const usage = {}; - const now = Date.now(); - const dayAgo = now - 24 * 60 * 60 * 1000; - - Object.entries(this.historyData).forEach(([iso, hourData]) => { - const timestamp = new Date(iso).getTime(); - if (timestamp < dayAgo) return; - - Object.entries(hourData).forEach(([family, familyData]) => { - if (typeof familyData === 'object' && family !== '_total') { - Object.entries(familyData).forEach(([model, count]) => { - if (model !== '_subtotal') { - const key = `${family}:${model}`; - usage[key] = (usage[key] || 0) + count; - } - }); - } - }); - }); - - // Sort by usage and take top N - const sorted = Object.entries(usage) - .sort((a, b) => b[1] - a[1]) - .slice(0, n); - - // Clear current selection - this.selectedFamilies = []; - this.selectedModels = {}; - - // Select top N models - sorted.forEach(([key]) => { - const [family, model] = key.split(':'); - if (!this.selectedFamilies.includes(family)) { - this.selectedFamilies.push(family); - } - if (!this.selectedModels[family]) { - this.selectedModels[family] = []; - } - this.selectedModels[family].push(model); - }); - - this.savePreferences(); - this.refreshChart(); - }, - - // Toggle display mode between family and model level - setDisplayMode(mode) { - this.displayMode = mode; - this.savePreferences(); - this.updateTrendChart(); - }, - - // Toggle family selection - toggleFamily(family) { - const index = this.selectedFamilies.indexOf(family); - if (index > -1) { - this.selectedFamilies.splice(index, 1); - } else { - this.selectedFamilies.push(family); - } - this.savePreferences(); - this.updateTrendChart(); - }, - - // Toggle model selection within a family - toggleModel(family, model) { - if (!this.selectedModels[family]) { - this.selectedModels[family] = []; - } - const index = this.selectedModels[family].indexOf(model); - if (index > -1) { - this.selectedModels[family].splice(index, 1); - } else { - this.selectedModels[family].push(model); - } - this.savePreferences(); - this.updateTrendChart(); - }, - - // Check if family is selected - isFamilySelected(family) { - return this.selectedFamilies.includes(family); - }, - - // Check if model is selected - isModelSelected(family, model) { - return this.selectedModels[family]?.includes(model) || false; - }, - - // Select all families and models - selectAll() { - this.selectedFamilies = [...this.families]; - this.families.forEach(family => { - this.selectedModels[family] = [...(this.modelTree[family] || [])]; - }); - this.savePreferences(); - this.updateTrendChart(); - }, - - // Deselect all - deselectAll() { - this.selectedFamilies = []; - this.selectedModels = {}; - this.savePreferences(); - this.updateTrendChart(); - }, - - // Get color for family - getFamilyColor(family) { - return FAMILY_COLORS[family] || FAMILY_COLORS.other; - }, - - // Get color for model (with index for variation within family) - getModelColor(family, modelIndex) { - const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8); - return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length]; - }, - - // Get count of selected items for display - getSelectedCount() { - if (this.displayMode === 'family') { - return `${this.selectedFamilies.length}/${this.families.length}`; - } - let selected = 0, total = 0; - this.families.forEach(family => { - const models = this.modelTree[family] || []; - total += models.length; - selected += (this.selectedModels[family] || []).length; - }); - return `${selected}/${total}`; + // Delegation methods for charts + updateCharts() { + window.DashboardCharts.updateCharts(this); }, updateTrendChart() { - const ctx = document.getElementById('usageTrendChart'); - if (!ctx || typeof Chart === 'undefined') return; - - if (this.charts.usageTrend) { - this.charts.usageTrend.destroy(); - } - - const history = this.historyData; - const labels = []; - const datasets = []; - - if (this.displayMode === 'family') { - // Aggregate by family - const dataByFamily = {}; - this.selectedFamilies.forEach(family => { - dataByFamily[family] = []; - }); - - Object.entries(history).forEach(([iso, hourData]) => { - const date = new Date(iso); - labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); - - this.selectedFamilies.forEach(family => { - const familyData = hourData[family]; - const count = familyData?._subtotal || 0; - dataByFamily[family].push(count); - }); - }); - - // Build datasets for families - this.selectedFamilies.forEach(family => { - const color = this.getFamilyColor(family); - const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1); - const label = Alpine.store('global').t(familyKey); - datasets.push(this.createDataset( - label, - dataByFamily[family], - color, - ctx - )); - }); + // Use debounced version to prevent rapid successive updates + if (this._debouncedUpdateTrendChart) { + this._debouncedUpdateTrendChart(); } else { - // Show individual models - const dataByModel = {}; - - // Initialize data arrays - this.families.forEach(family => { - (this.selectedModels[family] || []).forEach(model => { - const key = `${family}:${model}`; - dataByModel[key] = []; - }); - }); - - Object.entries(history).forEach(([iso, hourData]) => { - const date = new Date(iso); - labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); - - this.families.forEach(family => { - const familyData = hourData[family] || {}; - (this.selectedModels[family] || []).forEach(model => { - const key = `${family}:${model}`; - dataByModel[key].push(familyData[model] || 0); - }); - }); - }); - - // Build datasets for models - this.families.forEach(family => { - (this.selectedModels[family] || []).forEach((model, modelIndex) => { - const key = `${family}:${model}`; - const color = this.getModelColor(family, modelIndex); - datasets.push(this.createDataset(model, dataByModel[key], color, ctx)); - }); - }); + // Fallback if debounced version not initialized + window.DashboardCharts.updateTrendChart(this); } - - this.charts.usageTrend = new Chart(ctx, { - type: 'line', - data: { labels, datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - legend: { display: false }, - tooltip: { - backgroundColor: getThemeColor('--color-space-950') || 'rgba(24, 24, 27, 0.9)', - titleColor: getThemeColor('--color-text-main'), - bodyColor: getThemeColor('--color-text-bright'), - borderColor: getThemeColor('--color-space-border'), - borderWidth: 1, - padding: 10, - displayColors: true, - callbacks: { - label: function (context) { - return context.dataset.label + ': ' + context.parsed.y; - } - } - } - }, - scales: { - x: { - display: true, - grid: { display: false }, - ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } } - }, - y: { - display: true, - beginAtZero: true, - grid: { display: true, color: getThemeColor('--color-space-border') + '1a' || 'rgba(255,255,255,0.05)' }, - ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } } - } - } - } - }); }, - createDataset(label, data, color, ctx) { - const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200); - // Reduced opacity from 0.3 to 0.12 for less visual noise - gradient.addColorStop(0, this.hexToRgba(color, 0.12)); - gradient.addColorStop(0.6, this.hexToRgba(color, 0.05)); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - return { - label, - data, - borderColor: color, - backgroundColor: gradient, - borderWidth: 2.5, // Slightly thicker line for better visibility - tension: 0.35, // Smoother curves - fill: true, - pointRadius: 2.5, - pointHoverRadius: 6, - pointBackgroundColor: color, - pointBorderColor: 'rgba(9, 9, 11, 0.8)', - pointBorderWidth: 1.5 - }; + // Delegation methods for filters + loadPreferences() { + window.DashboardFilters.loadPreferences(this); }, - hexToRgba(hex, alpha) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (result) { - return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`; - } - return hex; + savePreferences() { + window.DashboardFilters.savePreferences(this); }, - updateStats() { - const accounts = Alpine.store('data').accounts; - let active = 0, limited = 0; - - const isCore = (id) => /sonnet|opus|pro|flash/i.test(id); - - // Only count enabled accounts in statistics - const enabledAccounts = accounts.filter(acc => acc.enabled !== false); - - enabledAccounts.forEach(acc => { - if (acc.status === 'ok') { - const limits = Object.entries(acc.limits || {}); - let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id)); - - if (!hasActiveCore) { - const hasAnyCore = limits.some(([id]) => isCore(id)); - if (!hasAnyCore) { - hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05); - } - } - - if (hasActiveCore) active++; else limited++; - } else { - limited++; - } - }); - - // TOTAL shows only enabled accounts - // Disabled accounts are excluded from all statistics - this.stats.total = enabledAccounts.length; - this.stats.active = active; - this.stats.limited = limited; + setDisplayMode(mode) { + window.DashboardFilters.setDisplayMode(this, mode); }, - updateCharts() { - const ctx = document.getElementById('quotaChart'); - if (!ctx || typeof Chart === 'undefined') return; + toggleFamily(family) { + window.DashboardFilters.toggleFamily(this, family); + }, - if (this.charts.quotaDistribution) { - this.charts.quotaDistribution.destroy(); - } + toggleModel(family, model) { + window.DashboardFilters.toggleModel(this, family, model); + }, - // Use UNFILTERED data for global health chart - const rows = Alpine.store('data').getUnfilteredQuotaData(); + isFamilySelected(family) { + return window.DashboardFilters.isFamilySelected(this, family); + }, - // Dynamic family aggregation (supports any model family) - const familyStats = {}; - rows.forEach(row => { - if (!familyStats[row.family]) { - familyStats[row.family] = { used: 0, total: 0 }; - } - row.quotaInfo.forEach(info => { - familyStats[row.family].used += info.pct; - familyStats[row.family].total += 100; - }); - }); + isModelSelected(family, model) { + return window.DashboardFilters.isModelSelected(this, family, model); + }, - // Calculate global health - const globalTotal = Object.values(familyStats).reduce((sum, f) => sum + f.total, 0); - const globalUsed = Object.values(familyStats).reduce((sum, f) => sum + f.used, 0); - this.stats.overallHealth = globalTotal > 0 ? Math.round((globalUsed / globalTotal) * 100) : 0; + selectAll() { + window.DashboardFilters.selectAll(this); + }, - // Generate chart data dynamically - const familyColors = { - 'claude': getThemeColor('--color-neon-purple'), - 'gemini': getThemeColor('--color-neon-green'), - 'other': getThemeColor('--color-neon-cyan'), - 'unknown': '#666666' - }; + deselectAll() { + window.DashboardFilters.deselectAll(this); + }, - const families = Object.keys(familyStats).sort(); - const segmentSize = families.length > 0 ? 100 / families.length : 100; + getFamilyColor(family) { + return window.DashboardFilters.getFamilyColor(family); + }, - const data = []; - const colors = []; - const labels = []; + getModelColor(family, modelIndex) { + return window.DashboardFilters.getModelColor(family, modelIndex); + }, - families.forEach(family => { - const stats = familyStats[family]; - const health = stats.total > 0 ? Math.round((stats.used / stats.total) * 100) : 0; - const activeVal = (health / 100) * segmentSize; - const inactiveVal = segmentSize - activeVal; + getSelectedCount() { + return window.DashboardFilters.getSelectedCount(this); + }, - const familyColor = familyColors[family] || familyColors['unknown']; + autoSelectNew() { + window.DashboardFilters.autoSelectNew(this); + }, - // Get translation keys - const store = Alpine.store('global'); - const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1); - const familyName = store.t(familyKey); - - // Labels using translations if possible - const activeLabel = family === 'claude' ? store.t('claudeActive') : - family === 'gemini' ? store.t('geminiActive') : - `${familyName} ${store.t('activeSuffix')}`; - - const depletedLabel = family === 'claude' ? store.t('claudeEmpty') : - family === 'gemini' ? store.t('geminiEmpty') : - `${familyName} ${store.t('depleted')}`; - - // Active segment - data.push(activeVal); - colors.push(familyColor); - labels.push(activeLabel); - - // Inactive segment - data.push(inactiveVal); - colors.push(this.hexToRgba(familyColor, 0.1)); - labels.push(depletedLabel); - }); - - this.charts.quotaDistribution = new Chart(ctx, { - type: 'doughnut', - data: { - labels: labels, - datasets: [{ - data: data, - backgroundColor: colors, - borderColor: getThemeColor('--color-space-950'), - borderWidth: 2, - hoverOffset: 0, - borderRadius: 0 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - cutout: '85%', - rotation: -90, - circumference: 360, - plugins: { - legend: { display: false }, - tooltip: { enabled: false }, - title: { display: false } - }, - animation: { - animateScale: true, - animateRotate: true - } - } - }); + autoSelectTopN(n = 5) { + window.DashboardFilters.autoSelectTopN(this, n); } }); diff --git a/public/js/components/dashboard/charts.js b/public/js/components/dashboard/charts.js new file mode 100644 index 0000000..71befda --- /dev/null +++ b/public/js/components/dashboard/charts.js @@ -0,0 +1,493 @@ +/** + * Dashboard Charts Module + * Handles Chart.js visualizations (quota distribution & usage trend) + */ +window.DashboardCharts = window.DashboardCharts || {}; + +// Helper to get CSS variable values (alias to window.utils.getThemeColor) +const getThemeColor = (name) => window.utils.getThemeColor(name); + +// Color palette for different families and models +const FAMILY_COLORS = { + get claude() { + return getThemeColor("--color-neon-purple"); + }, + get gemini() { + return getThemeColor("--color-neon-green"); + }, + get other() { + return getThemeColor("--color-neon-cyan"); + }, +}; + +const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => + getThemeColor(`--color-chart-${i + 1}`) +); + +// Export constants for filter module +window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS }; + +// Module-level lock to prevent concurrent chart updates (fixes race condition) +let _trendChartUpdateLock = false; + +/** + * Convert hex color to rgba + * @param {string} hex - Hex color string + * @param {number} alpha - Alpha value (0-1) + * @returns {string} rgba color string + */ +window.DashboardCharts.hexToRgba = function (hex, alpha) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + return `rgba(${parseInt(result[1], 16)}, ${parseInt( + result[2], + 16 + )}, ${parseInt(result[3], 16)}, ${alpha})`; + } + return hex; +}; + +/** + * Check if canvas is ready for Chart creation + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {boolean} True if canvas is ready + */ +function isCanvasReady(canvas) { + if (!canvas || !canvas.isConnected) return false; + if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false; + + try { + const ctx = canvas.getContext("2d"); + return !!ctx; + } catch (e) { + return false; + } +} + +/** + * Create a Chart.js dataset with gradient fill + * @param {string} label - Dataset label + * @param {Array} data - Data points + * @param {string} color - Line color + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {object} Chart.js dataset configuration + */ +window.DashboardCharts.createDataset = function (label, data, color, canvas) { + let gradient; + + try { + // Safely create gradient with fallback + if (canvas && canvas.getContext) { + const ctx = canvas.getContext("2d"); + if (ctx && ctx.createLinearGradient) { + gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12)); + gradient.addColorStop( + 0.6, + window.DashboardCharts.hexToRgba(color, 0.05) + ); + gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + } + } + } catch (e) { + console.warn("Failed to create gradient, using solid color fallback:", e); + gradient = null; + } + + // Fallback to solid color if gradient creation failed + const backgroundColor = + gradient || window.DashboardCharts.hexToRgba(color, 0.08); + + return { + label, + data, + borderColor: color, + backgroundColor: backgroundColor, + borderWidth: 2.5, + tension: 0.35, + fill: true, + pointRadius: 2.5, + pointHoverRadius: 6, + pointBackgroundColor: color, + pointBorderColor: "rgba(9, 9, 11, 0.8)", + pointBorderWidth: 1.5, + }; +}; + +/** + * Update quota distribution donut chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateCharts = function (component) { + // Safely destroy existing chart instance FIRST + if (component.charts.quotaDistribution) { + try { + component.charts.quotaDistribution.destroy(); + } catch (e) { + console.error("Failed to destroy quota chart:", e); + } + component.charts.quotaDistribution = null; + } + + const canvas = document.getElementById("quotaChart"); + + // Safety checks + if (!canvas) { + console.warn("quotaChart canvas not found"); + return; + } + if (typeof Chart === "undefined") { + console.warn("Chart.js not loaded"); + return; + } + if (!isCanvasReady(canvas)) { + console.warn("quotaChart canvas not ready, skipping update"); + return; + } + + // Use UNFILTERED data for global health chart + const rows = Alpine.store("data").getUnfilteredQuotaData(); + if (!rows || rows.length === 0) return; + + const healthByFamily = {}; + let totalHealthSum = 0; + let totalModelCount = 0; + + rows.forEach((row) => { + const family = row.family || "unknown"; + if (!healthByFamily[family]) { + healthByFamily[family] = { total: 0, weighted: 0 }; + } + + // Calculate average health from quotaInfo (each entry has { pct }) + // Health = average of all account quotas for this model + const quotaInfo = row.quotaInfo || []; + if (quotaInfo.length > 0) { + const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length; + healthByFamily[family].total++; + healthByFamily[family].weighted += avgHealth; + totalHealthSum += avgHealth; + totalModelCount++; + } + }); + + // Update overall health for dashboard display + component.stats.overallHealth = totalModelCount > 0 + ? Math.round(totalHealthSum / totalModelCount) + : 0; + + const familyColors = { + claude: getThemeColor("--color-neon-purple"), + gemini: getThemeColor("--color-neon-green"), + unknown: getThemeColor("--color-neon-cyan"), + }; + + const data = []; + const colors = []; + const labels = []; + + const totalFamilies = Object.keys(healthByFamily).length; + const segmentSize = 100 / totalFamilies; + + Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => { + const health = weighted / total; + const activeVal = (health / 100) * segmentSize; + const inactiveVal = segmentSize - activeVal; + + const familyColor = familyColors[family] || familyColors["unknown"]; + + // Get translation keys + const store = Alpine.store("global"); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const familyName = store.t(familyKey); + + // Labels using translations if possible + const activeLabel = + family === "claude" + ? store.t("claudeActive") + : family === "gemini" + ? store.t("geminiActive") + : `${familyName} ${store.t("activeSuffix")}`; + + const depletedLabel = + family === "claude" + ? store.t("claudeEmpty") + : family === "gemini" + ? store.t("geminiEmpty") + : `${familyName} ${store.t("depleted")}`; + + // Active segment + data.push(activeVal); + colors.push(familyColor); + labels.push(activeLabel); + + // Inactive segment + data.push(inactiveVal); + colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.1)); + labels.push(depletedLabel); + }); + + try { + component.charts.quotaDistribution = new Chart(canvas, { + type: "doughnut", + data: { + labels: labels, + datasets: [ + { + data: data, + backgroundColor: colors, + borderColor: getThemeColor("--color-space-950"), + borderWidth: 2, + hoverOffset: 0, + borderRadius: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "85%", + rotation: -90, + circumference: 360, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + title: { display: false }, + }, + animation: { + animateScale: true, + animateRotate: true, + }, + }, + }); + } catch (e) { + console.error("Failed to create quota chart:", e); + } +}; + +/** + * Update usage trend line chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateTrendChart = function (component) { + // Prevent concurrent updates (fixes race condition on rapid toggling) + if (_trendChartUpdateLock) { + console.log("[updateTrendChart] Update already in progress, skipping"); + return; + } + _trendChartUpdateLock = true; + + console.log("[updateTrendChart] Starting update..."); + + // Safely destroy existing chart instance FIRST + if (component.charts.usageTrend) { + console.log("[updateTrendChart] Destroying existing chart"); + try { + // Stop all animations before destroying to prevent null context errors + component.charts.usageTrend.stop(); + component.charts.usageTrend.destroy(); + } catch (e) { + console.error("[updateTrendChart] Failed to destroy chart:", e); + } + component.charts.usageTrend = null; + } + + const canvas = document.getElementById("usageTrendChart"); + + // Safety checks + if (!canvas) { + console.error("[updateTrendChart] Canvas not found in DOM!"); + return; + } + if (typeof Chart === "undefined") { + console.error("[updateTrendChart] Chart.js not loaded"); + return; + } + + console.log("[updateTrendChart] Canvas element:", { + exists: !!canvas, + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + parentElement: canvas.parentElement?.tagName, + }); + + if (!isCanvasReady(canvas)) { + console.error("[updateTrendChart] Canvas not ready!", { + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + }); + _trendChartUpdateLock = false; + return; + } + + // Clear canvas to ensure clean state after destroy + try { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } catch (e) { + console.warn("[updateTrendChart] Failed to clear canvas:", e); + } + + console.log( + "[updateTrendChart] Canvas is ready, proceeding with chart creation" + ); + + const history = component.historyData; + if (!history || Object.keys(history).length === 0) { + console.warn("No history data available for trend chart"); + _trendChartUpdateLock = false; + return; + } + + const labels = []; + const datasets = []; + + if (component.displayMode === "family") { + // Aggregate by family + const dataByFamily = {}; + component.selectedFamilies.forEach((family) => { + dataByFamily[family] = []; + }); + + Object.entries(history).forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push( + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); + + component.selectedFamilies.forEach((family) => { + const familyData = hourData[family]; + const count = familyData?._subtotal || 0; + dataByFamily[family].push(count); + }); + }); + + // Build datasets for families + component.selectedFamilies.forEach((family) => { + const color = window.DashboardFilters.getFamilyColor(family); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const label = Alpine.store("global").t(familyKey); + datasets.push( + window.DashboardCharts.createDataset( + label, + dataByFamily[family], + color, + canvas + ) + ); + }); + } else { + // Show individual models + const dataByModel = {}; + + // Initialize data arrays + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key] = []; + }); + }); + + Object.entries(history).forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push( + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); + + component.families.forEach((family) => { + const familyData = hourData[family] || {}; + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key].push(familyData[model] || 0); + }); + }); + }); + + // Build datasets for models + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model, modelIndex) => { + const key = `${family}:${model}`; + const color = window.DashboardFilters.getModelColor(family, modelIndex); + datasets.push( + window.DashboardCharts.createDataset( + model, + dataByModel[key], + color, + canvas + ) + ); + }); + }); + } + + try { + component.charts.usageTrend = new Chart(canvas, { + type: "line", + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 300, // Reduced animation for faster updates + }, + interaction: { + mode: "index", + intersect: false, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: + getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)", + titleColor: getThemeColor("--color-text-main"), + bodyColor: getThemeColor("--color-text-bright"), + borderColor: getThemeColor("--color-space-border"), + borderWidth: 1, + padding: 10, + displayColors: true, + callbacks: { + label: function (context) { + return context.dataset.label + ": " + context.parsed.y; + }, + }, + }, + }, + scales: { + x: { + display: true, + grid: { display: false }, + ticks: { + color: getThemeColor("--color-text-muted"), + font: { size: 10 }, + }, + }, + y: { + display: true, + beginAtZero: true, + grid: { + display: true, + color: + getThemeColor("--color-space-border") + "1a" || + "rgba(255,255,255,0.05)", + }, + ticks: { + color: getThemeColor("--color-text-muted"), + font: { size: 10 }, + }, + }, + }, + }, + }); + } catch (e) { + console.error("Failed to create trend chart:", e); + } finally { + // Always release lock + _trendChartUpdateLock = false; + } +}; diff --git a/public/js/components/dashboard/filters.js b/public/js/components/dashboard/filters.js new file mode 100644 index 0000000..26f2752 --- /dev/null +++ b/public/js/components/dashboard/filters.js @@ -0,0 +1,275 @@ +/** + * Dashboard Filters Module + * Handles model/family filter selection and persistence + */ +window.DashboardFilters = window.DashboardFilters || {}; + +/** + * Get initial filter state + * @returns {object} Initial state for filter properties + */ +window.DashboardFilters.getInitialState = function() { + return { + displayMode: 'model', + selectedFamilies: [], + selectedModels: {}, + showModelFilter: false + }; +}; + +/** + * Load filter preferences from localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.loadPreferences = function(component) { + try { + const saved = localStorage.getItem('dashboard_chart_prefs'); + if (saved) { + const prefs = JSON.parse(saved); + component.displayMode = prefs.displayMode || 'model'; + component.selectedFamilies = prefs.selectedFamilies || []; + component.selectedModels = prefs.selectedModels || {}; + } + } catch (e) { + console.error('Failed to load dashboard preferences:', e); + } +}; + +/** + * Save filter preferences to localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.savePreferences = function(component) { + try { + localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ + displayMode: component.displayMode, + selectedFamilies: component.selectedFamilies, + selectedModels: component.selectedModels + })); + } catch (e) { + console.error('Failed to save dashboard preferences:', e); + } +}; + +/** + * Set display mode (family or model) + * @param {object} component - Dashboard component instance + * @param {string} mode - 'family' or 'model' + */ +window.DashboardFilters.setDisplayMode = function(component, mode) { + component.displayMode = mode; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Toggle family selection + * @param {object} component - Dashboard component instance + * @param {string} family - Family name (e.g., 'claude', 'gemini') + */ +window.DashboardFilters.toggleFamily = function(component, family) { + const index = component.selectedFamilies.indexOf(family); + if (index > -1) { + component.selectedFamilies.splice(index, 1); + } else { + component.selectedFamilies.push(family); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Toggle model selection within a family + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + */ +window.DashboardFilters.toggleModel = function(component, family, model) { + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + const index = component.selectedModels[family].indexOf(model); + if (index > -1) { + component.selectedModels[family].splice(index, 1); + } else { + component.selectedModels[family].push(model); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Check if family is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @returns {boolean} + */ +window.DashboardFilters.isFamilySelected = function(component, family) { + return component.selectedFamilies.includes(family); +}; + +/** + * Check if model is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + * @returns {boolean} + */ +window.DashboardFilters.isModelSelected = function(component, family, model) { + return component.selectedModels[family]?.includes(model) || false; +}; + +/** + * Select all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.selectAll = function(component) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Deselect all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.deselectAll = function(component) { + component.selectedFamilies = []; + component.selectedModels = {}; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Get color for a family + * @param {string} family - Family name + * @returns {string} Color value + */ +window.DashboardFilters.getFamilyColor = function(family) { + const FAMILY_COLORS = window.DashboardConstants?.FAMILY_COLORS || {}; + return FAMILY_COLORS[family] || FAMILY_COLORS.other; +}; + +/** + * Get color for a model (with index for variation within family) + * @param {string} family - Family name + * @param {number} modelIndex - Index of model within family + * @returns {string} Color value + */ +window.DashboardFilters.getModelColor = function(family, modelIndex) { + const MODEL_COLORS = window.DashboardConstants?.MODEL_COLORS || []; + const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8); + return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length]; +}; + +/** + * Get count of selected items for display + * @param {object} component - Dashboard component instance + * @returns {string} Selected count string (e.g., "3/5") + */ +window.DashboardFilters.getSelectedCount = function(component) { + if (component.displayMode === 'family') { + return `${component.selectedFamilies.length}/${component.families.length}`; + } + let selected = 0, total = 0; + component.families.forEach(family => { + const models = component.modelTree[family] || []; + total += models.length; + selected += (component.selectedModels[family] || []).length; + }); + return `${selected}/${total}`; +}; + +/** + * Auto-select new families/models that haven't been configured + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.autoSelectNew = function(component) { + // If no preferences saved, select all + if (component.selectedFamilies.length === 0 && Object.keys(component.selectedModels).length === 0) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + return; + } + + // Add new families/models that appeared + component.families.forEach(family => { + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + (component.modelTree[family] || []).forEach(model => { + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + }); +}; + +/** + * Auto-select top N models by usage (past 24 hours) + * @param {object} component - Dashboard component instance + * @param {number} n - Number of models to select (default: 5) + */ +window.DashboardFilters.autoSelectTopN = function(component, n = 5) { + // Calculate usage for each model over past 24 hours + const usage = {}; + const now = Date.now(); + const dayAgo = now - 24 * 60 * 60 * 1000; + + Object.entries(component.historyData).forEach(([iso, hourData]) => { + const timestamp = new Date(iso).getTime(); + if (timestamp < dayAgo) return; + + Object.entries(hourData).forEach(([family, familyData]) => { + if (typeof familyData === 'object' && family !== '_total') { + Object.entries(familyData).forEach(([model, count]) => { + if (model !== '_subtotal') { + const key = `${family}:${model}`; + usage[key] = (usage[key] || 0) + count; + } + }); + } + }); + }); + + // Sort by usage and take top N + const sorted = Object.entries(usage) + .sort((a, b) => b[1] - a[1]) + .slice(0, n); + + // Clear current selection + component.selectedFamilies = []; + component.selectedModels = {}; + + // Select top models and their families + sorted.forEach(([key, _]) => { + const [family, model] = key.split(':'); + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; diff --git a/public/js/components/dashboard/stats.js b/public/js/components/dashboard/stats.js new file mode 100644 index 0000000..967804c --- /dev/null +++ b/public/js/components/dashboard/stats.js @@ -0,0 +1,43 @@ +/** + * Dashboard Stats Module + * Handles account statistics calculation + */ +window.DashboardStats = window.DashboardStats || {}; + +/** + * Update account statistics (active, limited, total) + * @param {object} component - Dashboard component instance + */ +window.DashboardStats.updateStats = function(component) { + const accounts = Alpine.store('data').accounts; + let active = 0, limited = 0; + + const isCore = (id) => /sonnet|opus|pro|flash/i.test(id); + + // Only count enabled accounts in statistics + const enabledAccounts = accounts.filter(acc => acc.enabled !== false); + + enabledAccounts.forEach(acc => { + if (acc.status === 'ok') { + const limits = Object.entries(acc.limits || {}); + let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id)); + + if (!hasActiveCore) { + const hasAnyCore = limits.some(([id]) => isCore(id)); + if (!hasAnyCore) { + hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05); + } + } + + if (hasActiveCore) active++; else limited++; + } else { + limited++; + } + }); + + // TOTAL shows only enabled accounts + // Disabled accounts are excluded from all statistics + component.stats.total = enabledAccounts.length; + component.stats.active = active; + component.stats.limited = limited; +}; diff --git a/public/js/components/logs-viewer.js b/public/js/components/logs-viewer.js index 28c24a5..969032f 100644 --- a/public/js/components/logs-viewer.js +++ b/public/js/components/logs-viewer.js @@ -70,7 +70,7 @@ window.Components.logsViewer = () => ({ this.logs.push(log); // Limit log buffer - const limit = Alpine.store('settings')?.logLimit || 2000; + const limit = Alpine.store('settings')?.logLimit || window.AppConstants.LIMITS.DEFAULT_LOG_LIMIT; if (this.logs.length > limit) { this.logs = this.logs.slice(-limit); } diff --git a/public/js/components/model-manager.js b/public/js/components/model-manager.js index 3ec866a..607cc42 100644 --- a/public/js/components/model-manager.js +++ b/public/js/components/model-manager.js @@ -37,33 +37,11 @@ window.Components.modelManager = () => ({ }, /** - * Update model configuration with authentication + * Update model configuration (delegates to shared utility) * @param {string} modelId - The model ID to update * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) */ async updateModelConfig(modelId, configUpdates) { - const store = Alpine.store('global'); - try { - const { response, newPassword } = await window.utils.request('/api/models/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ modelId, config: configUpdates }) - }, store.webuiPassword); - - if (newPassword) store.webuiPassword = newPassword; - - if (!response.ok) { - throw new Error('Failed to update model config'); - } - - // Optimistic update - Alpine.store('data').modelConfig[modelId] = { - ...Alpine.store('data').modelConfig[modelId], - ...configUpdates - }; - Alpine.store('data').computeQuotaRows(); - } catch (e) { - store.showToast('Failed to update model config: ' + e.message, 'error'); - } + return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); } }); diff --git a/public/js/components/models.js b/public/js/components/models.js index 84d4581..68f6b40 100644 --- a/public/js/components/models.js +++ b/public/js/components/models.js @@ -7,9 +7,9 @@ window.Components = window.Components || {}; window.Components.models = () => ({ init() { - // Ensure data is fetched when this tab becomes active - this.$watch('$store.global.activeTab', (val) => { - if (val === 'models') { + // Ensure data is fetched when this tab becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'models' && oldVal !== undefined) { // Trigger recompute to ensure filters are applied this.$nextTick(() => { Alpine.store('data').computeQuotaRows(); @@ -26,33 +26,11 @@ window.Components.models = () => ({ }, /** - * Update model configuration (Pin/Hide quick actions) + * Update model configuration (delegates to shared utility) * @param {string} modelId - The model ID to update * @param {object} configUpdates - Configuration updates (pinned, hidden) */ async updateModelConfig(modelId, configUpdates) { - const store = Alpine.store('global'); - try { - const { response, newPassword } = await window.utils.request('/api/models/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ modelId, config: configUpdates }) - }, store.webuiPassword); - - if (newPassword) store.webuiPassword = newPassword; - - if (!response.ok) { - throw new Error('Failed to update model config'); - } - - // Optimistic update - Alpine.store('data').modelConfig[modelId] = { - ...Alpine.store('data').modelConfig[modelId], - ...configUpdates - }; - Alpine.store('data').computeQuotaRows(); - } catch (e) { - store.showToast('Failed to update: ' + e.message, 'error'); - } + return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); } }); diff --git a/public/js/components/server-config.js b/public/js/components/server-config.js index 135f1cf..5ab7f8b 100644 --- a/public/js/components/server-config.js +++ b/public/js/components/server-config.js @@ -16,9 +16,9 @@ window.Components.serverConfig = () => ({ this.fetchServerConfig(); } - // Watch local activeTab (from parent settings scope) - this.$watch('activeTab', (tab) => { - if (tab === 'server') { + // Watch local activeTab (from parent settings scope, skip initial trigger) + this.$watch('activeTab', (tab, oldTab) => { + if (tab === 'server' && oldTab !== undefined) { this.fetchServerConfig(); } }); @@ -164,10 +164,23 @@ window.Components.serverConfig = () => ({ } }, - // Generic debounced save method for numeric configs - async saveConfigField(fieldName, value, displayName) { + // Generic debounced save method for numeric configs with validation + async saveConfigField(fieldName, value, displayName, validator = null) { const store = Alpine.store('global'); + // Validate input if validator provided + if (validator) { + const validation = window.Validators.validate(value, validator, true); + if (!validation.isValid) { + // Rollback to previous value + this.serverConfig[fieldName] = this.serverConfig[fieldName]; + return; + } + value = validation.value; + } else { + value = parseInt(value); + } + // Clear existing timer for this field if (this.debounceTimers[fieldName]) { clearTimeout(this.debounceTimers[fieldName]); @@ -175,13 +188,13 @@ window.Components.serverConfig = () => ({ // Optimistic update const previousValue = this.serverConfig[fieldName]; - this.serverConfig[fieldName] = parseInt(value); + this.serverConfig[fieldName] = value; // Set new timer this.debounceTimers[fieldName] = setTimeout(async () => { try { const payload = {}; - payload[fieldName] = parseInt(value); + payload[fieldName] = value; const { response, newPassword } = await window.utils.request('/api/config', { method: 'POST', @@ -203,27 +216,37 @@ window.Components.serverConfig = () => ({ this.serverConfig[fieldName] = previousValue; store.showToast(`Failed to update ${displayName}: ` + e.message, 'error'); } - }, 500); // 500ms debounce + }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE); }, - // Individual toggle methods for each Advanced Tuning field + // Individual toggle methods for each Advanced Tuning field with validation toggleMaxRetries(value) { - this.saveConfigField('maxRetries', value, 'Max Retries'); + const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxRetries', value, 'Max Retries', + (v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries')); }, toggleRetryBaseMs(value) { - this.saveConfigField('retryBaseMs', value, 'Retry Base Delay'); + const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryBaseMs', value, 'Retry Base Delay', + (v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay')); }, toggleRetryMaxMs(value) { - this.saveConfigField('retryMaxMs', value, 'Retry Max Delay'); + const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryMaxMs', value, 'Retry Max Delay', + (v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay')); }, toggleDefaultCooldownMs(value) { - this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown'); + const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown', + (v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX)); }, toggleMaxWaitBeforeErrorMs(value) { - this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold'); + const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold', + (v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX)); } }); diff --git a/public/js/config/constants.js b/public/js/config/constants.js new file mode 100644 index 0000000..a5a1b6c --- /dev/null +++ b/public/js/config/constants.js @@ -0,0 +1,82 @@ +/** + * Application Constants + * Centralized configuration values and magic numbers + */ +window.AppConstants = window.AppConstants || {}; + +/** + * Time intervals (in milliseconds) + */ +window.AppConstants.INTERVALS = { + // Dashboard refresh interval (5 minutes) + DASHBOARD_REFRESH: 300000, + + // OAuth message handler timeout (5 minutes) + OAUTH_MESSAGE_TIMEOUT: 300000, + + // Server config debounce delay + CONFIG_DEBOUNCE: 500, + + // General short delay (for UI transitions) + SHORT_DELAY: 2000 +}; + +/** + * Data limits and quotas + */ +window.AppConstants.LIMITS = { + // Default log limit + DEFAULT_LOG_LIMIT: 2000, + + // Minimum quota value + MIN_QUOTA: 100, + + // Percentage base (for calculations) + PERCENTAGE_BASE: 100 +}; + +/** + * Validation ranges + */ +window.AppConstants.VALIDATION = { + // Port range + PORT_MIN: 1, + PORT_MAX: 65535, + + // Timeout range (0 - 5 minutes) + TIMEOUT_MIN: 0, + TIMEOUT_MAX: 300000, + + // Log limit range + LOG_LIMIT_MIN: 100, + LOG_LIMIT_MAX: 10000, + + // Retry configuration ranges + MAX_RETRIES_MIN: 0, + MAX_RETRIES_MAX: 20, + + RETRY_BASE_MS_MIN: 100, + RETRY_BASE_MS_MAX: 10000, + + RETRY_MAX_MS_MIN: 1000, + RETRY_MAX_MS_MAX: 60000, + + // Cooldown range (0 - 10 minutes) + DEFAULT_COOLDOWN_MIN: 0, + DEFAULT_COOLDOWN_MAX: 600000, + + // Max wait threshold (1 - 30 minutes) + MAX_WAIT_MIN: 60000, + MAX_WAIT_MAX: 1800000 +}; + +/** + * UI Constants + */ +window.AppConstants.UI = { + // Toast auto-dismiss duration + TOAST_DURATION: 3000, + + // Loading spinner delay + LOADING_DELAY: 200 +}; diff --git a/public/js/data-store.js b/public/js/data-store.js index 5b80947..4bcb24d 100644 --- a/public/js/data-store.js +++ b/public/js/data-store.js @@ -12,6 +12,7 @@ document.addEventListener('alpine:init', () => { models: [], // Source of truth modelConfig: {}, // Model metadata (hidden, pinned, alias) quotaRows: [], // Filtered view + usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) loading: false, connectionStatus: 'connecting', lastUpdated: '-', @@ -39,7 +40,10 @@ document.addEventListener('alpine:init', () => { try { // Get password from global store const password = Alpine.store('global').webuiPassword; - const { response, newPassword } = await window.utils.request('/account-limits', {}, password); + + // Include history for dashboard (single API call optimization) + const url = '/account-limits?includeHistory=true'; + const { response, newPassword } = await window.utils.request(url, {}, password); if (newPassword) Alpine.store('global').webuiPassword = newPassword; @@ -52,6 +56,11 @@ document.addEventListener('alpine:init', () => { } this.modelConfig = data.modelConfig || {}; + // Store usage history if included (for dashboard) + if (data.history) { + this.usageHistory = data.history; + } + this.computeQuotaRows(); this.connectionStatus = 'connected'; diff --git a/public/js/utils.js b/public/js/utils.js index 34cae36..bdfa9c3 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -47,5 +47,23 @@ window.utils = { getThemeColor(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + }, + + /** + * Debounce function - delays execution until after specified wait time + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {Function} Debounced function + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; } }; diff --git a/public/js/utils/error-handler.js b/public/js/utils/error-handler.js new file mode 100644 index 0000000..8289485 --- /dev/null +++ b/public/js/utils/error-handler.js @@ -0,0 +1,107 @@ +/** + * Error Handling Utilities + * Provides standardized error handling with toast notifications + */ +window.ErrorHandler = window.ErrorHandler || {}; + +/** + * Safely execute an async function with error handling + * @param {Function} fn - Async function to execute + * @param {string} errorMessage - User-friendly error message prefix + * @param {object} options - Additional options + * @param {boolean} options.rethrow - Whether to rethrow the error after handling (default: false) + * @param {Function} options.onError - Custom error handler callback + * @returns {Promise} Result of the function or undefined on error + */ +window.ErrorHandler.safeAsync = async function(fn, errorMessage = 'Operation failed', options = {}) { + const { rethrow = false, onError = null } = options; + const store = Alpine.store('global'); + + try { + return await fn(); + } catch (error) { + // Log error for debugging + console.error(`[ErrorHandler] ${errorMessage}:`, error); + + // Show toast notification + const fullMessage = `${errorMessage}: ${error.message || 'Unknown error'}`; + store.showToast(fullMessage, 'error'); + + // Call custom error handler if provided + if (onError && typeof onError === 'function') { + try { + onError(error); + } catch (handlerError) { + console.error('[ErrorHandler] Custom error handler failed:', handlerError); + } + } + + // Rethrow if requested + if (rethrow) { + throw error; + } + + return undefined; + } +}; + +/** + * Wrap a component method with error handling + * @param {Function} method - Method to wrap + * @param {string} errorMessage - Error message prefix + * @returns {Function} Wrapped method + */ +window.ErrorHandler.wrapMethod = function(method, errorMessage = 'Operation failed') { + return async function(...args) { + return window.ErrorHandler.safeAsync( + () => method.apply(this, args), + errorMessage + ); + }; +}; + +/** + * Show a success toast notification + * @param {string} message - Success message + */ +window.ErrorHandler.showSuccess = function(message) { + const store = Alpine.store('global'); + store.showToast(message, 'success'); +}; + +/** + * Show an info toast notification + * @param {string} message - Info message + */ +window.ErrorHandler.showInfo = function(message) { + const store = Alpine.store('global'); + store.showToast(message, 'info'); +}; + +/** + * Show an error toast notification + * @param {string} message - Error message + * @param {Error} error - Optional error object + */ +window.ErrorHandler.showError = function(message, error = null) { + const store = Alpine.store('global'); + const fullMessage = error ? `${message}: ${error.message}` : message; + store.showToast(fullMessage, 'error'); +}; + +/** + * Validate and execute an API call with error handling + * @param {Function} apiCall - Async function that makes the API call + * @param {string} successMessage - Message to show on success (optional) + * @param {string} errorMessage - Message to show on error + * @returns {Promise} API response or undefined on error + */ +window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, errorMessage = 'API call failed') { + const result = await window.ErrorHandler.safeAsync(apiCall, errorMessage); + + if (result !== undefined && successMessage) { + window.ErrorHandler.showSuccess(successMessage); + } + + return result; +}; diff --git a/public/js/utils/model-config.js b/public/js/utils/model-config.js new file mode 100644 index 0000000..46a2cc7 --- /dev/null +++ b/public/js/utils/model-config.js @@ -0,0 +1,42 @@ +/** + * Model Configuration Utilities + * Shared functions for model configuration updates + */ +window.ModelConfigUtils = window.ModelConfigUtils || {}; + +/** + * Update model configuration with authentication and optimistic updates + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) + * @returns {Promise} + */ +window.ModelConfigUtils.updateModelConfig = async function(modelId, configUpdates) { + return window.ErrorHandler.safeAsync(async () => { + const store = Alpine.store('global'); + + const { response, newPassword } = await window.utils.request('/api/models/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modelId, config: configUpdates }) + }, store.webuiPassword); + + // Update password if server provided a new one + if (newPassword) { + store.webuiPassword = newPassword; + } + + if (!response.ok) { + throw new Error('Failed to update model config'); + } + + // Optimistic update of local state + const dataStore = Alpine.store('data'); + dataStore.modelConfig[modelId] = { + ...dataStore.modelConfig[modelId], + ...configUpdates + }; + + // Recompute quota rows to reflect changes + dataStore.computeQuotaRows(); + }, 'Failed to update model config'); +}; diff --git a/public/js/utils/validators.js b/public/js/utils/validators.js new file mode 100644 index 0000000..cedbb2f --- /dev/null +++ b/public/js/utils/validators.js @@ -0,0 +1,168 @@ +/** + * Input Validation Utilities + * Provides validation functions for user inputs + */ +window.Validators = window.Validators || {}; + +/** + * Validate a number is within a range + * @param {number} value - Value to validate + * @param {number} min - Minimum allowed value (inclusive) + * @param {number} max - Maximum allowed value (inclusive) + * @param {string} fieldName - Name of the field for error messages + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateRange = function(value, min, max, fieldName = 'Value') { + const numValue = Number(value); + + if (isNaN(numValue)) { + return { + isValid: false, + value: min, + error: `${fieldName} must be a valid number` + }; + } + + if (numValue < min) { + return { + isValid: false, + value: min, + error: `${fieldName} must be at least ${min}` + }; + } + + if (numValue > max) { + return { + isValid: false, + value: max, + error: `${fieldName} must be at most ${max}` + }; + } + + return { + isValid: true, + value: numValue, + error: null + }; +}; + +/** + * Validate a port number + * @param {number} port - Port number to validate + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validatePort = function(port) { + const { PORT_MIN, PORT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(port, PORT_MIN, PORT_MAX, 'Port'); +}; + +/** + * Validate a string is not empty + * @param {string} value - String to validate + * @param {string} fieldName - Name of the field for error messages + * @returns {object} { isValid: boolean, value: string, error: string|null } + */ +window.Validators.validateNotEmpty = function(value, fieldName = 'Field') { + const trimmedValue = String(value || '').trim(); + + if (trimmedValue.length === 0) { + return { + isValid: false, + value: trimmedValue, + error: `${fieldName} cannot be empty` + }; + } + + return { + isValid: true, + value: trimmedValue, + error: null + }; +}; + +/** + * Validate a boolean value + * @param {any} value - Value to validate as boolean + * @returns {object} { isValid: boolean, value: boolean, error: string|null } + */ +window.Validators.validateBoolean = function(value) { + if (typeof value === 'boolean') { + return { + isValid: true, + value: value, + error: null + }; + } + + // Try to coerce common values + if (value === 'true' || value === 1 || value === '1') { + return { isValid: true, value: true, error: null }; + } + + if (value === 'false' || value === 0 || value === '0') { + return { isValid: true, value: false, error: null }; + } + + return { + isValid: false, + value: false, + error: 'Value must be true or false' + }; +}; + +/** + * Validate a timeout/duration value (in milliseconds) + * @param {number} value - Timeout value in ms + * @param {number} minMs - Minimum allowed timeout (default: from constants) + * @param {number} maxMs - Maximum allowed timeout (default: from constants) + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateTimeout = function(value, minMs = null, maxMs = null) { + const { TIMEOUT_MIN, TIMEOUT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(value, minMs ?? TIMEOUT_MIN, maxMs ?? TIMEOUT_MAX, 'Timeout'); +}; + +/** + * Validate log limit + * @param {number} value - Log limit value + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateLogLimit = function(value) { + const { LOG_LIMIT_MIN, LOG_LIMIT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(value, LOG_LIMIT_MIN, LOG_LIMIT_MAX, 'Log limit'); +}; + +/** + * Validate and sanitize input with custom validator + * @param {any} value - Value to validate + * @param {Function} validator - Validator function + * @param {boolean} showError - Whether to show error toast (default: true) + * @returns {object} Validation result + */ +window.Validators.validate = function(value, validator, showError = true) { + const result = validator(value); + + if (!result.isValid && showError && result.error) { + window.ErrorHandler.showError(result.error); + } + + return result; +}; + +/** + * Create a validated input handler for Alpine.js + * @param {Function} validator - Validator function + * @param {Function} onValid - Callback when validation passes + * @returns {Function} Handler function + */ +window.Validators.createHandler = function(validator, onValid) { + return function(value) { + const result = window.Validators.validate(value, validator, true); + + if (result.isValid && onValid) { + onValid.call(this, result.value); + } + + return result.value; + }; +}; diff --git a/public/views/models.html b/public/views/models.html index 25e3d13..1cf5b49 100644 --- a/public/views/models.html +++ b/public/views/models.html @@ -133,17 +133,7 @@
+ :data-tip="row.quotaInfo && row.quotaInfo.length > 0 ? row.quotaInfo.filter(q => q.resetTime).map(q => q.email + ': ' + q.resetTime).join('\n') : 'No reset data'">
@@ -158,7 +148,7 @@