perf(webui): refactor dashboard modules and optimize API performance

This commit is contained in:
Wha1eChai
2026-01-09 17:58:09 +08:00
parent e909ea6fe3
commit a914821d49
20 changed files with 1420 additions and 599 deletions

View File

@@ -346,13 +346,21 @@
</dialog>
<!-- Scripts - Loading Order Matters! -->
<!-- 1. Utils (global helpers) -->
<!-- 1. Config & Utils (global helpers) -->
<script src="js/config/constants.js"></script>
<script src="js/utils.js"></script>
<script src="js/utils/error-handler.js"></script>
<script src="js/utils/validators.js"></script>
<script src="js/utils/model-config.js"></script>
<!-- 2. Alpine Stores (register alpine:init listeners) -->
<script src="js/store.js"></script>
<script src="js/data-store.js"></script>
<script src="js/settings-store.js"></script>
<!-- 3. Components (register to window.Components) -->
<!-- Dashboard modules (load before main dashboard) -->
<script src="js/components/dashboard/stats.js"></script>
<script src="js/components/dashboard/charts.js"></script>
<script src="js/components/dashboard/filters.js"></script>
<script src="js/components/dashboard.js"></script>
<script src="js/components/models.js"></script>
<script src="js/components/account-manager.js"></script>

View File

@@ -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');

View File

@@ -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 || [];
});

View File

@@ -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);
}
});

View File

@@ -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;
}
};

View File

@@ -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();
};

View File

@@ -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;
};

View File

@@ -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);
}

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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));
}
});

View File

@@ -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
};

View File

@@ -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';

View File

@@ -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);
};
}
};

View File

@@ -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<any>} 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<any>} 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;
};

View File

@@ -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<void>}
*/
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');
};

View File

@@ -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;
};
};

View File

@@ -133,17 +133,7 @@
</td>
<td class="font-mono text-xs">
<div class="tooltip tooltip-left"
x-data="{
get tooltipText() {
if (!row.quotaInfo || row.quotaInfo.length === 0) return 'No reset data';
const resets = row.quotaInfo
.filter(q => q.resetTime)
.map(q => `${q.email}: ${q.resetTime}`)
.join('&#10;');
return resets || 'No reset scheduled';
}
}"
:data-tip="tooltipText">
: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'">
<span x-text="row.resetIn"
:class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
</div>
@@ -158,7 +148,7 @@
</div>
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]">
<template x-for="q in row.quotaInfo" :key="q.fullEmail">
<div class="tooltip tooltip-left" :data-tip="q.fullEmail + ' (' + q.pct + '%)">
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
<div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div>

View File

@@ -166,10 +166,24 @@ function setupRoutes(app) {
});
}
/**
* Get usage history data
* @returns {object} History data sorted by timestamp
*/
function getHistory() {
const sortedKeys = Object.keys(history).sort();
const sortedData = {};
sortedKeys.forEach(key => {
sortedData[key] = history[key];
});
return sortedData;
}
export default {
setupMiddleware,
setupRoutes,
track,
getFamily,
getShortName
getShortName,
getHistory
};

View File

@@ -249,6 +249,7 @@ app.get('/account-limits', async (req, res) => {
await ensureInitialized();
const allAccounts = accountManager.getAllAccounts();
const format = req.query.format || 'json';
const includeHistory = req.query.includeHistory === 'true';
// Fetch quotas for each account in parallel
const results = await Promise.allSettled(
@@ -429,8 +430,8 @@ app.get('/account-limits', async (req, res) => {
accountStatus.accounts.map(a => [a.email, a])
);
// Default: JSON format
res.json({
// Build response data
const responseData = {
timestamp: new Date().toLocaleString(),
totalAccounts: allAccounts.length,
models: sortedModels,
@@ -468,7 +469,14 @@ app.get('/account-limits', async (req, res) => {
)
};
})
});
};
// Optionally include usage history (for dashboard performance optimization)
if (includeHistory) {
responseData.history = usageStats.getHistory();
}
res.json(responseData);
} catch (error) {
res.status(500).json({
status: 'error',