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:
175
src/modules/usage-stats.js
Normal file
175
src/modules/usage-stats.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Persistence path
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const HISTORY_FILE = path.join(DATA_DIR, 'usage-history.json');
|
||||
|
||||
// In-memory storage
|
||||
// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } }
|
||||
let history = {};
|
||||
let isDirty = false;
|
||||
|
||||
/**
|
||||
* Extract model family from model ID
|
||||
* @param {string} modelId - The model identifier (e.g., "claude-opus-4-5-thinking")
|
||||
* @returns {string} The family name (claude, gemini, or other)
|
||||
*/
|
||||
function getFamily(modelId) {
|
||||
const lower = (modelId || '').toLowerCase();
|
||||
if (lower.includes('claude')) return 'claude';
|
||||
if (lower.includes('gemini')) return 'gemini';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short model name (without family prefix)
|
||||
* @param {string} modelId - The model identifier
|
||||
* @param {string} family - The model family
|
||||
* @returns {string} Short model name
|
||||
*/
|
||||
function getShortName(modelId, family) {
|
||||
if (family === 'other') return modelId;
|
||||
// Remove family prefix (e.g., "claude-opus-4-5" -> "opus-4-5")
|
||||
return modelId.replace(new RegExp(`^${family}-`, 'i'), '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure data directory exists and load history
|
||||
*/
|
||||
function load() {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(HISTORY_FILE)) {
|
||||
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
||||
history = JSON.parse(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UsageStats] Failed to load history:', err);
|
||||
history = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save history to disk
|
||||
*/
|
||||
function save() {
|
||||
if (!isDirty) return;
|
||||
try {
|
||||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
||||
isDirty = false;
|
||||
} catch (err) {
|
||||
console.error('[UsageStats] Failed to save history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old data (keep last 30 days)
|
||||
*/
|
||||
function prune() {
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let pruned = false;
|
||||
Object.keys(history).forEach(key => {
|
||||
if (new Date(key) < cutoff) {
|
||||
delete history[key];
|
||||
pruned = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (pruned) isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a request by model ID using hierarchical structure
|
||||
* @param {string} modelId - The specific model identifier
|
||||
*/
|
||||
function track(modelId) {
|
||||
const now = new Date();
|
||||
// Round down to nearest hour
|
||||
now.setMinutes(0, 0, 0);
|
||||
const key = now.toISOString();
|
||||
|
||||
if (!history[key]) {
|
||||
history[key] = { _total: 0 };
|
||||
}
|
||||
|
||||
const hourData = history[key];
|
||||
const family = getFamily(modelId);
|
||||
const shortName = getShortName(modelId, family);
|
||||
|
||||
// Initialize family object if needed
|
||||
if (!hourData[family]) {
|
||||
hourData[family] = { _subtotal: 0 };
|
||||
}
|
||||
|
||||
// Increment model-specific count
|
||||
hourData[family][shortName] = (hourData[family][shortName] || 0) + 1;
|
||||
|
||||
// Increment family subtotal
|
||||
hourData[family]._subtotal = (hourData[family]._subtotal || 0) + 1;
|
||||
|
||||
// Increment global total
|
||||
hourData._total = (hourData._total || 0) + 1;
|
||||
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Express Middleware
|
||||
* @param {import('express').Application} app
|
||||
*/
|
||||
function setupMiddleware(app) {
|
||||
load();
|
||||
|
||||
// Auto-save every minute
|
||||
setInterval(() => {
|
||||
save();
|
||||
prune();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Save on exit
|
||||
process.on('SIGINT', () => { save(); process.exit(); });
|
||||
process.on('SIGTERM', () => { save(); process.exit(); });
|
||||
|
||||
// Request interceptor
|
||||
// Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints
|
||||
const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions'];
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) {
|
||||
const model = req.body?.model;
|
||||
if (model) {
|
||||
track(model);
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup API Routes
|
||||
* @param {import('express').Application} app
|
||||
*/
|
||||
function setupRoutes(app) {
|
||||
app.get('/api/stats/history', (req, res) => {
|
||||
// Sort keys to ensure chronological order
|
||||
const sortedKeys = Object.keys(history).sort();
|
||||
const sortedData = {};
|
||||
sortedKeys.forEach(key => {
|
||||
sortedData[key] = history[key];
|
||||
});
|
||||
res.json(sortedData);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
setupMiddleware,
|
||||
setupRoutes,
|
||||
track,
|
||||
getFamily,
|
||||
getShortName
|
||||
};
|
||||
Reference in New Issue
Block a user