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:
Wha1eChai
2026-01-08 19:04:43 +08:00
parent 85f7d3bae7
commit 217053839f
24 changed files with 1898 additions and 322 deletions

View File

@@ -39,6 +39,9 @@ export function getAvailableAccounts(accounts, modelId = null) {
return accounts.filter(acc => {
if (acc.isInvalid) return false;
// WebUI: Skip disabled accounts
if (acc.enabled === false) return false;
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
const limit = acc.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) {

View File

@@ -19,6 +19,9 @@ import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
function isAccountUsable(account, modelId) {
if (!account || account.isInvalid) return false;
// WebUI: Skip disabled accounts
if (account.enabled === false) return false;
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
const limit = account.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) {

View File

@@ -27,6 +27,7 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
const accounts = (config.accounts || []).map(acc => ({
...acc,
lastUsed: acc.lastUsed || null,
enabled: acc.enabled !== false, // Default to true if not specified
// Reset invalid flag on startup - give accounts a fresh chance to refresh
isInvalid: false,
invalidReason: null,
@@ -107,6 +108,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
accounts: accounts.map(acc => ({
email: acc.email,
source: acc.source,
enabled: acc.enabled !== false, // Persist enabled state
dbPath: acc.dbPath || null,
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,

View File

@@ -12,6 +12,8 @@ const DEFAULT_CONFIG = {
retryBaseMs: 1000,
retryMaxMs: 30000,
persistTokenCache: false,
defaultCooldownMs: 60000, // 1 minute
maxWaitBeforeErrorMs: 120000, // 2 minutes
modelMapping: {}
};

175
src/modules/usage-stats.js Normal file
View 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
};

View File

@@ -10,6 +10,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
import { mountWebUI } from './webui/index.js';
import { config } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -18,6 +19,7 @@ import { REQUEST_BODY_LIMIT } from './constants.js';
import { AccountManager } from './account-manager/index.js';
import { formatDuration } from './utils/helpers.js';
import { logger } from './utils/logger.js';
import usageStats from './modules/usage-stats.js';
// Parse fallback flag directly from command line args to avoid circular dependency
const args = process.argv.slice(2);
@@ -63,6 +65,9 @@ async function ensureInitialized() {
app.use(cors());
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
// Setup usage statistics middleware
usageStats.setupMiddleware(app);
// Mount WebUI (optional web interface for account management)
mountWebUI(app, __dirname, accountManager);
@@ -132,11 +137,11 @@ app.get('/health', async (req, res) => {
try {
await ensureInitialized();
const start = Date.now();
// Get high-level status first
const status = accountManager.getStatus();
const allAccounts = accountManager.getAllAccounts();
// Fetch quotas for each account in parallel to get detailed model info
const accountDetails = await Promise.allSettled(
allAccounts.map(async (account) => {
@@ -423,6 +428,7 @@ app.get('/account-limits', async (req, res) => {
timestamp: new Date().toLocaleString(),
totalAccounts: allAccounts.length,
models: sortedModels,
modelConfig: config.modelMapping || {},
accounts: accountLimits.map(acc => ({
email: acc.email,
status: acc.status,
@@ -535,23 +541,19 @@ app.post('/v1/messages', async (req, res) => {
await ensureInitialized();
const {
model,
messages,
max_tokens,
stream,
system,
tools,
tool_choice,
thinking,
top_p,
top_k,
temperature
} = req.body;
// Resolve model mapping if configured
let requestedModel = model || 'claude-3-5-sonnet-20241022';
const modelMapping = config.modelMapping || {};
if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
const targetModel = modelMapping[requestedModel].mapping;
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
requestedModel = targetModel;
}
const modelId = requestedModel;
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
// If we have some available accounts, we try them first.
const modelId = model || 'claude-3-5-sonnet-20241022';
if (accountManager.isAllRateLimited(modelId)) {
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
accountManager.resetAllRateLimits();
@@ -570,7 +572,7 @@ app.post('/v1/messages', async (req, res) => {
// Build the request object
const request = {
model: model || 'claude-3-5-sonnet-20241022',
model: modelId,
messages,
max_tokens: max_tokens || 4096,
stream,
@@ -676,6 +678,8 @@ app.post('/v1/messages', async (req, res) => {
/**
* Catch-all for unsupported endpoints
*/
usageStats.setupRoutes(app);
app.use('*', (req, res) => {
if (logger.isDebugEnabled) {
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);

View File

@@ -15,14 +15,87 @@
import path from 'path';
import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js';
import { DEFAULT_PORT } from '../constants.js';
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
import { readClaudeConfig, updateClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.js';
import { logger } from '../utils/logger.js';
import { getAuthorizationUrl, completeOAuthFlow } from '../auth/oauth.js';
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
// OAuth state storage (state -> { verifier, timestamp })
const pendingOAuthStates = new Map();
/**
* WebUI Helper Functions - Direct account manipulation
* These functions work around AccountManager's limited API by directly
* manipulating the accounts.json config file (non-invasive approach for PR)
*/
/**
* Set account enabled/disabled state
*/
async function setAccountEnabled(email, enabled) {
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
const account = accounts.find(a => a.email === email);
if (!account) {
throw new Error(`Account ${email} not found`);
}
account.enabled = enabled;
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Remove account from config
*/
async function removeAccount(email) {
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
const index = accounts.findIndex(a => a.email === email);
if (index === -1) {
throw new Error(`Account ${email} not found`);
}
accounts.splice(index, 1);
// Adjust activeIndex if needed
const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex;
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex);
logger.info(`[WebUI] Account ${email} removed`);
}
/**
* Add new account to config
*/
async function addAccount(accountData) {
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
// Check if account already exists
const existingIndex = accounts.findIndex(a => a.email === accountData.email);
if (existingIndex !== -1) {
// Update existing account
accounts[existingIndex] = {
...accounts[existingIndex],
...accountData,
enabled: true,
isInvalid: false,
invalidReason: null,
addedAt: accounts[existingIndex].addedAt || new Date().toISOString()
};
logger.info(`[WebUI] Account ${accountData.email} updated`);
} else {
// Add new account
accounts.push({
...accountData,
enabled: true,
isInvalid: false,
invalidReason: null,
modelRateLimits: {},
lastUsed: null,
addedAt: new Date().toISOString()
});
logger.info(`[WebUI] Account ${accountData.email} added`);
}
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
}
/**
* Auth Middleware - Optional password protection for WebUI
* Password can be set via WEBUI_PASSWORD env var or config.json
@@ -114,7 +187,11 @@ export function mountWebUI(app, dirname, accountManager) {
return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
}
accountManager.setAccountEnabled(email, enabled);
await setAccountEnabled(email, enabled);
// Reload AccountManager to pick up changes
await accountManager.initialize();
res.json({
status: 'ok',
message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
@@ -130,7 +207,11 @@ export function mountWebUI(app, dirname, accountManager) {
app.delete('/api/accounts/:email', async (req, res) => {
try {
const { email } = req.params;
accountManager.removeAccount(email);
await removeAccount(email);
// Reload AccountManager to pick up changes
await accountManager.initialize();
res.json({
status: 'ok',
message: `Account ${email} removed`
@@ -145,7 +226,9 @@ export function mountWebUI(app, dirname, accountManager) {
*/
app.post('/api/accounts/reload', async (req, res) => {
try {
await accountManager.reloadAccounts();
// Reload AccountManager from disk
await accountManager.initialize();
const status = accountManager.getStatus();
res.json({
status: 'ok',
@@ -183,7 +266,7 @@ export function mountWebUI(app, dirname, accountManager) {
*/
app.post('/api/config', (req, res) => {
try {
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache } = req.body;
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs } = req.body;
// Only allow updating specific fields (security)
const updates = {};
@@ -203,6 +286,12 @@ export function mountWebUI(app, dirname, accountManager) {
if (typeof persistTokenCache === 'boolean') {
updates.persistTokenCache = persistTokenCache;
}
if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) {
updates.defaultCooldownMs = defaultCooldownMs;
}
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
@@ -232,6 +321,48 @@ export function mountWebUI(app, dirname, accountManager) {
}
});
/**
* POST /api/config/password - Change WebUI password
*/
app.post('/api/config/password', (req, res) => {
try {
const { oldPassword, newPassword } = req.body;
// Validate input
if (!newPassword || typeof newPassword !== 'string') {
return res.status(400).json({
status: 'error',
error: 'New password is required'
});
}
// If current password exists, verify old password
if (config.webuiPassword && config.webuiPassword !== oldPassword) {
return res.status(403).json({
status: 'error',
error: 'Invalid current password'
});
}
// Save new password
const success = saveConfig({ webuiPassword: newPassword });
if (success) {
// Update in-memory config
config.webuiPassword = newPassword;
res.json({
status: 'ok',
message: 'Password changed successfully'
});
} else {
throw new Error('Failed to save password to config file');
}
} catch (error) {
logger.error('[WebUI] Error changing password:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* GET /api/settings - Get runtime settings
*/
@@ -427,24 +558,28 @@ export function mountWebUI(app, dirname, accountManager) {
const accountData = await completeOAuthFlow(code, storedState.verifier);
// Add or update the account
accountManager.addAccount({
await addAccount({
email: accountData.email,
refreshToken: accountData.refreshToken,
projectId: accountData.projectId,
source: 'oauth'
});
// Reload AccountManager to pick up the new account
await accountManager.initialize();
// Return a simple HTML page that closes itself or redirects
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<link rel="stylesheet" href="/css/style.css">
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #09090b;
color: #e4e4e7;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: var(--color-space-950);
color: var(--color-text-main);
display: flex;
justify-content: center;
align-items: center;
@@ -452,7 +587,7 @@ export function mountWebUI(app, dirname, accountManager) {
margin: 0;
flex-direction: column;
}
h1 { color: #22c55e; }
h1 { color: var(--color-neon-green); }
</style>
</head>
<body>
@@ -479,14 +614,16 @@ export function mountWebUI(app, dirname, accountManager) {
<html>
<head>
<title>Authentication Failed</title>
<link rel="stylesheet" href="/css/style.css">
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #09090b;
color: #ef4444;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: var(--color-space-950);
color: var(--color-text-main);
text-align: center;
padding: 50px;
}
h1 { color: var(--color-neon-red); }
</style>
</head>
<body>