feat(webui): Enhance dashboard, global styles, and settings module
## Dashboard Enhancements
- Add Request Volume trend chart with Chart.js line graph
- Support Family/Model display modes for aggregation levels
- Show Total/Today/1H usage statistics
- Hierarchical filter dropdown with Smart select (Top 5 by 24h usage)
- Persist chart preferences to localStorage
- Improve account health detection logic
- Core models (sonnet/opus/pro/flash) require >5% quota to be healthy
- Dynamic quota ring chart supporting any model family
- Unify table styles with standard-table class
## Global Style Refactoring
- Add CSS variable system for theming
- Space color scale (950/900/850/800/border)
- Neon accent colors (purple/green/cyan/yellow/red)
- Text hierarchy (main/dim/muted/bright)
- Chart palette (16 colors)
- Add unified component classes
- .view-container for consistent page layouts
- .section-header/.section-title/.section-desc
- .standard-table for table styling
- Update scrollbar, nav-item, progress-bar to use theme variables
## Settings Module Extensions
- Add model mapping column in Models tab
- Enhance model selectors with family color indicators
- Support horizontal scroll for tabs on narrow screens
- Add defaultCooldownMs and maxWaitBeforeErrorMs config options
## New Module
- Add src/modules/usage-stats.js for request tracking
- Track /v1/messages and /v1/chat/completions endpoints
- Hierarchical storage: { hour: { family: { model: count } } }
- Auto-save every minute, 30-day retention
- GET /api/stats/history endpoint for dashboard chart
## Backend Changes
- Add direct account manipulation helpers (bypass AccountManager)
- Add POST /api/config/password endpoint for WebUI password change
- Auto-reload AccountManager after account operations
- Use CSS variables in OAuth callback pages
## Other
- Update .gitignore for runtime data directory
- Add i18n keys for new UI elements (EN/zh_CN)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,99 +6,105 @@ window.Components = window.Components || {};
|
||||
|
||||
window.Components.accountManager = () => ({
|
||||
async refreshAccount(email) {
|
||||
Alpine.store('global').showToast(`Refreshing ${email}...`, 'info');
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const store = Alpine.store('global');
|
||||
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Refreshed ${email}`, 'success');
|
||||
store.showToast(store.t('refreshedAccount', { email }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Refresh failed', 'error');
|
||||
store.showToast(data.error || store.t('refreshFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Refresh failed: ' + e.message, 'error');
|
||||
store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleAccount(email, enabled) {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Account ${email} ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
|
||||
store.showToast(store.t('accountToggled', { email, status }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Toggle failed', 'error');
|
||||
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Toggle failed: ' + e.message, 'error');
|
||||
store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async fixAccount(email) {
|
||||
Alpine.store('global').showToast(`Re-authenticating ${email}...`, 'info');
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const store = Alpine.store('global');
|
||||
store.showToast(store.t('reauthenticating', { email }), 'info');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
|
||||
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
|
||||
store.showToast(data.error || store.t('authUrlFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed: ' + e.message, 'error');
|
||||
store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAccount(email) {
|
||||
if (!confirm(Alpine.store('global').t('confirmDelete'))) return;
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const store = Alpine.store('global');
|
||||
if (!confirm(store.t('confirmDelete'))) return;
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Deleted ${email}`, 'success');
|
||||
store.showToast(store.t('deletedAccount', { email }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Delete failed', 'error');
|
||||
store.showToast(data.error || store.t('deleteFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Delete failed: ' + e.message, 'error');
|
||||
store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async reloadAccounts() {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast('Accounts reloaded', 'success');
|
||||
store.showToast(store.t('accountsReloaded'), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Reload failed', 'error');
|
||||
store.showToast(data.error || store.t('reloadFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Reload failed: ' + e.message, 'error');
|
||||
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ window.Components.claudeConfig = () => ({
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
Alpine.store('global').showToast('Claude config saved!', 'success');
|
||||
Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success');
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to save config: ' + e.message, 'error');
|
||||
Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,50 @@
|
||||
*/
|
||||
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 = () => ({
|
||||
stats: { total: 0, active: 0, limited: 0, overallHealth: 0 },
|
||||
charts: { quotaDistribution: null },
|
||||
stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
|
||||
charts: { quotaDistribution: null, usageTrend: null },
|
||||
|
||||
// Usage stats
|
||||
usageStats: { total: 0, today: 0, thisHour: 0 },
|
||||
historyData: {},
|
||||
|
||||
// Hierarchical model tree: { claude: ['opus-4-5', 'sonnet-4-5'], gemini: ['3-flash'] }
|
||||
modelTree: {},
|
||||
families: [], // ['claude', 'gemini']
|
||||
|
||||
// Display mode: 'family' or 'model'
|
||||
displayMode: 'model',
|
||||
|
||||
// Selection state
|
||||
selectedFamilies: [],
|
||||
selectedModels: {}, // { claude: ['opus-4-5'], gemini: ['3-flash'] }
|
||||
|
||||
showModelFilter: false,
|
||||
|
||||
init() {
|
||||
// Load saved preferences from localStorage
|
||||
this.loadPreferences();
|
||||
|
||||
// Update stats when dashboard becomes active
|
||||
this.$watch('$store.global.activeTab', (val) => {
|
||||
if (val === 'dashboard') {
|
||||
this.$nextTick(() => {
|
||||
this.updateStats();
|
||||
this.updateCharts();
|
||||
this.fetchHistory();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -32,22 +65,456 @@ window.Components.dashboard = () => ({
|
||||
this.$nextTick(() => {
|
||||
this.updateStats();
|
||||
this.updateCharts();
|
||||
this.fetchHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Build model tree from hierarchical data
|
||||
const tree = {};
|
||||
let total = 0, today = 0, thisHour = 0;
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
Object.entries(history).forEach(([iso, hourData]) => {
|
||||
const timestamp = new Date(iso);
|
||||
|
||||
// Process each family in the hour data
|
||||
Object.entries(hourData).forEach(([key, value]) => {
|
||||
// Skip metadata keys
|
||||
if (key === '_total' || key === 'total') return;
|
||||
|
||||
// Handle hierarchical format: { claude: { "opus-4-5": 10, "_subtotal": 10 } }
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (!tree[key]) tree[key] = new Set();
|
||||
|
||||
Object.keys(value).forEach(modelName => {
|
||||
if (modelName !== '_subtotal') {
|
||||
tree[key].add(modelName);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Skip old flat format keys (claude, gemini as numbers)
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const hourTotal = hourData._total || hourData.total || 0;
|
||||
total += hourTotal;
|
||||
|
||||
if (timestamp >= todayStart) {
|
||||
today += hourTotal;
|
||||
}
|
||||
if (timestamp.getTime() === currentHour.getTime()) {
|
||||
thisHour = hourTotal;
|
||||
}
|
||||
});
|
||||
|
||||
this.usageStats = { total, today, thisHour };
|
||||
|
||||
// Convert Sets to sorted arrays
|
||||
this.modelTree = {};
|
||||
Object.entries(tree).forEach(([family, models]) => {
|
||||
this.modelTree[family] = Array.from(models).sort();
|
||||
});
|
||||
this.families = Object.keys(this.modelTree).sort();
|
||||
|
||||
// Auto-select new families/models that haven't been configured
|
||||
this.autoSelectNew();
|
||||
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
autoSelectNew() {
|
||||
// If no preferences saved, select all
|
||||
if (this.selectedFamilies.length === 0 && Object.keys(this.selectedModels).length === 0) {
|
||||
this.selectedFamilies = [...this.families];
|
||||
this.families.forEach(family => {
|
||||
this.selectedModels[family] = [...(this.modelTree[family] || [])];
|
||||
});
|
||||
this.savePreferences();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new families/models that appeared
|
||||
this.families.forEach(family => {
|
||||
if (!this.selectedFamilies.includes(family)) {
|
||||
this.selectedFamilies.push(family);
|
||||
}
|
||||
if (!this.selectedModels[family]) {
|
||||
this.selectedModels[family] = [];
|
||||
}
|
||||
(this.modelTree[family] || []).forEach(model => {
|
||||
if (!this.selectedModels[family].includes(model)) {
|
||||
this.selectedModels[family].push(model);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
autoSelectTopN(n = 5) {
|
||||
// Calculate usage for each model over past 24 hours
|
||||
const usage = {};
|
||||
const now = Date.now();
|
||||
const dayAgo = now - 24 * 60 * 60 * 1000;
|
||||
|
||||
Object.entries(this.historyData).forEach(([iso, hourData]) => {
|
||||
const timestamp = new Date(iso).getTime();
|
||||
if (timestamp < dayAgo) return;
|
||||
|
||||
Object.entries(hourData).forEach(([family, familyData]) => {
|
||||
if (typeof familyData === 'object' && family !== '_total') {
|
||||
Object.entries(familyData).forEach(([model, count]) => {
|
||||
if (model !== '_subtotal') {
|
||||
const key = `${family}:${model}`;
|
||||
usage[key] = (usage[key] || 0) + count;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by usage and take top N
|
||||
const sorted = Object.entries(usage)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n);
|
||||
|
||||
// Clear current selection
|
||||
this.selectedFamilies = [];
|
||||
this.selectedModels = {};
|
||||
|
||||
// Select top N models
|
||||
sorted.forEach(([key]) => {
|
||||
const [family, model] = key.split(':');
|
||||
if (!this.selectedFamilies.includes(family)) {
|
||||
this.selectedFamilies.push(family);
|
||||
}
|
||||
if (!this.selectedModels[family]) {
|
||||
this.selectedModels[family] = [];
|
||||
}
|
||||
this.selectedModels[family].push(model);
|
||||
});
|
||||
|
||||
this.savePreferences();
|
||||
this.refreshChart();
|
||||
},
|
||||
|
||||
// Toggle display mode between family and model level
|
||||
setDisplayMode(mode) {
|
||||
this.displayMode = mode;
|
||||
this.savePreferences();
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
// Toggle family selection
|
||||
toggleFamily(family) {
|
||||
const index = this.selectedFamilies.indexOf(family);
|
||||
if (index > -1) {
|
||||
this.selectedFamilies.splice(index, 1);
|
||||
} else {
|
||||
this.selectedFamilies.push(family);
|
||||
}
|
||||
this.savePreferences();
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
// Toggle model selection within a family
|
||||
toggleModel(family, model) {
|
||||
if (!this.selectedModels[family]) {
|
||||
this.selectedModels[family] = [];
|
||||
}
|
||||
const index = this.selectedModels[family].indexOf(model);
|
||||
if (index > -1) {
|
||||
this.selectedModels[family].splice(index, 1);
|
||||
} else {
|
||||
this.selectedModels[family].push(model);
|
||||
}
|
||||
this.savePreferences();
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
// Check if family is selected
|
||||
isFamilySelected(family) {
|
||||
return this.selectedFamilies.includes(family);
|
||||
},
|
||||
|
||||
// Check if model is selected
|
||||
isModelSelected(family, model) {
|
||||
return this.selectedModels[family]?.includes(model) || false;
|
||||
},
|
||||
|
||||
// Select all families and models
|
||||
selectAll() {
|
||||
this.selectedFamilies = [...this.families];
|
||||
this.families.forEach(family => {
|
||||
this.selectedModels[family] = [...(this.modelTree[family] || [])];
|
||||
});
|
||||
this.savePreferences();
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
// Deselect all
|
||||
deselectAll() {
|
||||
this.selectedFamilies = [];
|
||||
this.selectedModels = {};
|
||||
this.savePreferences();
|
||||
this.updateTrendChart();
|
||||
},
|
||||
|
||||
// Get color for family
|
||||
getFamilyColor(family) {
|
||||
return FAMILY_COLORS[family] || FAMILY_COLORS.other;
|
||||
},
|
||||
|
||||
// Get color for model (with index for variation within family)
|
||||
getModelColor(family, modelIndex) {
|
||||
const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8);
|
||||
return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length];
|
||||
},
|
||||
|
||||
// Get count of selected items for display
|
||||
getSelectedCount() {
|
||||
if (this.displayMode === 'family') {
|
||||
return `${this.selectedFamilies.length}/${this.families.length}`;
|
||||
}
|
||||
let selected = 0, total = 0;
|
||||
this.families.forEach(family => {
|
||||
const models = this.modelTree[family] || [];
|
||||
total += models.length;
|
||||
selected += (this.selectedModels[family] || []).length;
|
||||
});
|
||||
return `${selected}/${total}`;
|
||||
},
|
||||
|
||||
updateTrendChart() {
|
||||
const ctx = document.getElementById('usageTrendChart');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
|
||||
if (this.charts.usageTrend) {
|
||||
this.charts.usageTrend.destroy();
|
||||
}
|
||||
|
||||
const history = this.historyData;
|
||||
const labels = [];
|
||||
const datasets = [];
|
||||
|
||||
if (this.displayMode === 'family') {
|
||||
// Aggregate by family
|
||||
const dataByFamily = {};
|
||||
this.selectedFamilies.forEach(family => {
|
||||
dataByFamily[family] = [];
|
||||
});
|
||||
|
||||
Object.entries(history).forEach(([iso, hourData]) => {
|
||||
const date = new Date(iso);
|
||||
labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
|
||||
this.selectedFamilies.forEach(family => {
|
||||
const familyData = hourData[family];
|
||||
const count = familyData?._subtotal || 0;
|
||||
dataByFamily[family].push(count);
|
||||
});
|
||||
});
|
||||
|
||||
// Build datasets for families
|
||||
this.selectedFamilies.forEach(family => {
|
||||
const color = this.getFamilyColor(family);
|
||||
const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1);
|
||||
const label = Alpine.store('global').t(familyKey);
|
||||
datasets.push(this.createDataset(
|
||||
label,
|
||||
dataByFamily[family],
|
||||
color,
|
||||
ctx
|
||||
));
|
||||
});
|
||||
} else {
|
||||
// Show individual models
|
||||
const dataByModel = {};
|
||||
|
||||
// Initialize data arrays
|
||||
this.families.forEach(family => {
|
||||
(this.selectedModels[family] || []).forEach(model => {
|
||||
const key = `${family}:${model}`;
|
||||
dataByModel[key] = [];
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(history).forEach(([iso, hourData]) => {
|
||||
const date = new Date(iso);
|
||||
labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
|
||||
this.families.forEach(family => {
|
||||
const familyData = hourData[family] || {};
|
||||
(this.selectedModels[family] || []).forEach(model => {
|
||||
const key = `${family}:${model}`;
|
||||
dataByModel[key].push(familyData[model] || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Build datasets for models
|
||||
this.families.forEach(family => {
|
||||
(this.selectedModels[family] || []).forEach((model, modelIndex) => {
|
||||
const key = `${family}:${model}`;
|
||||
const color = this.getModelColor(family, modelIndex);
|
||||
datasets.push(this.createDataset(model, dataByModel[key], color, ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.charts.usageTrend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: getThemeColor('--color-space-950') || 'rgba(24, 24, 27, 0.9)',
|
||||
titleColor: getThemeColor('--color-text-main'),
|
||||
bodyColor: getThemeColor('--color-text-bright'),
|
||||
borderColor: getThemeColor('--color-space-border'),
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.dataset.label + ': ' + context.parsed.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
grid: { display: true, color: getThemeColor('--color-space-border') + '1a' || 'rgba(255,255,255,0.05)' },
|
||||
ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
createDataset(label, data, color, ctx) {
|
||||
const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200);
|
||||
gradient.addColorStop(0, this.hexToRgba(color, 0.3));
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
return {
|
||||
label,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: color
|
||||
};
|
||||
},
|
||||
|
||||
hexToRgba(hex, alpha) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result) {
|
||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
||||
}
|
||||
return hex;
|
||||
},
|
||||
|
||||
updateStats() {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
let active = 0, limited = 0;
|
||||
|
||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
||||
|
||||
accounts.forEach(acc => {
|
||||
if (acc.status === 'ok') {
|
||||
const hasQuota = Object.values(acc.limits || {}).some(l => l && l.remainingFraction > 0);
|
||||
if (hasQuota) active++; else limited++;
|
||||
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++;
|
||||
}
|
||||
});
|
||||
this.stats = { total: accounts.length, active, limited };
|
||||
this.stats.total = accounts.length;
|
||||
this.stats.active = active;
|
||||
this.stats.limited = limited;
|
||||
},
|
||||
|
||||
updateCharts() {
|
||||
@@ -59,32 +526,70 @@ window.Components.dashboard = () => ({
|
||||
}
|
||||
|
||||
const rows = Alpine.store('data').quotaRows;
|
||||
const familyStats = { claude: { sum: 0, count: 0 }, gemini: { sum: 0, count: 0 }, other: { sum: 0, count: 0 } };
|
||||
|
||||
// Calculate overall system health
|
||||
let totalHealthSum = 0;
|
||||
let totalModelCount = 0;
|
||||
|
||||
// Dynamic family aggregation (supports any model family)
|
||||
const familyStats = {};
|
||||
rows.forEach(row => {
|
||||
const f = familyStats[row.family] ? row.family : 'other';
|
||||
// Use avgQuota if available (new logic), fallback to minQuota (old logic compatibility)
|
||||
const quota = row.avgQuota !== undefined ? row.avgQuota : row.minQuota;
|
||||
|
||||
familyStats[f].sum += quota;
|
||||
familyStats[f].count++;
|
||||
|
||||
totalHealthSum += quota;
|
||||
totalModelCount++;
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
this.stats.overallHealth = totalModelCount > 0 ? Math.round(totalHealthSum / totalModelCount) : 0;
|
||||
// Calculate global health
|
||||
const globalTotal = Object.values(familyStats).reduce((sum, f) => sum + f.total, 0);
|
||||
const globalUsed = Object.values(familyStats).reduce((sum, f) => sum + f.used, 0);
|
||||
this.stats.overallHealth = globalTotal > 0 ? Math.round((globalUsed / globalTotal) * 100) : 0;
|
||||
|
||||
const labels = ['Claude', 'Gemini', 'Other'];
|
||||
const data = [
|
||||
familyStats.claude.count ? Math.round(familyStats.claude.sum / familyStats.claude.count) : 0,
|
||||
familyStats.gemini.count ? Math.round(familyStats.gemini.sum / familyStats.gemini.count) : 0,
|
||||
familyStats.other.count ? Math.round(familyStats.other.sum / familyStats.other.count) : 0,
|
||||
];
|
||||
// Generate chart data dynamically
|
||||
const familyColors = {
|
||||
'claude': getThemeColor('--color-neon-purple'),
|
||||
'gemini': getThemeColor('--color-neon-green'),
|
||||
'other': getThemeColor('--color-neon-cyan'),
|
||||
'unknown': '#666666'
|
||||
};
|
||||
|
||||
const families = Object.keys(familyStats).sort();
|
||||
const segmentSize = families.length > 0 ? 100 / families.length : 100;
|
||||
|
||||
const data = [];
|
||||
const colors = [];
|
||||
const labels = [];
|
||||
|
||||
families.forEach(family => {
|
||||
const stats = familyStats[family];
|
||||
const health = stats.total > 0 ? Math.round((stats.used / stats.total) * 100) : 0;
|
||||
const activeVal = (health / 100) * segmentSize;
|
||||
const inactiveVal = segmentSize - activeVal;
|
||||
|
||||
const familyColor = familyColors[family] || familyColors['unknown'];
|
||||
|
||||
// Get translation keys if available, otherwise capitalize
|
||||
const familyName = family.charAt(0).toUpperCase() + family.slice(1);
|
||||
const store = Alpine.store('global');
|
||||
|
||||
// Labels using translations if possible
|
||||
const activeLabel = family === 'claude' ? store.t('claudeActive') :
|
||||
family === 'gemini' ? store.t('geminiActive') :
|
||||
`${familyName} Active`;
|
||||
|
||||
const depletedLabel = family === 'claude' ? store.t('claudeEmpty') :
|
||||
family === 'gemini' ? store.t('geminiEmpty') :
|
||||
`${familyName} 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',
|
||||
@@ -92,24 +597,22 @@ window.Components.dashboard = () => ({
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: ['#a855f7', '#22c55e', '#52525b'],
|
||||
borderColor: '#09090b', // Matches bg-space-900 roughly
|
||||
backgroundColor: colors,
|
||||
borderColor: getThemeColor('--color-space-950'),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
borderRadius: 2
|
||||
borderRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '85%', // Thinner ring
|
||||
cutout: '85%',
|
||||
rotation: -90,
|
||||
circumference: 360,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // Hide default legend
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false // Disable tooltip for cleaner look, or style it? Let's keep it simple.
|
||||
},
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
title: { display: false }
|
||||
},
|
||||
animation: {
|
||||
|
||||
Reference in New Issue
Block a user