## 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>
171 lines
6.5 KiB
JavaScript
171 lines
6.5 KiB
JavaScript
/**
|
|
* Data Store
|
|
* Holds Accounts, Models, and Computed Quota Rows
|
|
* Shared between Dashboard and AccountManager
|
|
*/
|
|
|
|
// utils is loaded globally as window.utils in utils.js
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.store('data', {
|
|
accounts: [],
|
|
models: [], // Source of truth
|
|
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
|
quotaRows: [], // Filtered view
|
|
loading: false,
|
|
connectionStatus: 'connecting',
|
|
lastUpdated: '-',
|
|
|
|
// Filters state
|
|
filters: {
|
|
account: 'all',
|
|
family: 'all',
|
|
search: ''
|
|
},
|
|
|
|
// Settings for calculation
|
|
// We need to access global settings? Or duplicate?
|
|
// Let's assume settings are passed or in another store.
|
|
// For simplicity, let's keep relevant filters here.
|
|
|
|
init() {
|
|
// Watch filters to recompute
|
|
// Alpine stores don't have $watch automatically unless inside a component?
|
|
// We can manually call compute when filters change.
|
|
},
|
|
|
|
async fetchData() {
|
|
this.loading = true;
|
|
try {
|
|
// Get password from global store
|
|
const password = Alpine.store('global').webuiPassword;
|
|
const { response, newPassword } = await window.utils.request('/account-limits', {}, password);
|
|
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
const data = await response.json();
|
|
this.accounts = data.accounts || [];
|
|
if (data.models && data.models.length > 0) {
|
|
this.models = data.models;
|
|
}
|
|
this.modelConfig = data.modelConfig || {};
|
|
|
|
this.computeQuotaRows();
|
|
|
|
this.connectionStatus = 'connected';
|
|
this.lastUpdated = new Date().toLocaleTimeString();
|
|
} catch (error) {
|
|
console.error('Fetch error:', error);
|
|
this.connectionStatus = 'disconnected';
|
|
const store = Alpine.store('global');
|
|
store.showToast(store.t('connectionLost'), 'error');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
computeQuotaRows() {
|
|
const models = this.models || [];
|
|
const rows = [];
|
|
const showExhausted = Alpine.store('settings')?.showExhausted ?? true; // Need settings store
|
|
// Temporary debug flag or settings flag to show hidden models
|
|
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
|
|
|
models.forEach(modelId => {
|
|
// Config
|
|
const config = this.modelConfig[modelId] || {};
|
|
const family = this.getModelFamily(modelId);
|
|
|
|
// Smart Visibility Logic:
|
|
// 1. If explicit config exists, use it.
|
|
// 2. If no config, default 'unknown' families to HIDDEN to prevent clutter.
|
|
// 3. Known families (Claude/Gemini) default to VISIBLE.
|
|
let isHidden = config.hidden;
|
|
if (isHidden === undefined) {
|
|
isHidden = (family === 'other' || family === 'unknown');
|
|
}
|
|
|
|
// Skip hidden models unless "Show Hidden" is enabled
|
|
if (isHidden && !showHidden) return;
|
|
|
|
// Filters
|
|
if (this.filters.family !== 'all' && this.filters.family !== family) return;
|
|
if (this.filters.search) {
|
|
const searchLower = this.filters.search.toLowerCase();
|
|
const aliasMatch = config.alias && config.alias.toLowerCase().includes(searchLower);
|
|
const idMatch = modelId.toLowerCase().includes(searchLower);
|
|
if (!aliasMatch && !idMatch) return;
|
|
}
|
|
|
|
// Data Collection
|
|
const quotaInfo = [];
|
|
let minQuota = 100;
|
|
let totalQuotaSum = 0;
|
|
let validAccountCount = 0;
|
|
let minResetTime = null;
|
|
|
|
this.accounts.forEach(acc => {
|
|
if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
|
|
|
|
const limit = acc.limits?.[modelId];
|
|
if (!limit) return;
|
|
|
|
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
|
minQuota = Math.min(minQuota, pct);
|
|
|
|
// Accumulate for average
|
|
totalQuotaSum += pct;
|
|
validAccountCount++;
|
|
|
|
if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
|
|
minResetTime = limit.resetTime;
|
|
}
|
|
|
|
quotaInfo.push({
|
|
email: acc.email.split('@')[0],
|
|
fullEmail: acc.email,
|
|
pct: pct,
|
|
resetTime: limit.resetTime
|
|
});
|
|
});
|
|
|
|
if (quotaInfo.length === 0) return;
|
|
const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
|
|
|
|
if (!showExhausted && minQuota === 0) return;
|
|
|
|
rows.push({
|
|
modelId,
|
|
displayName: config.alias || modelId, // Use alias if available
|
|
family,
|
|
minQuota,
|
|
avgQuota, // Added Average Quota
|
|
minResetTime,
|
|
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
|
quotaInfo,
|
|
pinned: !!config.pinned,
|
|
hidden: !!isHidden // Use computed visibility
|
|
});
|
|
});
|
|
|
|
// Sort: Pinned first, then by avgQuota (descending)
|
|
this.quotaRows = rows.sort((a, b) => {
|
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
return b.avgQuota - a.avgQuota;
|
|
});
|
|
|
|
// Trigger Dashboard Update if active
|
|
// Ideally dashboard watches this store.
|
|
},
|
|
|
|
getModelFamily(modelId) {
|
|
const lower = modelId.toLowerCase();
|
|
if (lower.includes('claude')) return 'claude';
|
|
if (lower.includes('gemini')) return 'gemini';
|
|
return 'other';
|
|
}
|
|
});
|
|
});
|