perf(webui): refactor dashboard modules and optimize API performance
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 || [];
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
493
public/js/components/dashboard/charts.js
Normal file
493
public/js/components/dashboard/charts.js
Normal 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;
|
||||
}
|
||||
};
|
||||
275
public/js/components/dashboard/filters.js
Normal file
275
public/js/components/dashboard/filters.js
Normal 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();
|
||||
};
|
||||
43
public/js/components/dashboard/stats.js
Normal file
43
public/js/components/dashboard/stats.js
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
82
public/js/config/constants.js
Normal file
82
public/js/config/constants.js
Normal 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
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
107
public/js/utils/error-handler.js
Normal file
107
public/js/utils/error-handler.js
Normal 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;
|
||||
};
|
||||
42
public/js/utils/model-config.js
Normal file
42
public/js/utils/model-config.js
Normal 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');
|
||||
};
|
||||
168
public/js/utils/validators.js
Normal file
168
public/js/utils/validators.js
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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(' ');
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user