perf(webui): refactor dashboard modules and optimize API performance
This commit is contained in:
@@ -346,13 +346,21 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Scripts - Loading Order Matters! -->
|
<!-- 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.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) -->
|
<!-- 2. Alpine Stores (register alpine:init listeners) -->
|
||||||
<script src="js/store.js"></script>
|
<script src="js/store.js"></script>
|
||||||
<script src="js/data-store.js"></script>
|
<script src="js/data-store.js"></script>
|
||||||
<script src="js/settings-store.js"></script>
|
<script src="js/settings-store.js"></script>
|
||||||
<!-- 3. Components (register to window.Components) -->
|
<!-- 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/dashboard.js"></script>
|
||||||
<script src="js/components/models.js"></script>
|
<script src="js/components/models.js"></script>
|
||||||
<script src="js/components/account-manager.js"></script>
|
<script src="js/components/account-manager.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
console.log('Registering app component...');
|
// App component registration
|
||||||
|
|
||||||
// Main App Controller
|
// Main App Controller
|
||||||
Alpine.data('app', () => ({
|
Alpine.data('app', () => ({
|
||||||
@@ -17,7 +17,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
console.log('App component initializing...');
|
// App component initialization
|
||||||
|
|
||||||
// Theme setup
|
// Theme setup
|
||||||
document.documentElement.setAttribute('data-theme', 'black');
|
document.documentElement.setAttribute('data-theme', 'black');
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ window.Components.claudeConfig = () => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
init() {
|
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.$watch('$store.data.models', (val) => {
|
||||||
this.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
|
* Registers itself to window.Components for Alpine.js to consume
|
||||||
*/
|
*/
|
||||||
window.Components = window.Components || {};
|
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 = () => ({
|
window.Components.dashboard = () => ({
|
||||||
|
// Core state
|
||||||
stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
|
stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
|
||||||
charts: { quotaDistribution: null, usageTrend: null },
|
charts: { quotaDistribution: null, usageTrend: null },
|
||||||
|
|
||||||
// Usage stats
|
|
||||||
usageStats: { total: 0, today: 0, thisHour: 0 },
|
usageStats: { total: 0, today: 0, thisHour: 0 },
|
||||||
historyData: {},
|
historyData: {},
|
||||||
|
|
||||||
// Hierarchical model tree: { claude: ['opus-4-5', 'sonnet-4-5'], gemini: ['3-flash'] }
|
|
||||||
modelTree: {},
|
modelTree: {},
|
||||||
families: [], // ['claude', 'gemini']
|
families: [],
|
||||||
|
|
||||||
// Display mode: 'family' or 'model'
|
// Filter state (from module)
|
||||||
displayMode: 'model',
|
...window.DashboardFilters.getInitialState(),
|
||||||
|
|
||||||
// Selection state
|
// Debounced chart update to prevent rapid successive updates
|
||||||
selectedFamilies: [],
|
_debouncedUpdateTrendChart: null,
|
||||||
selectedModels: {}, // { claude: ['opus-4-5'], gemini: ['3-flash'] }
|
|
||||||
|
|
||||||
showModelFilter: false,
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Load saved preferences from localStorage
|
// Create debounced version of updateTrendChart (300ms delay for stability)
|
||||||
this.loadPreferences();
|
this._debouncedUpdateTrendChart = window.utils.debounce(() => {
|
||||||
|
window.DashboardCharts.updateTrendChart(this);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
// Update stats when dashboard becomes active
|
// Load saved preferences from localStorage
|
||||||
this.$watch('$store.global.activeTab', (val) => {
|
window.DashboardFilters.loadPreferences(this);
|
||||||
if (val === 'dashboard') {
|
|
||||||
|
// Update stats when dashboard becomes active (skip initial trigger)
|
||||||
|
this.$watch('$store.global.activeTab', (val, oldVal) => {
|
||||||
|
if (val === 'dashboard' && oldVal !== undefined) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
this.updateCharts();
|
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
|
// Initial update if already on dashboard
|
||||||
if (this.$store.global.activeTab === 'dashboard') {
|
if (this.$store.global.activeTab === 'dashboard') {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
this.updateCharts();
|
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) {
|
processHistory(history) {
|
||||||
@@ -150,7 +103,6 @@ window.Components.dashboard = () => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Skip old flat format keys (claude, gemini as numbers)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
@@ -180,458 +132,80 @@ window.Components.dashboard = () => ({
|
|||||||
this.updateTrendChart();
|
this.updateTrendChart();
|
||||||
},
|
},
|
||||||
|
|
||||||
autoSelectNew() {
|
// Delegation methods for stats
|
||||||
// If no preferences saved, select all
|
updateStats() {
|
||||||
if (this.selectedFamilies.length === 0 && Object.keys(this.selectedModels).length === 0) {
|
window.DashboardStats.updateStats(this);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
autoSelectTopN(n = 5) {
|
// Delegation methods for charts
|
||||||
// Calculate usage for each model over past 24 hours
|
updateCharts() {
|
||||||
const usage = {};
|
window.DashboardCharts.updateCharts(this);
|
||||||
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}`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTrendChart() {
|
updateTrendChart() {
|
||||||
const ctx = document.getElementById('usageTrendChart');
|
// Use debounced version to prevent rapid successive updates
|
||||||
if (!ctx || typeof Chart === 'undefined') return;
|
if (this._debouncedUpdateTrendChart) {
|
||||||
|
this._debouncedUpdateTrendChart();
|
||||||
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
|
|
||||||
));
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Show individual models
|
// Fallback if debounced version not initialized
|
||||||
const dataByModel = {};
|
window.DashboardCharts.updateTrendChart(this);
|
||||||
|
|
||||||
// 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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
// Delegation methods for filters
|
||||||
const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200);
|
loadPreferences() {
|
||||||
// Reduced opacity from 0.3 to 0.12 for less visual noise
|
window.DashboardFilters.loadPreferences(this);
|
||||||
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
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
hexToRgba(hex, alpha) {
|
savePreferences() {
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
window.DashboardFilters.savePreferences(this);
|
||||||
if (result) {
|
|
||||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStats() {
|
setDisplayMode(mode) {
|
||||||
const accounts = Alpine.store('data').accounts;
|
window.DashboardFilters.setDisplayMode(this, mode);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateCharts() {
|
toggleFamily(family) {
|
||||||
const ctx = document.getElementById('quotaChart');
|
window.DashboardFilters.toggleFamily(this, family);
|
||||||
if (!ctx || typeof Chart === 'undefined') return;
|
},
|
||||||
|
|
||||||
if (this.charts.quotaDistribution) {
|
toggleModel(family, model) {
|
||||||
this.charts.quotaDistribution.destroy();
|
window.DashboardFilters.toggleModel(this, family, model);
|
||||||
}
|
},
|
||||||
|
|
||||||
// Use UNFILTERED data for global health chart
|
isFamilySelected(family) {
|
||||||
const rows = Alpine.store('data').getUnfilteredQuotaData();
|
return window.DashboardFilters.isFamilySelected(this, family);
|
||||||
|
},
|
||||||
|
|
||||||
// Dynamic family aggregation (supports any model family)
|
isModelSelected(family, model) {
|
||||||
const familyStats = {};
|
return window.DashboardFilters.isModelSelected(this, family, model);
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate global health
|
selectAll() {
|
||||||
const globalTotal = Object.values(familyStats).reduce((sum, f) => sum + f.total, 0);
|
window.DashboardFilters.selectAll(this);
|
||||||
const globalUsed = Object.values(familyStats).reduce((sum, f) => sum + f.used, 0);
|
},
|
||||||
this.stats.overallHealth = globalTotal > 0 ? Math.round((globalUsed / globalTotal) * 100) : 0;
|
|
||||||
|
|
||||||
// Generate chart data dynamically
|
deselectAll() {
|
||||||
const familyColors = {
|
window.DashboardFilters.deselectAll(this);
|
||||||
'claude': getThemeColor('--color-neon-purple'),
|
},
|
||||||
'gemini': getThemeColor('--color-neon-green'),
|
|
||||||
'other': getThemeColor('--color-neon-cyan'),
|
|
||||||
'unknown': '#666666'
|
|
||||||
};
|
|
||||||
|
|
||||||
const families = Object.keys(familyStats).sort();
|
getFamilyColor(family) {
|
||||||
const segmentSize = families.length > 0 ? 100 / families.length : 100;
|
return window.DashboardFilters.getFamilyColor(family);
|
||||||
|
},
|
||||||
|
|
||||||
const data = [];
|
getModelColor(family, modelIndex) {
|
||||||
const colors = [];
|
return window.DashboardFilters.getModelColor(family, modelIndex);
|
||||||
const labels = [];
|
},
|
||||||
|
|
||||||
families.forEach(family => {
|
getSelectedCount() {
|
||||||
const stats = familyStats[family];
|
return window.DashboardFilters.getSelectedCount(this);
|
||||||
const health = stats.total > 0 ? Math.round((stats.used / stats.total) * 100) : 0;
|
},
|
||||||
const activeVal = (health / 100) * segmentSize;
|
|
||||||
const inactiveVal = segmentSize - activeVal;
|
|
||||||
|
|
||||||
const familyColor = familyColors[family] || familyColors['unknown'];
|
autoSelectNew() {
|
||||||
|
window.DashboardFilters.autoSelectNew(this);
|
||||||
|
},
|
||||||
|
|
||||||
// Get translation keys
|
autoSelectTopN(n = 5) {
|
||||||
const store = Alpine.store('global');
|
window.DashboardFilters.autoSelectTopN(this, n);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
this.logs.push(log);
|
||||||
|
|
||||||
// Limit log buffer
|
// 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) {
|
if (this.logs.length > limit) {
|
||||||
this.logs = this.logs.slice(-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 {string} modelId - The model ID to update
|
||||||
* @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping)
|
* @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping)
|
||||||
*/
|
*/
|
||||||
async updateModelConfig(modelId, configUpdates) {
|
async updateModelConfig(modelId, configUpdates) {
|
||||||
const store = Alpine.store('global');
|
return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ window.Components = window.Components || {};
|
|||||||
|
|
||||||
window.Components.models = () => ({
|
window.Components.models = () => ({
|
||||||
init() {
|
init() {
|
||||||
// Ensure data is fetched when this tab becomes active
|
// Ensure data is fetched when this tab becomes active (skip initial trigger)
|
||||||
this.$watch('$store.global.activeTab', (val) => {
|
this.$watch('$store.global.activeTab', (val, oldVal) => {
|
||||||
if (val === 'models') {
|
if (val === 'models' && oldVal !== undefined) {
|
||||||
// Trigger recompute to ensure filters are applied
|
// Trigger recompute to ensure filters are applied
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
Alpine.store('data').computeQuotaRows();
|
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 {string} modelId - The model ID to update
|
||||||
* @param {object} configUpdates - Configuration updates (pinned, hidden)
|
* @param {object} configUpdates - Configuration updates (pinned, hidden)
|
||||||
*/
|
*/
|
||||||
async updateModelConfig(modelId, configUpdates) {
|
async updateModelConfig(modelId, configUpdates) {
|
||||||
const store = Alpine.store('global');
|
return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ window.Components.serverConfig = () => ({
|
|||||||
this.fetchServerConfig();
|
this.fetchServerConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch local activeTab (from parent settings scope)
|
// Watch local activeTab (from parent settings scope, skip initial trigger)
|
||||||
this.$watch('activeTab', (tab) => {
|
this.$watch('activeTab', (tab, oldTab) => {
|
||||||
if (tab === 'server') {
|
if (tab === 'server' && oldTab !== undefined) {
|
||||||
this.fetchServerConfig();
|
this.fetchServerConfig();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -164,10 +164,23 @@ window.Components.serverConfig = () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Generic debounced save method for numeric configs
|
// Generic debounced save method for numeric configs with validation
|
||||||
async saveConfigField(fieldName, value, displayName) {
|
async saveConfigField(fieldName, value, displayName, validator = null) {
|
||||||
const store = Alpine.store('global');
|
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
|
// Clear existing timer for this field
|
||||||
if (this.debounceTimers[fieldName]) {
|
if (this.debounceTimers[fieldName]) {
|
||||||
clearTimeout(this.debounceTimers[fieldName]);
|
clearTimeout(this.debounceTimers[fieldName]);
|
||||||
@@ -175,13 +188,13 @@ window.Components.serverConfig = () => ({
|
|||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const previousValue = this.serverConfig[fieldName];
|
const previousValue = this.serverConfig[fieldName];
|
||||||
this.serverConfig[fieldName] = parseInt(value);
|
this.serverConfig[fieldName] = value;
|
||||||
|
|
||||||
// Set new timer
|
// Set new timer
|
||||||
this.debounceTimers[fieldName] = setTimeout(async () => {
|
this.debounceTimers[fieldName] = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
payload[fieldName] = parseInt(value);
|
payload[fieldName] = value;
|
||||||
|
|
||||||
const { response, newPassword } = await window.utils.request('/api/config', {
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -203,27 +216,37 @@ window.Components.serverConfig = () => ({
|
|||||||
this.serverConfig[fieldName] = previousValue;
|
this.serverConfig[fieldName] = previousValue;
|
||||||
store.showToast(`Failed to update ${displayName}: ` + e.message, 'error');
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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
|
models: [], // Source of truth
|
||||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||||
quotaRows: [], // Filtered view
|
quotaRows: [], // Filtered view
|
||||||
|
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||||
loading: false,
|
loading: false,
|
||||||
connectionStatus: 'connecting',
|
connectionStatus: 'connecting',
|
||||||
lastUpdated: '-',
|
lastUpdated: '-',
|
||||||
@@ -39,7 +40,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
try {
|
try {
|
||||||
// Get password from global store
|
// Get password from global store
|
||||||
const password = Alpine.store('global').webuiPassword;
|
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;
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||||
|
|
||||||
@@ -52,6 +56,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
this.modelConfig = data.modelConfig || {};
|
this.modelConfig = data.modelConfig || {};
|
||||||
|
|
||||||
|
// Store usage history if included (for dashboard)
|
||||||
|
if (data.history) {
|
||||||
|
this.usageHistory = data.history;
|
||||||
|
}
|
||||||
|
|
||||||
this.computeQuotaRows();
|
this.computeQuotaRows();
|
||||||
|
|
||||||
this.connectionStatus = 'connected';
|
this.connectionStatus = 'connected';
|
||||||
|
|||||||
@@ -47,5 +47,23 @@ window.utils = {
|
|||||||
|
|
||||||
getThemeColor(name) {
|
getThemeColor(name) {
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
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>
|
||||||
<td class="font-mono text-xs">
|
<td class="font-mono text-xs">
|
||||||
<div class="tooltip tooltip-left"
|
<div class="tooltip tooltip-left"
|
||||||
x-data="{
|
: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'">
|
||||||
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">
|
|
||||||
<span x-text="row.resetIn"
|
<span x-text="row.resetIn"
|
||||||
:class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
|
:class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]">
|
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]">
|
||||||
<template x-for="q in row.quotaInfo" :key="q.fullEmail">
|
<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"
|
<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')">
|
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
export default {
|
||||||
setupMiddleware,
|
setupMiddleware,
|
||||||
setupRoutes,
|
setupRoutes,
|
||||||
track,
|
track,
|
||||||
getFamily,
|
getFamily,
|
||||||
getShortName
|
getShortName,
|
||||||
|
getHistory
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
const allAccounts = accountManager.getAllAccounts();
|
const allAccounts = accountManager.getAllAccounts();
|
||||||
const format = req.query.format || 'json';
|
const format = req.query.format || 'json';
|
||||||
|
const includeHistory = req.query.includeHistory === 'true';
|
||||||
|
|
||||||
// Fetch quotas for each account in parallel
|
// Fetch quotas for each account in parallel
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
@@ -429,8 +430,8 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
accountStatus.accounts.map(a => [a.email, a])
|
accountStatus.accounts.map(a => [a.email, a])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default: JSON format
|
// Build response data
|
||||||
res.json({
|
const responseData = {
|
||||||
timestamp: new Date().toLocaleString(),
|
timestamp: new Date().toLocaleString(),
|
||||||
totalAccounts: allAccounts.length,
|
totalAccounts: allAccounts.length,
|
||||||
models: sortedModels,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
|||||||
Reference in New Issue
Block a user