feat: Add Web UI for account and quota management

## Summary
Add an optional Web UI for managing accounts and monitoring quotas.
WebUI is implemented as a modular plugin with minimal changes to server.js (only 5 lines added).

## New Features
- Dashboard: Real-time model quota visualization with Chart.js
- Accounts: OAuth-based account management (add/enable/disable/refresh/remove)
- Logs: Live server log streaming via SSE with search and level filtering
- Settings: System configuration with 4 tabs
  - Interface: Language (EN/zh_CN), polling interval, log buffer size, display options
  - Claude CLI: Proxy connection config, model selection, alias overrides (~/.claude.json)
  - Models: Model visibility and ordering management
  - Server Info: Runtime info and account config reload

## Technical Changes
- Add src/webui/index.js as modular plugin (all WebUI routes encapsulated)
- Add src/config.js for centralized configuration (~/.config/antigravity-proxy/config.json)
- Add authMiddleware for optional password protection (WEBUI_PASSWORD env var)
- Enhance logger with EventEmitter for SSE log streaming
- Make constants configurable via config.json
- Merge with main v1.2.6 (model fallback, cross-model thinking)
- server.js changes: only 5 lines added to import and mount WebUI module

## Bug Fixes
- Fix Alpine.js $watch error in settings-store.js (not supported in store init)
- Fix "OK" label to "SUCCESS" in logs filter
- Add saveSettings() calls to settings toggles for proper persistence
- Improve Claude CLI config robustness (handle empty/invalid JSON files)
- Add safety check for empty config.env in claude-config component
- Improve config.example.json instructions with clear Windows/macOS/Linux paths

## New Files
- src/webui/index.js - WebUI module with all API routes
- public/ - Complete Web UI frontend (Alpine.js + TailwindCSS + DaisyUI)
- src/config.js - Configuration management
- src/utils/claude-config.js - Claude CLI settings helper
- tests/frontend/ - Frontend test suite

## API Endpoints Added
- GET/POST /api/config - Server configuration
- GET/POST /api/claude/config - Claude CLI configuration
- POST /api/models/config - Model alias/hidden settings
- GET /api/accounts - Account list with status
- POST /api/accounts/:email/toggle - Enable/disable account
- POST /api/accounts/:email/refresh - Refresh account token
- DELETE /api/accounts/:email - Remove account
- GET /api/logs - Log history
- GET /api/logs/stream - Live log streaming (SSE)
- GET /api/auth/url - OAuth URL generation
- GET /oauth/callback - OAuth callback handler

## Backward Compatibility
- Default port remains 8080
- All existing CLI/API functionality unchanged
- WebUI is entirely optional
- Can be disabled by removing mountWebUI() call
This commit is contained in:
Wha1eChai
2026-01-04 08:32:36 +08:00
parent d03c79cc39
commit 85f7d3bae7
34 changed files with 4330 additions and 23 deletions

169
public/js/data-store.js Normal file
View File

@@ -0,0 +1,169 @@
/**
* 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';
Alpine.store('global').showToast('Connection Lost', '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';
}
});
});