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:
169
public/js/data-store.js
Normal file
169
public/js/data-store.js
Normal 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';
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user