## 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>
626 lines
22 KiB
JavaScript
626 lines
22 KiB
JavaScript
/**
|
|
* Dashboard Component
|
|
* Registers itself to window.Components for Alpine.js to consume
|
|
*/
|
|
window.Components = window.Components || {};
|
|
|
|
// Helper to get CSS variable values (alias to window.utils.getThemeColor)
|
|
const getThemeColor = (name) => window.utils.getThemeColor(name);
|
|
|
|
// Color palette for different families and models
|
|
const FAMILY_COLORS = {
|
|
get claude() { return getThemeColor('--color-neon-purple'); },
|
|
get gemini() { return getThemeColor('--color-neon-green'); },
|
|
get other() { return getThemeColor('--color-neon-cyan'); }
|
|
};
|
|
|
|
const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => getThemeColor(`--color-chart-${i + 1}`));
|
|
|
|
window.Components.dashboard = () => ({
|
|
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();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Watch for data changes
|
|
this.$watch('$store.data.accounts', () => {
|
|
if (this.$store.global.activeTab === 'dashboard') {
|
|
this.updateStats();
|
|
this.$nextTick(() => this.updateCharts());
|
|
}
|
|
});
|
|
|
|
// Initial update if already on dashboard
|
|
if (this.$store.global.activeTab === '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 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;
|
|
this.stats.active = active;
|
|
this.stats.limited = limited;
|
|
},
|
|
|
|
updateCharts() {
|
|
const ctx = document.getElementById('quotaChart');
|
|
if (!ctx || typeof Chart === 'undefined') return;
|
|
|
|
if (this.charts.quotaDistribution) {
|
|
this.charts.quotaDistribution.destroy();
|
|
}
|
|
|
|
const rows = Alpine.store('data').quotaRows;
|
|
|
|
// Dynamic family aggregation (supports any model family)
|
|
const familyStats = {};
|
|
rows.forEach(row => {
|
|
if (!familyStats[row.family]) {
|
|
familyStats[row.family] = { used: 0, total: 0 };
|
|
}
|
|
row.quotaInfo.forEach(info => {
|
|
familyStats[row.family].used += info.pct;
|
|
familyStats[row.family].total += 100;
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
|
|
// 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',
|
|
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
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|