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

52
config.example.json Normal file
View File

@@ -0,0 +1,52 @@
{
"_comment": "Antigravity Claude Proxy Configuration",
"_instructions": [
"HOW TO USE THIS FILE:",
"1. Copy to your HOME directory: ~/.config/antigravity-proxy/config.json",
" - Windows: C:\\Users\\<username>\\.config\\antigravity-proxy\\config.json",
" - macOS/Linux: ~/.config/antigravity-proxy/config.json",
"2. Or copy to project root as 'config.json' (fallback if home config not found)",
"",
"NOTE: Environment variables (e.g., WEBUI_PASSWORD) take precedence over file config",
"Restart server after making changes"
],
"webuiPassword": "",
"_webuiPassword_comment": "Optional password to protect WebUI. Can also use WEBUI_PASSWORD env var.",
"port": 8080,
"debug": false,
"logLevel": "info",
"maxRetries": 5,
"retryBaseMs": 1000,
"retryMaxMs": 30000,
"defaultCooldownMs": 60000,
"maxWaitBeforeErrorMs": 120000,
"tokenCacheTtlMs": 300000,
"persistTokenCache": false,
"requestTimeoutMs": 300000,
"maxAccounts": 10,
"_profiles": {
"development": {
"debug": true,
"logLevel": "debug",
"maxRetries": 3
},
"production": {
"debug": false,
"logLevel": "info",
"maxRetries": 5,
"persistTokenCache": true
},
"high-performance": {
"maxRetries": 10,
"retryMaxMs": 60000,
"tokenCacheTtlMs": 600000
}
}
}

BIN
images/webui-accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
images/webui-dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

BIN
images/webui-logs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
images/webui-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "antigravity-claude-proxy", "name": "antigravity-claude-proxy",
"version": "1.0.2", "version": "1.2.6",
"description": "Proxy server to use Antigravity's Claude models with Claude Code CLI", "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
"main": "src/index.js", "main": "src/index.js",
"type": "module", "type": "module",
@@ -9,7 +9,8 @@
}, },
"files": [ "files": [
"src", "src",
"bin" "bin",
"public"
], ],
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
@@ -49,8 +50,9 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"async-mutex": "^0.5.0",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2" "express": "^4.18.2"
} }
} }

130
public/app.js Normal file
View File

@@ -0,0 +1,130 @@
/**
* Antigravity Console - Main Entry
*
* This file orchestrates Alpine.js initialization.
* Components are loaded via separate script files that register themselves
* to window.Components before this script runs.
*/
document.addEventListener('alpine:init', () => {
// Register Components (loaded from separate files via window.Components)
Alpine.data('dashboard', window.Components.dashboard);
Alpine.data('accountManager', window.Components.accountManager);
Alpine.data('claudeConfig', window.Components.claudeConfig);
Alpine.data('logsViewer', window.Components.logsViewer);
// View Loader Directive
Alpine.directive('load-view', (el, { expression }, { evaluate }) => {
if (!window.viewCache) window.viewCache = new Map();
// Evaluate the expression to get the actual view name (removes quotes)
const viewName = evaluate(expression);
if (window.viewCache.has(viewName)) {
el.innerHTML = window.viewCache.get(viewName);
Alpine.initTree(el);
return;
}
fetch(`views/${viewName}.html`)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then(html => {
window.viewCache.set(viewName, html);
el.innerHTML = html;
Alpine.initTree(el);
})
.catch(err => {
console.error('Failed to load view:', viewName, err);
el.innerHTML = `<div class="p-4 border border-red-500/50 bg-red-500/10 rounded-lg text-red-400 font-mono text-sm">
Error loading view: ${viewName}<br>
<span class="text-xs opacity-75">${err.message}</span>
</div>`;
});
});
// Main App Controller
Alpine.data('app', () => ({
get connectionStatus() {
return Alpine.store('data')?.connectionStatus || 'connecting';
},
get loading() {
return Alpine.store('data')?.loading || false;
},
init() {
console.log('App controller initialized');
// Theme setup
document.documentElement.setAttribute('data-theme', 'black');
document.documentElement.classList.add('dark');
// Chart Defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#71717a';
Chart.defaults.borderColor = '#27272a';
Chart.defaults.font.family = '"JetBrains Mono", monospace';
}
// Start Data Polling
this.startAutoRefresh();
document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
// Initial Fetch
Alpine.store('data').fetchData();
},
refreshTimer: null,
fetchData() {
Alpine.store('data').fetchData();
},
startAutoRefresh() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
const interval = parseInt(Alpine.store('settings')?.refreshInterval || 60);
if (interval > 0) {
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
}
},
t(key) {
return Alpine.store('global')?.t(key) || key;
},
async addAccountWeb(reAuthEmail = null) {
const password = Alpine.store('global').webuiPassword;
try {
const urlPath = reAuthEmail
? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
: '/api/auth/url';
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
const messageHandler = (event) => {
if (event.data?.type === 'oauth-success') {
const action = reAuthEmail ? 're-authenticated' : 'added';
Alpine.store('global').showToast(`Account ${event.data.email} ${action} successfully`, 'success');
Alpine.store('data').fetchData();
document.getElementById('add_account_modal')?.close();
}
};
window.addEventListener('message', messageHandler);
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
} else {
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Failed to start OAuth flow: ' + e.message, 'error');
}
}
}));
});

92
public/css/style.css Normal file
View File

@@ -0,0 +1,92 @@
[x-cloak] {
display: none !important;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
/* Animations */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
/* Utility */
.glass-panel {
background: rgba(23, 23, 23, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.nav-item.active {
background: linear-gradient(90deg, rgba(168, 85, 247, 0.15) 0%, transparent 100%);
border-left: 3px solid #a855f7;
color: #fff;
}
.nav-item {
border-left: 3px solid transparent;
transition: all 0.2s;
}
.progress-gradient-success::-webkit-progress-value {
background-image: linear-gradient(to right, #22c55e, #4ade80);
}
.progress-gradient-warning::-webkit-progress-value {
background-image: linear-gradient(to right, #eab308, #facc15);
}
.progress-gradient-error::-webkit-progress-value {
background-image: linear-gradient(to right, #dc2626, #f87171);
}
/* Dashboard Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
/* Tooltip Customization */
.tooltip:before {
background-color: #171717 !important; /* space-800 */
border: 1px solid #27272a; /* space-border */
color: #e5e7eb !important;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
}
.tooltip-left:before {
margin-right: 0.5rem;
}

291
public/index.html Normal file
View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="en" data-theme="black" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Antigravity Console</title>
<!-- Libraries -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<!-- Alpine.js must be deferred so stores register their listeners first -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Custom Config -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
sans: ['Inter', 'system-ui', 'sans-serif']
},
colors: {
// Deep Space Palette
space: {
950: '#050505', // Almost black
900: '#0a0a0a',
800: '#171717',
border: '#27272a'
},
neon: {
purple: '#a855f7',
cyan: '#06b6d4',
green: '#22c55e'
}
}
}
}
}
</script>
<link rel="stylesheet" href="css/style.css">
</head>
<body
class="bg-space-950 text-gray-300 font-sans antialiased min-h-screen overflow-hidden selection:bg-neon-purple selection:text-white"
x-cloak x-data="app" x-init="console.log('App initialized')">
<!-- Toast Notification -->
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<template x-if="$store.global.toast">
<div x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8 scale-95"
x-transition:enter-end="opacity-100 translate-x-0 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0 scale-100"
x-transition:leave-end="opacity-0 translate-x-4 scale-95"
class="alert shadow-lg border backdrop-blur-md pointer-events-auto min-w-[300px]" :class="{
'alert-info border-neon-cyan/20 bg-space-900/90 text-neon-cyan': $store.global.toast.type === 'info',
'alert-success border-neon-green/20 bg-space-900/90 text-neon-green': $store.global.toast.type === 'success',
'alert-error border-red-500/20 bg-space-900/90 text-red-400': $store.global.toast.type === 'error'
}">
<div class="flex items-center gap-3">
<!-- Icons based on type -->
<template x-if="$store.global.toast.type === 'info'">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
<template x-if="$store.global.toast.type === 'success'">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
<template x-if="$store.global.toast.type === 'error'">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
<span x-text="$store.global.toast.message" class="font-mono text-sm"></span>
</div>
</div>
</template>
</div>
<!-- Navbar -->
<div
class="h-14 border-b border-space-border flex items-center px-6 justify-between bg-space-900/50 backdrop-blur-md z-50">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded bg-gradient-to-br from-neon-purple to-blue-600 flex items-center justify-center text-white font-bold shadow-[0_0_15px_rgba(168,85,247,0.4)]">
AG</div>
<div class="flex flex-col">
<span class="text-sm font-bold tracking-wide text-white">ANTIGRAVITY</span>
<span class="text-[10px] text-gray-500 font-mono tracking-wider">CLAUDE PROXY SYSTEM</span>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Connection Pill -->
<div class="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-mono border transition-all duration-300"
:class="connectionStatus === 'connected'
? 'bg-neon-green/10 border-neon-green/20 text-neon-green'
: (connectionStatus === 'connecting' ? 'bg-yellow-500/10 border-yellow-500/20 text-yellow-500' : 'bg-red-500/10 border-red-500/20 text-red-500')">
<div class="w-1.5 h-1.5 rounded-full"
:class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
</div>
<span
x-text="connectionStatus === 'connected' ? 'ONLINE' : (connectionStatus === 'disconnected' ? 'OFFLINE' : 'CONNECTING')"></span>
</div>
<div class="h-4 w-px bg-space-border"></div>
<!-- Refresh Button -->
<button class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
@click="fetchData" :disabled="loading" title="Refresh Data">
<svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<!-- Layout -->
<div class="flex h-[calc(100vh-56px)]">
<!-- Sidebar -->
<div class="w-64 bg-space-900 border-r border-space-border flex flex-col pt-6 pb-4">
<div class="px-4 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest">Main</div>
<nav class="flex flex-col gap-1">
<button
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
:class="{'active': $store.global.activeTab === 'dashboard'}"
@click="$store.global.activeTab = 'dashboard'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<span x-text="$store.global.t('dashboard')">Dashboard</span>
</button>
<button
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
:class="{'active': $store.global.activeTab === 'accounts'}"
@click="$store.global.activeTab = 'accounts'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span x-text="$store.global.t('accounts')">Accounts</span>
</button>
</nav>
<div class="px-4 mt-8 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest">System</div>
<nav class="flex flex-col gap-1">
<button
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
:class="{'active': $store.global.activeTab === 'logs'}" @click="$store.global.activeTab = 'logs'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span x-text="$store.global.t('logs')">Logs</span>
</button>
<button
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
:class="{'active': $store.global.activeTab === 'settings'}"
@click="$store.global.activeTab = 'settings'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span x-text="$store.global.t('settings')">Settings</span>
</button>
</nav>
<!-- Footer Info -->
<div class="mt-auto px-6 text-[10px] text-gray-700 font-mono">
<div class="flex justify-between">
<span>V 1.0.0</span>
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank"
class="hover:text-neon-purple transition-colors">GitHub</a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-auto bg-space-950 p-6 relative">
<!-- Views Container -->
<!-- Dashboard -->
<div x-show="$store.global.activeTab === 'dashboard'" x-load-view="'dashboard'"
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-7xl mx-auto h-full"></div>
<!-- Logs -->
<div x-show="$store.global.activeTab === 'logs'" x-load-view="'logs'" x-transition:enter="fade-enter-active"
x-transition:enter-start="fade-enter-from" class="h-full"></div>
<!-- Accounts -->
<div x-show="$store.global.activeTab === 'accounts'" x-load-view="'accounts'"
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-4xl mx-auto h-full"></div>
<!-- Settings -->
<div x-show="$store.global.activeTab === 'settings'" x-load-view="'settings'"
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-2xl mx-auto h-full"></div>
</div>
</div>
<!-- Add Account Modal -->
<dialog id="add_account_modal" class="modal backdrop-blur-sm">
<div class="modal-box bg-space-900 border border-space-border text-gray-300 shadow-[0_0_50px_rgba(0,0,0,0.5)]">
<h3 class="font-bold text-lg text-white" x-text="t('addNode')">Add New Account</h3>
<div class="py-6 flex flex-col gap-4">
<p class="text-sm text-gray-400">Connect a Google Workspace account to increase your API quota limit.
The account will be used to proxy Claude requests via Antigravity.</p>
<button
class="btn btn-primary bg-white text-black hover:bg-gray-200 border-none flex items-center justify-center gap-3 h-12"
@click="addAccountWeb">
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"></path>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"></path>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"></path>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"></path>
</svg>
<span x-text="t('connectGoogle')">Connect Google Account</span>
</button>
<div class="divider text-xs text-gray-600">OR</div>
<div class="collapse collapse-arrow bg-space-800 border border-space-border/50">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium text-gray-400">
Use CLI Command
</div>
<div class="collapse-content">
<div class="mockup-code bg-black text-gray-300 border border-space-border scale-90 -ml-4 mt-2">
<pre data-prefix="$"><code>npm run accounts:add</code></pre>
</div>
</div>
</div>
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost hover:bg-white/10">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Scripts - Loading Order Matters! -->
<!-- 1. Utils (global helpers) -->
<script src="js/utils.js"></script>
<!-- 2. Alpine Stores (register alpine:init listeners) -->
<script src="js/store.js"></script>
<script src="js/data-store.js"></script>
<script src="js/settings-store.js"></script>
<!-- 3. Components (register to window.Components) -->
<script src="js/components/dashboard.js"></script>
<script src="js/components/account-manager.js"></script>
<script src="js/components/claude-config.js"></script>
<script src="js/components/logs-viewer.js"></script>
<!-- 4. App (registers Alpine components from window.Components) -->
<script src="app.js"></script>
</body>
</html>

106
public/js/app-init.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* App Initialization (Non-module version)
* This must load BEFORE Alpine initializes
*/
document.addEventListener('alpine:init', () => {
console.log('Registering app component...');
// Main App Controller
Alpine.data('app', () => ({
// Re-expose store properties for easier access in navbar
get connectionStatus() {
return Alpine.store('data').connectionStatus;
},
get loading() {
return Alpine.store('data').loading;
},
init() {
console.log('App component initializing...');
// Theme setup
document.documentElement.setAttribute('data-theme', 'black');
document.documentElement.classList.add('dark');
// Chart Defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#71717a';
Chart.defaults.borderColor = '#27272a';
Chart.defaults.font.family = '"JetBrains Mono", monospace';
}
// Start Data Polling
this.startAutoRefresh();
document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
// Initial Fetch
Alpine.store('data').fetchData();
},
refreshTimer: null,
fetchData() {
Alpine.store('data').fetchData();
},
startAutoRefresh() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
const interval = parseInt(Alpine.store('settings').refreshInterval);
if (interval > 0) {
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
}
},
// Translation helper for modal (not in a component scope)
t(key) {
return Alpine.store('global').t(key);
},
// Add account handler for modal
async addAccountWeb(reAuthEmail = null) {
const password = Alpine.store('global').webuiPassword;
try {
const urlPath = reAuthEmail
? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
: '/api/auth/url';
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const width = 600;
const height = 700;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
data.url,
'google_oauth',
`width=${width},height=${height},top=${top},left=${left},scrollbars=yes`
);
const messageHandler = (event) => {
if (event.data?.type === 'oauth-success') {
const action = reAuthEmail ? 're-authenticated' : 'added';
Alpine.store('global').showToast(`Account ${event.data.email} ${action} successfully`, 'success');
Alpine.store('data').fetchData();
const modal = document.getElementById('add_account_modal');
if (modal) modal.close();
}
};
window.addEventListener('message', messageHandler);
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
} else {
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Failed to start OAuth flow: ' + e.message, 'error');
}
}
}));
});

View File

@@ -0,0 +1,104 @@
/**
* Account Manager Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.accountManager = () => ({
async refreshAccount(email) {
Alpine.store('global').showToast(`Refreshing ${email}...`, 'info');
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
Alpine.store('global').showToast(`Refreshed ${email}`, 'success');
Alpine.store('data').fetchData();
} else {
Alpine.store('global').showToast(data.error || 'Refresh failed', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Refresh failed: ' + e.message, 'error');
}
},
async toggleAccount(email, enabled) {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
Alpine.store('global').showToast(`Account ${email} ${enabled ? 'enabled' : 'disabled'}`, 'success');
Alpine.store('data').fetchData();
} else {
Alpine.store('global').showToast(data.error || 'Toggle failed', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Toggle failed: ' + e.message, 'error');
}
},
async fixAccount(email) {
Alpine.store('global').showToast(`Re-authenticating ${email}...`, 'info');
const password = Alpine.store('global').webuiPassword;
try {
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
} else {
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Failed: ' + e.message, 'error');
}
},
async deleteAccount(email) {
if (!confirm(Alpine.store('global').t('confirmDelete'))) return;
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
Alpine.store('global').showToast(`Deleted ${email}`, 'success');
Alpine.store('data').fetchData();
} else {
Alpine.store('global').showToast(data.error || 'Delete failed', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Delete failed: ' + e.message, 'error');
}
},
async reloadAccounts() {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
Alpine.store('global').showToast('Accounts reloaded', 'success');
Alpine.store('data').fetchData();
} else {
Alpine.store('global').showToast(data.error || 'Reload failed', 'error');
}
} catch (e) {
Alpine.store('global').showToast('Reload failed: ' + e.message, 'error');
}
}
});

View File

@@ -0,0 +1,54 @@
/**
* Claude Config Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.claudeConfig = () => ({
config: { env: {} },
models: [],
loading: false,
init() {
this.fetchConfig();
this.$watch('$store.data.models', (val) => {
this.models = val || [];
});
this.models = Alpine.store('data').models || [];
},
async fetchConfig() {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/claude/config', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.config = data.config || {};
if (!this.config.env) this.config.env = {};
} catch (e) {
console.error('Failed to fetch Claude config:', e);
}
},
async saveClaudeConfig() {
this.loading = true;
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/claude/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.config)
}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
Alpine.store('global').showToast('Claude config saved!', 'success');
} catch (e) {
Alpine.store('global').showToast('Failed to save config: ' + e.message, 'error');
} finally {
this.loading = false;
}
}
});

View File

@@ -0,0 +1,122 @@
/**
* Dashboard Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.dashboard = () => ({
stats: { total: 0, active: 0, limited: 0, overallHealth: 0 },
charts: { quotaDistribution: null },
init() {
// Update stats when dashboard becomes active
this.$watch('$store.global.activeTab', (val) => {
if (val === 'dashboard') {
this.$nextTick(() => {
this.updateStats();
this.updateCharts();
});
}
});
// Watch for data changes
this.$watch('$store.data.accounts', () => {
if (this.$store.global.activeTab === 'dashboard') {
this.updateStats();
this.$nextTick(() => this.updateCharts());
}
});
// Initial update if already on dashboard
if (this.$store.global.activeTab === 'dashboard') {
this.$nextTick(() => {
this.updateStats();
this.updateCharts();
});
}
},
updateStats() {
const accounts = Alpine.store('data').accounts;
let active = 0, limited = 0;
accounts.forEach(acc => {
if (acc.status === 'ok') {
const hasQuota = Object.values(acc.limits || {}).some(l => l && l.remainingFraction > 0);
if (hasQuota) active++; else limited++;
} else {
limited++;
}
});
this.stats = { total: accounts.length, active, limited };
},
updateCharts() {
const ctx = document.getElementById('quotaChart');
if (!ctx || typeof Chart === 'undefined') return;
if (this.charts.quotaDistribution) {
this.charts.quotaDistribution.destroy();
}
const rows = Alpine.store('data').quotaRows;
const familyStats = { claude: { sum: 0, count: 0 }, gemini: { sum: 0, count: 0 }, other: { sum: 0, count: 0 } };
// Calculate overall system health
let totalHealthSum = 0;
let totalModelCount = 0;
rows.forEach(row => {
const f = familyStats[row.family] ? row.family : 'other';
// Use avgQuota if available (new logic), fallback to minQuota (old logic compatibility)
const quota = row.avgQuota !== undefined ? row.avgQuota : row.minQuota;
familyStats[f].sum += quota;
familyStats[f].count++;
totalHealthSum += quota;
totalModelCount++;
});
this.stats.overallHealth = totalModelCount > 0 ? Math.round(totalHealthSum / totalModelCount) : 0;
const labels = ['Claude', 'Gemini', 'Other'];
const data = [
familyStats.claude.count ? Math.round(familyStats.claude.sum / familyStats.claude.count) : 0,
familyStats.gemini.count ? Math.round(familyStats.gemini.sum / familyStats.gemini.count) : 0,
familyStats.other.count ? Math.round(familyStats.other.sum / familyStats.other.count) : 0,
];
this.charts.quotaDistribution = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: ['#a855f7', '#22c55e', '#52525b'],
borderColor: '#09090b', // Matches bg-space-900 roughly
borderWidth: 2,
hoverOffset: 0,
borderRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '85%', // Thinner ring
plugins: {
legend: {
display: false // Hide default legend
},
tooltip: {
enabled: false // Disable tooltip for cleaner look, or style it? Let's keep it simple.
},
title: { display: false }
},
animation: {
animateScale: true,
animateRotate: true
}
}
});
}
});

View File

@@ -0,0 +1,87 @@
/**
* Logs Viewer Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.logsViewer = () => ({
logs: [],
isAutoScroll: true,
eventSource: null,
searchQuery: '',
filters: {
INFO: true,
WARN: true,
ERROR: true,
SUCCESS: true,
DEBUG: false
},
get filteredLogs() {
const query = this.searchQuery.toLowerCase();
return this.logs.filter(log => {
// Level Filter
if (!this.filters[log.level]) return false;
// Search Filter
if (query && !log.message.toLowerCase().includes(query)) return false;
return true;
});
},
init() {
this.startLogStream();
this.$watch('isAutoScroll', (val) => {
if (val) this.scrollToBottom();
});
// Watch filters to maintain auto-scroll if enabled
this.$watch('searchQuery', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
this.$watch('filters', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
},
startLogStream() {
if (this.eventSource) this.eventSource.close();
const password = Alpine.store('global').webuiPassword;
const url = password
? `/api/logs/stream?history=true&password=${encodeURIComponent(password)}`
: '/api/logs/stream?history=true';
this.eventSource = new EventSource(url);
this.eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
this.logs.push(log);
// Limit log buffer
const limit = Alpine.store('settings')?.logLimit || 2000;
if (this.logs.length > limit) {
this.logs = this.logs.slice(-limit);
}
if (this.isAutoScroll) {
this.$nextTick(() => this.scrollToBottom());
}
} catch (e) {
console.error('Log parse error:', e);
}
};
this.eventSource.onerror = () => {
console.warn('Log stream disconnected, reconnecting...');
setTimeout(() => this.startLogStream(), 3000);
};
},
scrollToBottom() {
const container = document.getElementById('logs-container');
if (container) container.scrollTop = container.scrollHeight;
},
clearLogs() {
this.logs = [];
}
});

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';
}
});
});

View File

@@ -0,0 +1,57 @@
/**
* Settings Store
*/
document.addEventListener('alpine:init', () => {
Alpine.store('settings', {
refreshInterval: 60,
logLimit: 2000,
showExhausted: true,
showHiddenModels: false, // New field
compact: false,
port: 8080, // Display only
init() {
this.loadSettings();
},
// Call this method when toggling settings in the UI
toggle(key) {
if (this.hasOwnProperty(key) && typeof this[key] === 'boolean') {
this[key] = !this[key];
this.saveSettings(true);
}
},
loadSettings() {
const saved = localStorage.getItem('antigravity_settings');
if (saved) {
const parsed = JSON.parse(saved);
Object.keys(parsed).forEach(k => {
// Only load keys that exist in our default state (safety)
if (this.hasOwnProperty(k)) this[k] = parsed[k];
});
}
},
saveSettings(silent = false) {
const toSave = {
refreshInterval: this.refreshInterval,
logLimit: this.logLimit,
showExhausted: this.showExhausted,
showHiddenModels: this.showHiddenModels,
compact: this.compact
};
localStorage.setItem('antigravity_settings', JSON.stringify(toSave));
if (!silent) {
Alpine.store('global').showToast('Configuration Saved', 'success');
}
// Trigger updates
document.dispatchEvent(new CustomEvent('refresh-interval-changed'));
if (Alpine.store('data')) {
Alpine.store('data').computeQuotaRows();
}
}
});
});

222
public/js/store.js Normal file
View File

@@ -0,0 +1,222 @@
/**
* Global Store for Antigravity Console
* Handles Translations, Toasts, and Shared Config
*/
document.addEventListener('alpine:init', () => {
Alpine.store('global', {
// App State
version: '1.0.0',
activeTab: 'dashboard',
webuiPassword: localStorage.getItem('antigravity_webui_password') || '',
// i18n
lang: localStorage.getItem('app_lang') || 'en',
translations: {
en: {
dashboard: "Dashboard",
accounts: "Accounts",
logs: "Logs",
settings: "Settings",
online: "ONLINE",
offline: "OFFLINE",
totalAccounts: "TOTAL ACCOUNTS",
active: "ACTIVE",
operational: "Operational",
rateLimited: "RATE LIMITED",
cooldown: "Cooldown",
searchPlaceholder: "Search models...",
allAccounts: "All Accounts",
stat: "STAT",
modelIdentity: "MODEL IDENTITY",
globalQuota: "GLOBAL QUOTA",
nextReset: "NEXT RESET",
distribution: "ACCOUNT DISTRIBUTION",
systemConfig: "System Configuration",
language: "Language",
pollingInterval: "Polling Interval",
logBufferSize: "Log Buffer Size",
showExhausted: "Show Exhausted Models",
showExhaustedDesc: "Display models even if they have 0% remaining quota.",
compactMode: "Compact Mode",
compactModeDesc: "Reduce padding in tables for higher information density.",
saveChanges: "Save Changes",
autoScroll: "Auto-scroll",
clearLogs: "Clear Logs",
accessCredentials: "Access Credentials",
manageTokens: "Manage OAuth tokens and session states",
addNode: "Add Node",
status: "STATUS",
enabled: "ENABLED",
health: "HEALTH",
identity: "IDENTITY (EMAIL)",
projectId: "PROJECT ID",
sessionState: "SESSION STATE",
operations: "OPERATIONS",
delete: "Delete",
confirmDelete: "Are you sure you want to remove this account?",
connectGoogle: "Connect Google Account",
manualReload: "Reload from Disk",
// Tabs
tabInterface: "Interface",
tabClaude: "Claude CLI",
tabModels: "Models",
tabServer: "Server Info",
// Dashboard
registeredNodes: "Registered Nodes",
noSignal: "NO SIGNAL DETECTED",
establishingUplink: "ESTABLISHING UPLINK...",
// Settings - Models
modelsDesc: "Manage visibility and ordering of models in the dashboard.",
showHidden: "Show Hidden Models",
modelId: "Model ID",
alias: "Alias",
actions: "Actions",
pinToTop: "Pin to top",
toggleVisibility: "Toggle Visibility",
noModels: "NO MODELS DETECTED",
// Settings - Claude
proxyConnection: "Proxy Connection",
modelSelection: "Model Selection",
aliasOverrides: "ALIAS OVERRIDES",
opusAlias: "Opus Alias",
sonnetAlias: "Sonnet Alias",
haikuAlias: "Haiku Alias",
claudeSettingsAlert: "Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply.",
writeToConfig: "Write to Config",
// Settings - Server
port: "Port",
uiVersion: "UI Version",
debugMode: "Debug Mode",
environment: "Environment",
serverReadOnly: "Server settings are read-only. Modify config.json or .env and restart the server to change.",
dangerZone: "Danger Zone / Advanced",
reloadConfigTitle: "Reload Account Config",
reloadConfigDesc: "Force reload accounts.json from disk",
reload: "Reload",
// Config Specific
primaryModel: "Primary Model",
subAgentModel: "Sub-agent Model",
advancedOverrides: "Default Model Overrides",
opusModel: "Opus Model",
sonnetModel: "Sonnet Model",
haikuModel: "Haiku Model",
authToken: "Auth Token",
saveConfig: "Save to ~/.claude/settings.json",
envVar: "Env",
},
zh: {
dashboard: "仪表盘",
accounts: "账号管理",
logs: "运行日志",
settings: "系统设置",
online: "在线",
offline: "离线",
totalAccounts: "账号总数",
active: "活跃状态",
operational: "运行中",
rateLimited: "受限状态",
cooldown: "冷却中",
searchPlaceholder: "搜索模型...",
allAccounts: "所有账号",
stat: "状态",
modelIdentity: "模型标识",
globalQuota: "全局配额",
nextReset: "重置时间",
distribution: "账号分布",
systemConfig: "系统配置",
language: "语言设置",
pollingInterval: "数据轮询间隔",
logBufferSize: "日志缓冲大小",
showExhausted: "显示耗尽模型",
showExhaustedDesc: "即使配额为 0% 也显示模型。",
compactMode: "紧凑模式",
compactModeDesc: "减少表格间距以显示更多信息。",
saveChanges: "保存更改",
autoScroll: "自动滚动",
clearLogs: "清除日志",
accessCredentials: "访问凭证",
manageTokens: "管理 OAuth 令牌和会话状态",
addNode: "添加节点",
status: "状态",
enabled: "启用",
health: "健康度",
identity: "身份 (邮箱)",
projectId: "项目 ID",
sessionState: "会话状态",
operations: "操作",
delete: "删除",
confirmDelete: "确定要移除此账号吗?",
connectGoogle: "连接 Google 账号",
manualReload: "重新加载配置",
// Tabs
tabInterface: "界面设置",
tabClaude: "Claude CLI",
tabModels: "模型管理",
tabServer: "服务器信息",
// Dashboard
registeredNodes: "已注册节点",
noSignal: "无信号连接",
establishingUplink: "正在建立上行链路...",
// Settings - Models
modelsDesc: "管理仪表盘中模型的可见性和排序。",
showHidden: "显示隐藏模型",
modelId: "模型 ID",
alias: "别名",
actions: "操作",
pinToTop: "置顶",
toggleVisibility: "切换可见性",
noModels: "未检测到模型",
// Settings - Claude
proxyConnection: "代理连接",
modelSelection: "模型选择",
aliasOverrides: "别名覆盖",
opusAlias: "Opus 别名",
sonnetAlias: "Sonnet 别名",
haikuAlias: "Haiku 别名",
claudeSettingsAlert: "以下设置直接修改 ~/.claude/settings.json。重启 Claude CLI 生效。",
writeToConfig: "写入配置",
// Settings - Server
port: "端口",
uiVersion: "UI 版本",
debugMode: "调试模式",
environment: "运行环境",
serverReadOnly: "服务器设置只读。修改 config.json 或 .env 并重启服务器以生效。",
dangerZone: "危险区域 / 高级",
reloadConfigTitle: "重载账号配置",
reloadConfigDesc: "强制从磁盘重新读取 accounts.json",
reload: "重载",
// Config Specific
primaryModel: "主模型",
subAgentModel: "子代理模型",
advancedOverrides: "默认模型覆盖 (高级)",
opusModel: "Opus 模型",
sonnetModel: "Sonnet 模型",
haikuModel: "Haiku 模型",
authToken: "认证令牌",
saveConfig: "保存到 ~/.claude/settings.json",
envVar: "环境变量",
}
},
// Toast Messages
toast: null,
t(key) {
return this.translations[this.lang][key] || key;
},
setLang(l) {
this.lang = l;
localStorage.setItem('app_lang', l);
},
showToast(message, type = 'info') {
const id = Date.now();
this.toast = { message, type, id };
setTimeout(() => {
if (this.toast && this.toast.id === id) this.toast = null;
}, 3000);
}
});
});

41
public/js/utils.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Utility functions for Antigravity Console
*/
window.utils = {
// Shared Request Wrapper
async request(url, options = {}, webuiPassword = '') {
options.headers = options.headers || {};
if (webuiPassword) {
options.headers['x-webui-password'] = webuiPassword;
}
let response = await fetch(url, options);
if (response.status === 401) {
const password = prompt('Enter Web UI Password:');
if (password) {
// Return new password so caller can update state
// This implies we need a way to propagate the new password back
// For simplicity in this functional utility, we might need a callback or state access
// But generally utils shouldn't probably depend on global state directly if possible
// let's stick to the current logic but wrapped
localStorage.setItem('antigravity_webui_password', password);
options.headers['x-webui-password'] = password;
response = await fetch(url, options);
return { response, newPassword: password };
}
}
return { response, newPassword: null };
},
formatTimeUntil(isoTime) {
const diff = new Date(isoTime) - new Date();
if (diff <= 0) return 'READY';
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}H ${mins % 60}M`;
return `${mins}M`;
}
};

View File

@@ -0,0 +1,80 @@
<div x-data="accountManager" class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accessCredentials')">
Access
Credentials</h2>
<p class="text-gray-500 text-sm" x-text="$store.global.t('manageTokens')">Manage OAuth tokens
and session
states</p>
</div>
<button class="btn btn-sm bg-neon-purple hover:bg-purple-600 text-white border-none gap-2"
onclick="document.getElementById('add_account_modal').showModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('addNode')">Add Node</span>
</button>
</div>
<div class="glass-panel rounded-xl overflow-hidden">
<table class="table w-full">
<thead class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border">
<tr>
<th class="pl-6 w-24" x-text="$store.global.t('enabled')">Enabled</th>
<th x-text="$store.global.t('identity')">Identity (Email)</th>
<th x-text="$store.global.t('projectId')">Project ID</th>
<th class="w-32" x-text="$store.global.t('health')">Health</th>
<th class="text-right pr-6" x-text="$store.global.t('operations')">Operations</th>
</tr>
</thead>
<tbody class="divide-y divide-space-border/50">
<template x-for="acc in $store.data.accounts" :key="acc.email">
<tr class="hover:bg-white/5 transition-colors">
<td class="pl-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" :checked="acc.enabled !== false"
@change="toggleAccount(acc.email, $el.checked)">
<div
class="w-9 h-5 bg-space-800 border border-space-border peer-focus:outline-none peer-focus:ring-1 peer-focus:ring-neon-purple rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-gray-400 after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green/20 peer-checked:border-neon-green peer-checked:after:bg-neon-green peer-checked:after:shadow-[0_0_8px_rgba(34,197,94,0.8)]">
</div>
</label>
</td>
<td class="font-medium text-gray-200" x-text="acc.email"></td>
<td class="font-mono text-xs text-gray-400" x-text="acc.projectId || '-'"></td>
<td>
<span class="badge badge-sm border-0 font-mono"
:class="acc.status === 'ok' ? 'bg-neon-green/10 text-neon-green' : 'bg-red-500/10 text-red-500'"
x-text="acc.status.toUpperCase()"></span>
</td>
<td class="text-right pr-6">
<div class="flex justify-end gap-1">
<!-- Fix Button -->
<button x-show="acc.status === 'invalid'"
class="btn btn-xs bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20 border-none mr-1 px-2 font-mono"
@click="fixAccount(acc.email)">
FIX
</button>
<button class="btn btn-xs btn-square btn-ghost text-gray-400 hover:text-white"
@click="refreshAccount(acc.email)" title="Refresh Token">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
class="btn btn-xs btn-square btn-ghost text-red-400 hover:text-red-500 hover:bg-red-500/10"
@click="deleteAccount(acc.email)" :title="$store.global.t('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>

221
public/views/dashboard.html Normal file
View File

@@ -0,0 +1,221 @@
<div x-data="dashboard" class="space-y-6 animate-fade-in">
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
<div class="stat-figure text-neon-cyan group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z">
</path>
</svg>
</div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
x-text="$store.global.t('totalAccounts')"></div>
<div class="stat-value text-white font-mono text-3xl" x-text="stats.total"></div>
<div class="stat-desc text-gray-600 text-[10px] mt-1">Registered Nodes</div>
</div>
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
<div class="stat-figure text-neon-green group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
x-text="$store.global.t('active')"></div>
<div class="stat-value text-white font-mono text-3xl" x-text="stats.active"></div>
<div class="stat-desc text-neon-green/60 text-[10px] mt-1" x-text="$store.global.t('operational')"></div>
</div>
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
<div class="stat-figure text-red-500 group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
x-text="$store.global.t('rateLimited')"></div>
<div class="stat-value text-white font-mono text-3xl" x-text="stats.limited"></div>
<div class="stat-desc text-red-500/60 text-[10px] mt-1" x-text="$store.global.t('cooldown')"></div>
</div>
<!-- Global Quota Chart -->
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 col-span-1 md:col-start-4 md:row-start-1 h-full flex items-center justify-between gap-4 overflow-hidden relative group hover:border-space-border/60 transition-colors">
<!-- Chart Container -->
<div class="h-20 w-20 relative flex-shrink-0">
<canvas id="quotaChart"></canvas>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div>
</div>
</div>
<!-- Legend / Info -->
<div class="flex flex-col justify-center gap-2 flex-grow min-w-0 z-10">
<div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono">Global Quota</span>
<!-- <span class="text-xs font-bold text-neon-purple" x-text="stats.overallHealth + '%'"></span> -->
</div>
<!-- Custom Legend -->
<div class="space-y-1">
<div class="flex items-center justify-between text-[10px] text-gray-400">
<div class="flex items-center gap-1.5">
<div class="w-1.5 h-1.5 rounded-full bg-neon-purple shadow-[0_0_4px_rgba(168,85,247,0.4)]"></div>
<span>Claude</span>
</div>
<!-- <span class="font-mono text-gray-600">--</span> -->
</div>
<div class="flex items-center justify-between text-[10px] text-gray-400">
<div class="flex items-center gap-1.5">
<div class="w-1.5 h-1.5 rounded-full bg-neon-green shadow-[0_0_4px_rgba(34,197,94,0.4)]"></div>
<span>Gemini</span>
</div>
</div>
</div>
</div>
<!-- Decorative Glow -->
<div class="absolute -right-6 -top-6 w-24 h-24 bg-neon-purple/5 rounded-full blur-2xl pointer-events-none"></div>
</div>
</div>
<!-- Controls -->
<div class="flex items-center justify-between gap-4 glass-panel p-2 rounded-lg h-16">
<div class="flex items-center gap-4 h-full">
<!-- Custom Select -->
<div class="relative w-64 h-10">
<select
class="appearance-none w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-4 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all truncate text-sm"
x-model="$store.data.filters.account" @change="$store.data.computeQuotaRows()">
<option value="all" x-text="$store.global.t('allAccounts')">All Accounts</option>
<template x-for="acc in $store.data.accounts" :key="acc.email">
<option :value="acc.email" x-text="acc.email.split('@')[0]"></option>
</template>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Filter Buttons -->
<div class="join h-10">
<button
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'all'}"
@click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()">ALL</button>
<button
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'claude'}"
@click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()">CLAUDE</button>
<button
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'gemini'}"
@click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()">GEMINI</button>
</div>
</div>
<!-- Search -->
<div class="relative w-72 h-10">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-4 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-sm placeholder-gray-600"
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
</div>
</div>
<!-- Main Table -->
<div class="glass-panel rounded-xl overflow-hidden min-h-[400px]">
<table class="table w-full" :class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
<thead
class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase tracking-wider border-b border-space-border">
<tr>
<th class="w-8 py-3 pl-4" x-text="$store.global.t('stat')">Stat</th>
<th class="py-3" x-text="$store.global.t('modelIdentity')">Model Identity</th>
<th class="w-48 py-3" x-text="$store.global.t('globalQuota')">Global Quota</th>
<th class="w-32 py-3" x-text="$store.global.t('nextReset')">Next Reset</th>
<th class="py-3 pr-4 text-right" x-text="$store.global.t('distribution')">Account
Distribution</th>
</tr>
</thead>
<tbody class="divide-y divide-space-border/50 text-sm">
<template x-for="row in $store.data.quotaRows" :key="row.modelId">
<tr class="hover:bg-white/5 transition-colors group">
<td class="pl-4">
<div class="w-2 h-2 rounded-full transition-all duration-500"
:class="row.avgQuota > 0 ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'">
</div>
</td>
<td>
<div class="font-bold text-gray-200 group-hover:text-neon-purple transition-colors"
x-text="row.modelId"></div>
<div class="text-[10px] font-mono text-gray-500 uppercase" x-text="row.family">
</div>
</td>
<td>
<div class="flex flex-col gap-1 pr-4">
<div class="flex justify-between text-xs font-mono">
<span x-text="row.avgQuota + '%'"
:class="row.avgQuota > 0 ? 'text-white' : 'text-red-500'"></span>
</div>
<progress class="progress w-full h-1.5 bg-space-800"
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
:value="row.avgQuota" max="100"></progress>
</div>
</td>
<td class="font-mono text-xs">
<span x-text="row.resetIn" :class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
</td>
<td>
<div class="flex items-center justify-end gap-3 pr-4">
<div class="text-[10px] font-mono text-gray-500 hidden xl:block text-right leading-tight opacity-70">
<div x-text="row.quotaInfo.filter(q => q.pct > 0).length + ' Active'"></div>
</div>
<div class="flex flex-wrap gap-1 justify-end max-w-[150px]">
<template x-for="q in row.quotaInfo" :key="q.fullEmail">
<div class="tooltip tooltip-left" :data-tip="q.fullEmail + ' (' + q.pct + '%)'">
<div class="w-1.5 h-3 rounded-[1px] transition-all hover:scale-125 cursor-help"
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div>
</div>
</template>
</div>
</div>
</td>
</tr>
</template>
<!-- Loading -->
<tr x-show="$store.data.loading && !$store.data.quotaRows.length">
<td colspan="5" class="h-64 text-center">
<div class="flex flex-col items-center justify-center gap-3">
<span class="loading loading-bars loading-md text-neon-purple"></span>
<span class="text-xs font-mono text-gray-600 animate-pulse">ESTABLISHING
UPLINK...</span>
</div>
</td>
</tr>
<!-- Empty -->
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0">
<td colspan="5" class="h-64 text-center text-gray-600 font-mono text-xs">
NO SIGNAL DETECTED
</td>
</tr>
</tbody>
</table>
</div>
</div>

85
public/views/logs.html Normal file
View File

@@ -0,0 +1,85 @@
<div x-data="logsViewer" class="h-full flex flex-col glass-panel rounded-xl overflow-hidden border-space-border">
<!-- Toolbar -->
<div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px]">
<!-- Left: Decor & Title -->
<div class="flex items-center gap-3 shrink-0">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500/50"></div>
<div class="w-3 h-3 rounded-full bg-green-500/20 border border-green-500/50"></div>
</div>
<span class="text-xs font-mono text-gray-500 hidden sm:inline-block">~/logs</span>
</div>
<!-- Center: Search & Filters -->
<div class="flex-1 flex items-center justify-center gap-4 px-4 min-w-0">
<!-- Search -->
<div class="relative w-full max-w-xs group">
<div class="absolute inset-y-0 left-0 pl-2 flex items-center pointer-events-none">
<svg class="h-3 w-3 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" x-model="searchQuery" placeholder="grep logs..."
class="w-full h-7 bg-space-950 border border-space-border rounded text-xs font-mono pl-7 pr-2 focus:border-neon-purple focus:outline-none transition-colors placeholder-gray-700 text-gray-300">
</div>
<!-- Filters -->
<div class="hidden md:flex gap-3 text-[10px] font-mono font-bold uppercase select-none">
<label class="flex items-center gap-1.5 cursor-pointer text-blue-400 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.INFO}">
<input type="checkbox" class="checkbox checkbox-xs checkbox-info rounded-[2px] w-3 h-3 border-blue-400/50" x-model="filters.INFO"> INFO
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-neon-green opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.SUCCESS}">
<input type="checkbox" class="checkbox checkbox-xs checkbox-success rounded-[2px] w-3 h-3 border-neon-green/50" x-model="filters.SUCCESS"> SUCCESS
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-yellow-400 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.WARN}">
<input type="checkbox" class="checkbox checkbox-xs checkbox-warning rounded-[2px] w-3 h-3 border-yellow-400/50" x-model="filters.WARN"> WARN
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-red-500 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.ERROR}">
<input type="checkbox" class="checkbox checkbox-xs checkbox-error rounded-[2px] w-3 h-3 border-red-500/50" x-model="filters.ERROR"> ERR
</label>
</div>
</div>
<!-- Right: Controls -->
<div class="flex items-center gap-4 shrink-0">
<div class="text-[10px] font-mono text-gray-600 hidden lg:block">
<span x-text="filteredLogs.length"></span>/<span x-text="logs.length"></span>
</div>
<label class="cursor-pointer flex items-center gap-2" title="Auto-scroll to bottom">
<span class="text-[10px] font-mono text-gray-500 uppercase hidden sm:inline-block"
x-text="$store.global.t('autoScroll')">Auto-Scroll</span>
<input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll">
</label>
<button class="btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white" @click="clearLogs" :title="$store.global.t('clearLogs')">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Log Content -->
<div id="logs-container" class="flex-1 overflow-auto p-4 font-mono text-xs space-y-0.5 bg-[#050505]">
<template x-for="(log, idx) in filteredLogs" :key="idx">
<div class="hover:bg-white/5 rounded px-2 py-0.5 -mx-2 break-words leading-tight flex gap-3 group">
<span class="text-gray-600 w-16 shrink-0 select-none group-hover:text-gray-500"
x-text="new Date(log.timestamp).toLocaleTimeString([], {hour12:false})"></span>
<span class="font-bold w-12 shrink-0 select-none" :class="{
'text-blue-400': log.level === 'INFO',
'text-yellow-400': log.level === 'WARN',
'text-red-500': log.level === 'ERROR',
'text-neon-green': log.level === 'SUCCESS',
'text-purple-400': log.level === 'DEBUG'
}" x-text="log.level"></span>
<span class="text-gray-300 break-all" x-html="log.message.replace(/\n/g, '<br>')"></span>
</div>
</template>
<!-- Blinking Cursor -->
<div class="h-4 w-2 bg-gray-500 animate-pulse mt-1 inline-block" x-show="filteredLogs.length === logs.length && !searchQuery"></div>
<div x-show="filteredLogs.length === 0 && logs.length > 0" class="text-gray-600 italic mt-4 text-center">
No logs match filter
</div>
</div>
</div>

519
public/views/settings.html Normal file
View File

@@ -0,0 +1,519 @@
<div x-data="{ activeTab: 'ui' }" class="glass-panel rounded-xl border border-space-border flex flex-col h-[calc(100vh-140px)] overflow-hidden">
<!-- Header & Tabs -->
<div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-neon-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span x-text="$store.global.t('systemConfig')">System Configuration</span>
</h3>
</div>
<div class="flex gap-6">
<button @click="activeTab = 'ui'"
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
:class="activeTab === 'ui' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
<span x-text="$store.global.t('tabInterface')">Interface</span>
</button>
<button @click="activeTab = 'claude'"
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
:class="activeTab === 'claude' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span x-text="$store.global.t('tabClaude')">Claude CLI</span>
</button>
<button @click="activeTab = 'models'"
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
:class="activeTab === 'models' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
<span x-text="$store.global.t('tabModels')">Models</span>
</button>
<button @click="activeTab = 'server'"
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
:class="activeTab === 'server' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
<span x-text="$store.global.t('tabServer')">Server Info</span>
</button>
</div>
</div>
<!-- Scrollable Content -->
<div class="p-8 overflow-y-auto flex-1 custom-scrollbar">
<!-- Tab 1: UI Preferences -->
<div x-show="activeTab === 'ui'" class="space-y-8 max-w-2xl animate-fade-in">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Language -->
<div class="form-control">
<label class="label">
<span class="label-text text-gray-300" x-text="$store.global.t('language')">Language</span>
</label>
<div class="join w-full grid grid-cols-2">
<button
class="join-item btn btn-sm border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.global.lang === 'en'}"
@click="$store.global.setLang('en')">English</button>
<button
class="join-item btn btn-sm border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.global.lang === 'zh'}"
@click="$store.global.setLang('zh')">中文</button>
</div>
</div>
<!-- Polling Interval -->
<div class="form-control">
<label class="label">
<span class="label-text text-gray-300" x-text="$store.global.t('pollingInterval')">Polling
Interval</span>
<span class="label-text-alt font-mono text-neon-purple"
x-text="$store.settings.refreshInterval + 's'"></span>
</label>
<input type="range" min="10" max="300" class="range range-xs range-primary"
x-model="$store.settings.refreshInterval"
@change="$store.settings.saveSettings(true)"
style="--range-shdw: #a855f7">
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>10s</span>
<span>300s</span>
</div>
</div>
<!-- Log Buffer -->
<div class="form-control col-span-full">
<label class="label">
<span class="label-text text-gray-300" x-text="$store.global.t('logBufferSize')">Log Buffer Size</span>
<span class="label-text-alt font-mono text-neon-purple"
x-text="$store.settings.logLimit + ' lines'"></span>
</label>
<input type="range" min="500" max="5000" step="500" class="range range-xs range-secondary"
x-model="$store.settings.logLimit"
@change="$store.settings.saveSettings(true)"
style="--range-shdw: #22c55e">
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>500</span>
<span>5000</span>
</div>
</div>
</div>
<div class="divider border-space-border/50"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="form-control bg-space-900/50 p-4 rounded-lg border transition-all duration-300 hover:border-space-border cursor-pointer group"
:class="$store.settings.showExhausted ? 'border-neon-purple/50 bg-neon-purple/5 shadow-[0_0_15px_rgba(168,85,247,0.1)]' : 'border-space-border/50'"
@click="$store.settings.showExhausted = !$store.settings.showExhausted; $store.settings.saveSettings(true)">
<label class="label cursor-pointer pointer-events-none"> <!-- pointer-events-none to prevent double toggle -->
<div class="flex flex-col gap-1">
<span class="label-text font-medium transition-colors"
:class="$store.settings.showExhausted ? 'text-neon-purple' : 'text-gray-300'"
x-text="$store.global.t('showExhausted')">Show Exhausted Models</span>
<span class="text-xs text-gray-500" x-text="$store.global.t('showExhaustedDesc')">Display models even if they have 0% remaining quota.</span>
</div>
<input type="checkbox" class="toggle toggle-primary" x-model="$store.settings.showExhausted">
</label>
</div>
<div
class="form-control bg-space-900/50 p-4 rounded-lg border transition-all duration-300 hover:border-space-border cursor-pointer group"
:class="$store.settings.compact ? 'border-neon-green/50 bg-neon-green/5 shadow-[0_0_15px_rgba(34,197,94,0.1)]' : 'border-space-border/50'"
@click="$store.settings.compact = !$store.settings.compact; $store.settings.saveSettings(true)">
<label class="label cursor-pointer pointer-events-none">
<div class="flex flex-col gap-1">
<span class="label-text font-medium transition-colors"
:class="$store.settings.compact ? 'text-neon-green' : 'text-gray-300'"
x-text="$store.global.t('compactMode')">Compact Mode</span>
<span class="text-xs text-gray-500" x-text="$store.global.t('compactModeDesc')">Reduce padding in tables for higher information density.</span>
</div>
<input type="checkbox" class="toggle toggle-secondary" x-model="$store.settings.compact">
</label>
</div>
</div>
<!-- Save button removed (Auto-save enabled) -->
</div>
<!-- Tab 2: Claude CLI Configuration -->
<div x-show="activeTab === 'claude'" x-data="claudeConfig" class="space-y-6 max-w-3xl animate-fade-in">
<div class="alert bg-space-900/50 border-space-border text-sm shadow-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span class="text-gray-400">Settings below directly modify <code class="text-neon-cyan font-mono">~/.claude/settings.json</code>. Restart Claude CLI to apply.</span>
</div>
<!-- Base URL -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2">Proxy Connection</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-[11px] text-gray-400 mb-1 font-mono">ANTHROPIC_BASE_URL</div>
<input type="text" x-model="config.env.ANTHROPIC_BASE_URL" placeholder="http://localhost:8080"
class="input input-sm input-bordered bg-space-800/50 w-full font-mono text-sm border-space-border focus:border-neon-purple focus:bg-space-800 text-gray-200">
</div>
<div>
<div class="text-[11px] text-gray-400 mb-1 font-mono">ANTHROPIC_AUTH_TOKEN</div>
<input type="password" x-model="config.env.ANTHROPIC_AUTH_TOKEN" placeholder="any-string"
class="input input-sm input-bordered bg-space-800/50 w-full font-mono text-sm border-space-border focus:border-neon-purple focus:bg-space-800 text-gray-200">
</div>
</div>
</div>
<!-- Models Selection -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2">Model Selection</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Primary -->
<div class="form-control">
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider">Primary Model</label>
<div class="relative w-full" x-data="{ open: false }">
<input type="text" x-model="config.env.ANTHROPIC_MODEL"
@focus="open = true"
@click.away="open = false"
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-white focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
placeholder="Type to search or select...">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent></div>
<ul x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || ''))" :key="modelId">
<li>
<a @mousedown.prevent="config.env.ANTHROPIC_MODEL = modelId; open = false"
class="font-mono text-xs py-2 hover:bg-space-800 hover:text-neon-cyan border-b border-space-border/30 last:border-0"
:class="config.env.ANTHROPIC_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<span x-text="modelId"></span>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || '')).length === 0">
<span class="text-xs text-gray-500 italic py-2">No matching models</span>
</li>
</ul>
</div>
<span class="text-[10px] text-gray-600 mt-1 font-mono">ANTHROPIC_MODEL</span>
</div>
<!-- Sub-agent -->
<div class="form-control">
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider">Sub-agent Model</label>
<div class="relative w-full" x-data="{ open: false }">
<input type="text" x-model="config.env.CLAUDE_CODE_SUBAGENT_MODEL"
@focus="open = true"
@click.away="open = false"
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-white focus:bg-space-800 focus:border-neon-purple pr-8 placeholder-gray-600"
placeholder="Type to search or select...">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent></div>
<ul x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || ''))" :key="modelId">
<li>
<a @mousedown.prevent="config.env.CLAUDE_CODE_SUBAGENT_MODEL = modelId; open = false"
class="font-mono text-xs py-2 hover:bg-space-800 hover:text-neon-purple border-b border-space-border/30 last:border-0"
:class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId ? 'text-neon-purple bg-space-800/50' : 'text-gray-300'">
<span x-text="modelId"></span>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || '')).length === 0">
<span class="text-xs text-gray-500 italic py-2">No matching models</span>
</li>
</ul>
</div>
<span class="text-[10px] text-gray-600 mt-1 font-mono">CLAUDE_CODE_SUBAGENT_MODEL</span>
</div>
</div>
<div class="divider text-xs font-mono text-gray-600 my-2">ALIAS OVERRIDES</div>
<!-- Overrides -->
<div class="space-y-4">
<!-- Opus -->
<div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Opus Alias</label>
<div class="relative w-full" x-data="{ open: false }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
@focus="open = true"
@click.away="open = false"
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
placeholder="Search...">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent></div>
<ul x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.toLowerCase() || ''))" :key="modelId">
<li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelId; open = false"
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
:class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<span x-text="modelId"></span>
</a>
</li>
</template>
</ul>
</div>
</div>
<!-- Sonnet -->
<div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Sonnet Alias</label>
<div class="relative w-full" x-data="{ open: false }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL"
@focus="open = true"
@click.away="open = false"
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
placeholder="Search...">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent></div>
<ul x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.toLowerCase() || ''))" :key="modelId">
<li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelId; open = false"
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
:class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<span x-text="modelId"></span>
</a>
</li>
</template>
</ul>
</div>
</div>
<!-- Haiku -->
<div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Haiku Alias</label>
<div class="relative w-full" x-data="{ open: false }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
@focus="open = true"
@click.away="open = false"
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
placeholder="Search...">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent></div>
<ul x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.toLowerCase() || ''))" :key="modelId">
<li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelId; open = false"
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
:class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<span x-text="modelId"></span>
</a>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-2">
<button class="btn btn-sm bg-neon-purple hover:bg-purple-600 border-none text-white px-6 gap-2"
@click="saveClaudeConfig" :disabled="loading">
<svg x-show="!loading" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /></svg>
<span x-show="!loading">Write to Config</span>
<span x-show="loading" class="loading loading-spinner loading-xs"></span>
</button>
</div>
</div>
<!-- Tab 2.5: Models Configuration -->
<div x-show="activeTab === 'models'" class="space-y-6 max-w-3xl animate-fade-in">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-400">Manage visibility and ordering of models in the dashboard.</div>
<div class="form-control">
<label class="label cursor-pointer gap-2">
<span class="label-text text-xs text-gray-500">Show Hidden Models</span>
<input type="checkbox" class="toggle toggle-xs toggle-primary" x-model="$store.settings.showHiddenModels" @change="$store.settings.saveSettings(true)">
</label>
</div>
</div>
<!-- Models List -->
<div class="bg-space-900/30 border border-space-border/50 rounded-lg overflow-hidden">
<table class="table table-sm w-full">
<thead class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase tracking-wider border-b border-space-border/50">
<tr>
<th class="pl-4">Model ID</th>
<th>Alias</th>
<th class="text-right pr-4">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-space-border/30">
<template x-for="modelId in $store.data.models" :key="modelId">
<tr class="hover:bg-white/5 transition-colors group"
x-data="{
isEditing: false,
newAlias: '',
// Use getters for reactivity - accessing store directly ensures updates are reflected immediately
get config() { return $store.data.modelConfig[modelId] || {} },
get isPinned() { return !!this.config.pinned },
get isHidden() {
if (this.config.hidden !== undefined) return this.config.hidden;
// Smart default: unknown models are hidden by default
const family = $store.data.getModelFamily(modelId);
return (family === 'other' || family === 'unknown');
}
}"
x-init="newAlias = config.alias || ''"
>
<td class="pl-4 font-mono text-xs text-gray-300" x-text="modelId"></td>
<td>
<div x-show="!isEditing" class="flex items-center gap-2 group-hover:text-white transition-colors cursor-pointer" @click="isEditing = true; newAlias = config.alias || ''">
<span x-text="config.alias || '-'" :class="{'text-gray-600 italic': !config.alias}"></span>
<svg class="w-3 h-3 text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
<div x-show="isEditing" class="flex items-center gap-1">
<input type="text" x-model="newAlias"
class="input input-xs bg-space-800 border-space-border text-white focus:outline-none focus:border-neon-purple w-32"
@keydown.enter="
$store.data.modelConfig[modelId] = { ...config, alias: newAlias };
fetch('/api/models/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ modelId, config: { alias: newAlias } })
});
isEditing = false;
$store.data.computeQuotaRows();
"
@keydown.escape="isEditing = false"
>
<button class="btn btn-xs btn-ghost btn-square text-green-500" @click="
$store.data.modelConfig[modelId] = { ...config, alias: newAlias };
fetch('/api/models/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ modelId, config: { alias: newAlias } })
});
isEditing = false;
$store.data.computeQuotaRows();
"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg></button>
<button class="btn btn-xs btn-ghost btn-square text-gray-500" @click="isEditing = false"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
</td>
<td class="text-right pr-4">
<div class="flex items-center justify-end gap-2">
<!-- Pin Toggle -->
<button class="btn btn-xs btn-circle transition-colors"
:class="isPinned ? 'bg-neon-purple/20 text-neon-purple border-neon-purple/50 hover:bg-neon-purple/30' : 'btn-ghost text-gray-600 hover:text-gray-300'"
@click="
const newVal = !isPinned;
// Optimistic update
$store.data.modelConfig[modelId] = { ...config, pinned: newVal };
fetch('/api/models/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ modelId, config: { pinned: newVal } })
});
$store.data.computeQuotaRows();
"
title="Pin to top">
<!-- Solid Icon when Pinned -->
<svg x-show="isPinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
</svg>
<!-- Outline Icon when Unpinned -->
<svg x-show="!isPinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
<!-- Hide Toggle -->
<button class="btn btn-xs btn-circle transition-colors"
:class="isHidden ? 'bg-red-500/20 text-red-400 border-red-500/50 hover:bg-red-500/30' : 'btn-ghost text-gray-400 hover:text-white'"
@click="
const currentHidden = isHidden;
const newVal = !currentHidden;
// Optimistic update
$store.data.modelConfig[modelId] = { ...config, hidden: newVal };
fetch('/api/models/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ modelId, config: { hidden: newVal } })
});
$store.data.computeQuotaRows();
"
title="Toggle Visibility">
<svg x-show="!isHidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg x-show="isHidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="!$store.data.models.length">
<td colspan="3" class="text-center py-8 text-gray-600 text-xs font-mono">
NO MODELS DETECTED
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tab 3: Server Info -->
<div x-show="activeTab === 'server'" class="space-y-6 max-w-2xl animate-fade-in">
<div class="grid grid-cols-2 gap-4 bg-space-900/30 p-6 rounded-lg border border-space-border/50">
<div>
<div class="text-xs text-gray-500 mb-1">Port</div>
<div class="text-sm font-mono text-neon-cyan" x-text="$store.settings.port || '-'"></div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">UI Version</div>
<div class="text-sm font-mono text-neon-cyan" x-text="$store.global.version"></div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">Debug Mode</div>
<div class="text-sm text-gray-300">Enabled (See Logs)</div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">Environment</div>
<div class="text-sm font-mono text-gray-300">Production</div>
</div>
</div>
<div class="alert bg-blue-500/10 border-blue-500/20 text-xs">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-blue-400 shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div class="text-blue-200">
Server settings are read-only. Modify <code class="font-mono bg-blue-500/20 px-1 rounded">config.json</code> or <code class="font-mono bg-blue-500/20 px-1 rounded">.env</code> and restart the server to change.
</div>
</div>
<!-- Manual Reload -->
<div class="pt-6 border-t border-space-border/30">
<h4 class="text-sm font-bold text-white mb-4">Danger Zone / Advanced</h4>
<div class="flex items-center justify-between p-4 border border-space-border/50 rounded-lg bg-space-900/20">
<div>
<div class="font-medium text-gray-300 text-sm">Reload Account Config</div>
<div class="text-xs text-gray-500">Force reload accounts.json from disk</div>
</div>
<button class="btn btn-sm btn-outline border-space-border hover:bg-white hover:text-black hover:border-white text-gray-400"
@click="Alpine.store('global').showToast('Reloading...', 'info'); fetch('/api/accounts/reload', {method: 'POST'}).then(() => Alpine.store('global').showToast('Reloaded', 'success'))">
Reload
</button>
</div>
</div>
</div>
</div>
</div>

84
src/config.js Normal file
View File

@@ -0,0 +1,84 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from './utils/logger.js';
// Default config
const DEFAULT_CONFIG = {
webuiPassword: '',
debug: false,
logLevel: 'info',
maxRetries: 5,
retryBaseMs: 1000,
retryMaxMs: 30000,
persistTokenCache: false,
modelMapping: {}
};
// Config locations
const HOME_DIR = os.homedir();
const CONFIG_DIR = path.join(HOME_DIR, '.config', 'antigravity-proxy');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
// Ensure config dir exists
if (!fs.existsSync(CONFIG_DIR)) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
} catch (err) {
// Ignore
}
}
// Load config
let config = { ...DEFAULT_CONFIG };
function loadConfig() {
try {
// Env vars take precedence for initial defaults, but file overrides them if present?
// Usually Env > File > Default.
if (fs.existsSync(CONFIG_FILE)) {
const fileContent = fs.readFileSync(CONFIG_FILE, 'utf8');
const userConfig = JSON.parse(fileContent);
config = { ...DEFAULT_CONFIG, ...userConfig };
} else {
// Try looking in current dir for config.json as fallback
const localConfigPath = path.resolve('config.json');
if (fs.existsSync(localConfigPath)) {
const fileContent = fs.readFileSync(localConfigPath, 'utf8');
const userConfig = JSON.parse(fileContent);
config = { ...DEFAULT_CONFIG, ...userConfig };
}
}
// Environment overrides
if (process.env.WEBUI_PASSWORD) config.webuiPassword = process.env.WEBUI_PASSWORD;
if (process.env.DEBUG === 'true') config.debug = true;
} catch (error) {
console.error('[Config] Error loading config:', error);
}
}
// Initial load
loadConfig();
export function getPublicConfig() {
return { ...config };
}
export function saveConfig(updates) {
try {
// Apply updates
config = { ...config, ...updates };
// Save to disk
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
return true;
} catch (error) {
logger.error('[Config] Failed to save config:', error);
return false;
}
}
export { config };

View File

@@ -5,6 +5,7 @@
import { homedir, platform, arch } from 'os'; import { homedir, platform, arch } from 'os';
import { join } from 'path'; import { join } from 'path';
import { config } from './config.js';
/** /**
* Get the Antigravity database path based on the current platform. * Get the Antigravity database path based on the current platform.
@@ -59,13 +60,14 @@ export const ANTIGRAVITY_HEADERS = {
// Default project ID if none can be discovered // Default project ID if none can be discovered
export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc';
export const TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes // Configurable constants - values from config.json take precedence
export const REQUEST_BODY_LIMIT = '50mb'; export const TOKEN_REFRESH_INTERVAL_MS = config?.tokenCacheTtlMs || (5 * 60 * 1000); // From config or 5 minutes
export const REQUEST_BODY_LIMIT = config?.requestBodyLimit || '50mb';
export const ANTIGRAVITY_AUTH_PORT = 9092; export const ANTIGRAVITY_AUTH_PORT = 9092;
export const DEFAULT_PORT = 8080; export const DEFAULT_PORT = config?.port || 8080;
// Multi-account configuration // Multi-account configuration
export const ACCOUNT_CONFIG_PATH = join( export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
homedir(), homedir(),
'.config/antigravity-proxy/accounts.json' '.config/antigravity-proxy/accounts.json'
); );
@@ -74,12 +76,12 @@ export const ACCOUNT_CONFIG_PATH = join(
// Uses platform-specific path detection // Uses platform-specific path detection
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath(); export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
export const DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute
export const MAX_RETRIES = 5; // Max retry attempts across accounts export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10
// Rate limit wait thresholds // Rate limit wait thresholds
export const MAX_WAIT_BEFORE_ERROR_MS = 120000; // 2 minutes - throw error if wait exceeds this export const MAX_WAIT_BEFORE_ERROR_MS = config?.maxWaitBeforeErrorMs || 120000; // From config or 2 minutes
// Thinking model constants // Thinking model constants
export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length

View File

@@ -6,7 +6,13 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js'; import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
import { mountWebUI } from './webui/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { forceRefresh } from './auth/token-extractor.js'; import { forceRefresh } from './auth/token-extractor.js';
import { REQUEST_BODY_LIMIT } from './constants.js'; import { REQUEST_BODY_LIMIT } from './constants.js';
import { AccountManager } from './account-manager/index.js'; import { AccountManager } from './account-manager/index.js';
@@ -57,6 +63,9 @@ async function ensureInitialized() {
app.use(cors()); app.use(cors());
app.use(express.json({ limit: REQUEST_BODY_LIMIT })); app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
// Mount WebUI (optional web interface for account management)
mountWebUI(app, __dirname, accountManager);
/** /**
* Parse error message to extract error type, status code, and user-friendly message * Parse error message to extract error type, status code, and user-friendly message
*/ */

111
src/utils/claude-config.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* Claude CLI Configuration Utility
*
* Handles reading and writing to the global Claude CLI settings file.
* Location: ~/.claude/settings.json (Windows: %USERPROFILE%\.claude\settings.json)
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { logger } from './logger.js';
/**
* Get the path to the global Claude CLI settings file
* @returns {string} Absolute path to settings.json
*/
export function getClaudeConfigPath() {
return path.join(os.homedir(), '.claude', 'settings.json');
}
/**
* Read the global Claude CLI configuration
* @returns {Promise<Object>} The configuration object or empty object if file missing
*/
export async function readClaudeConfig() {
const configPath = getClaudeConfigPath();
try {
const content = await fs.readFile(configPath, 'utf8');
if (!content.trim()) return { env: {} };
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn(`[ClaudeConfig] Config file not found at ${configPath}, returning empty default`);
return { env: {} };
}
if (error instanceof SyntaxError) {
logger.error(`[ClaudeConfig] Invalid JSON in config at ${configPath}. Returning safe default.`);
return { env: {} };
}
logger.error(`[ClaudeConfig] Failed to read config at ${configPath}:`, error.message);
throw error;
}
}
/**
* Update the global Claude CLI configuration
* Performs a deep merge with existing configuration to avoid losing other settings.
*
* @param {Object} updates - The partial configuration to merge in
* @returns {Promise<Object>} The updated full configuration
*/
export async function updateClaudeConfig(updates) {
const configPath = getClaudeConfigPath();
let currentConfig = {};
// 1. Read existing config
try {
currentConfig = await readClaudeConfig();
} catch (error) {
// Ignore ENOENT, otherwise rethrow
if (error.code !== 'ENOENT') throw error;
}
// 2. Deep merge updates
const newConfig = deepMerge(currentConfig, updates);
// 3. Ensure .claude directory exists
const configDir = path.dirname(configPath);
try {
await fs.mkdir(configDir, { recursive: true });
} catch (error) {
// Ignore if exists
}
// 4. Write back to file
try {
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
logger.info(`[ClaudeConfig] Updated config at ${configPath}`);
return newConfig;
} catch (error) {
logger.error(`[ClaudeConfig] Failed to write config:`, error.message);
throw error;
}
}
/**
* Simple deep merge for objects
*/
function deepMerge(target, source) {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}
function isObject(item) {
return (item && typeof item === 'object' && !Array.isArray(item));
}

View File

@@ -1,15 +1,18 @@
/** /**
* Logger Utility * Logger Utility
* *
* Provides structured logging with colors and debug support. * Provides structured logging with colors and debug support.
* Simple ANSI codes used to avoid dependencies. * Simple ANSI codes used to avoid dependencies.
*/ */
import { EventEmitter } from 'events';
import util from 'util';
const COLORS = { const COLORS = {
RESET: '\x1b[0m', RESET: '\x1b[0m',
BRIGHT: '\x1b[1m', BRIGHT: '\x1b[1m',
DIM: '\x1b[2m', DIM: '\x1b[2m',
RED: '\x1b[31m', RED: '\x1b[31m',
GREEN: '\x1b[32m', GREEN: '\x1b[32m',
YELLOW: '\x1b[33m', YELLOW: '\x1b[33m',
@@ -20,14 +23,17 @@ const COLORS = {
GRAY: '\x1b[90m' GRAY: '\x1b[90m'
}; };
class Logger { class Logger extends EventEmitter {
constructor() { constructor() {
super();
this.isDebugEnabled = false; this.isDebugEnabled = false;
this.history = [];
this.maxHistory = 1000;
} }
/** /**
* Set debug mode * Set debug mode
* @param {boolean} enabled * @param {boolean} enabled
*/ */
setDebug(enabled) { setDebug(enabled) {
this.isDebugEnabled = !!enabled; this.isDebugEnabled = !!enabled;
@@ -40,19 +46,44 @@ class Logger {
return new Date().toISOString(); return new Date().toISOString();
} }
/**
* Get log history
*/
getHistory() {
return this.history;
}
/** /**
* Format and print a log message * Format and print a log message
* @param {string} level * @param {string} level
* @param {string} color * @param {string} color
* @param {string} message * @param {string} message
* @param {...any} args * @param {...any} args
*/ */
print(level, color, message, ...args) { print(level, color, message, ...args) {
// Format: [TIMESTAMP] [LEVEL] Message // Format: [TIMESTAMP] [LEVEL] Message
const timestamp = `${COLORS.GRAY}[${this.getTimestamp()}]${COLORS.RESET}`; const timestampStr = this.getTimestamp();
const timestamp = `${COLORS.GRAY}[${timestampStr}]${COLORS.RESET}`;
const levelTag = `${color}[${level}]${COLORS.RESET}`; const levelTag = `${color}[${level}]${COLORS.RESET}`;
console.log(`${timestamp} ${levelTag} ${message}`, ...args); // Format the message with args similar to console.log
const formattedMessage = util.format(message, ...args);
console.log(`${timestamp} ${levelTag} ${formattedMessage}`);
// Store structured log
const logEntry = {
timestamp: timestampStr,
level,
message: formattedMessage
};
this.history.push(logEntry);
if (this.history.length > this.maxHistory) {
this.history.shift();
}
this.emit('log', logEntry);
} }
/** /**
@@ -98,7 +129,7 @@ class Logger {
log(message, ...args) { log(message, ...args) {
console.log(message, ...args); console.log(message, ...args);
} }
/** /**
* Print a section header * Print a section header
*/ */

161
src/utils/retry.js Normal file
View File

@@ -0,0 +1,161 @@
/**
* Retry Utilities with Exponential Backoff
*
* Provides retry logic with exponential backoff and jitter
* to prevent thundering herd and optimize API quota usage.
*/
import { sleep } from './helpers.js';
import { logger } from './logger.js';
/**
* Calculate exponential backoff delay with jitter
*
* @param {number} attempt - Current attempt number (0-based)
* @param {number} baseMs - Base delay in milliseconds
* @param {number} maxMs - Maximum delay in milliseconds
* @returns {number} Delay in milliseconds
*/
export function calculateBackoff(attempt, baseMs = 1000, maxMs = 30000) {
// Exponential: baseMs * 2^attempt
const exponential = baseMs * Math.pow(2, attempt);
// Cap at max
const capped = Math.min(exponential, maxMs);
// Add random jitter (±25%) to prevent thundering herd
const jitter = capped * 0.25 * (Math.random() * 2 - 1);
return Math.floor(capped + jitter);
}
/**
* Retry a function with exponential backoff
*
* @param {Function} fn - Async function to retry (receives attempt number)
* @param {Object} options - Retry options
* @param {number} options.maxAttempts - Maximum number of attempts (default: 5)
* @param {number} options.baseMs - Base delay in milliseconds (default: 1000)
* @param {number} options.maxMs - Maximum delay in milliseconds (default: 30000)
* @param {Function} options.shouldRetry - Function to determine if error is retryable
* @param {Function} options.onRetry - Callback before each retry (error, attempt, backoffMs)
* @returns {Promise<any>} Result from fn
* @throws {Error} Last error if all attempts fail
*/
export async function retryWithBackoff(fn, options = {}) {
const {
maxAttempts = 5,
baseMs = 1000,
maxMs = 30000,
shouldRetry = () => true,
onRetry = null
} = options;
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
// Check if this is the last attempt
if (attempt === maxAttempts - 1) {
logger.debug(`[Retry] All ${maxAttempts} attempts exhausted`);
throw error;
}
// Check if error is retryable
if (!shouldRetry(error, attempt)) {
logger.debug(`[Retry] Error not retryable, aborting: ${error.message}`);
throw error;
}
// Calculate backoff
const backoffMs = calculateBackoff(attempt, baseMs, maxMs);
logger.debug(`[Retry] Attempt ${attempt + 1}/${maxAttempts} failed, retrying in ${backoffMs}ms`);
// Call onRetry callback
if (onRetry) {
await onRetry(error, attempt, backoffMs);
}
// Wait before retrying
await sleep(backoffMs);
}
}
// Should never reach here, but just in case
throw lastError;
}
/**
* Check if an error is retryable (5xx errors or network issues)
*
* @param {Error} error - Error to check
* @returns {boolean} True if error is retryable
*/
export function isRetryableError(error) {
const message = error.message?.toLowerCase() || '';
// Network errors
if (message.includes('econnrefused') ||
message.includes('econnreset') ||
message.includes('etimedout') ||
message.includes('network') ||
message.includes('fetch failed')) {
return true;
}
// 5xx server errors
if (message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504')) {
return true;
}
// Rate limits (429) are retryable
if (message.includes('429') || message.includes('rate limit')) {
return true;
}
return false;
}
/**
* Check if an error is NOT retryable (4xx client errors except 429)
*
* @param {Error} error - Error to check
* @returns {boolean} True if error should not be retried
*/
export function isNonRetryableError(error) {
const message = error.message?.toLowerCase() || '';
// Authentication errors (401, 403)
if (message.includes('401') ||
message.includes('403') ||
message.includes('unauthorized') ||
message.includes('forbidden')) {
return true;
}
// Bad request (400)
if (message.includes('400') || message.includes('bad request')) {
return true;
}
// Not found (404)
if (message.includes('404') || message.includes('not found')) {
return true;
}
return false;
}
export default {
calculateBackoff,
retryWithBackoff,
isRetryableError,
isNonRetryableError
};

502
src/webui/index.js Normal file
View File

@@ -0,0 +1,502 @@
/**
* WebUI Module - Optional web interface for account management
*
* This module provides a web-based UI for:
* - Dashboard with real-time model quota visualization
* - Account management (add via OAuth, enable/disable, refresh, remove)
* - Live server log streaming with filtering
* - Claude CLI configuration editor
*
* Usage in server.js:
* import { mountWebUI } from './webui/index.js';
* mountWebUI(app, __dirname, accountManager);
*/
import path from 'path';
import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js';
import { DEFAULT_PORT } 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';
// OAuth state storage (state -> { verifier, timestamp })
const pendingOAuthStates = new Map();
/**
* Auth Middleware - Optional password protection for WebUI
* Password can be set via WEBUI_PASSWORD env var or config.json
*/
function createAuthMiddleware() {
return (req, res, next) => {
const password = config.webuiPassword;
if (!password) return next();
// Determine if this path should be protected
const isApiRoute = req.path.startsWith('/api/');
const isException = req.path === '/api/auth/url' || req.path === '/api/config';
const isProtected = (isApiRoute && !isException) || req.path === '/account-limits' || req.path === '/health';
if (isProtected) {
const providedPassword = req.headers['x-webui-password'] || req.query.password;
if (providedPassword !== password) {
return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' });
}
}
next();
};
}
/**
* Mount WebUI routes and middleware on Express app
* @param {Express} app - Express application instance
* @param {string} dirname - __dirname of the calling module (for static file path)
* @param {AccountManager} accountManager - Account manager instance
*/
export function mountWebUI(app, dirname, accountManager) {
// Apply auth middleware
app.use(createAuthMiddleware());
// Serve static files from public directory
app.use(express.static(path.join(dirname, '../public')));
// ==========================================
// Account Management API
// ==========================================
/**
* GET /api/accounts - List all accounts with status
*/
app.get('/api/accounts', async (req, res) => {
try {
const status = accountManager.getStatus();
res.json({
status: 'ok',
accounts: status.accounts,
summary: {
total: status.total,
available: status.available,
rateLimited: status.rateLimited,
invalid: status.invalid
}
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/accounts/:email/refresh - Refresh specific account token
*/
app.post('/api/accounts/:email/refresh', async (req, res) => {
try {
const { email } = req.params;
accountManager.clearTokenCache(email);
accountManager.clearProjectCache(email);
res.json({
status: 'ok',
message: `Token cache cleared for ${email}`
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/accounts/:email/toggle - Enable/disable account
*/
app.post('/api/accounts/:email/toggle', async (req, res) => {
try {
const { email } = req.params;
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
}
accountManager.setAccountEnabled(email, enabled);
res.json({
status: 'ok',
message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* DELETE /api/accounts/:email - Remove account
*/
app.delete('/api/accounts/:email', async (req, res) => {
try {
const { email } = req.params;
accountManager.removeAccount(email);
res.json({
status: 'ok',
message: `Account ${email} removed`
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/accounts/reload - Reload accounts from disk
*/
app.post('/api/accounts/reload', async (req, res) => {
try {
await accountManager.reloadAccounts();
const status = accountManager.getStatus();
res.json({
status: 'ok',
message: 'Accounts reloaded from disk',
summary: status.summary
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
// ==========================================
// Configuration API
// ==========================================
/**
* GET /api/config - Get server configuration
*/
app.get('/api/config', (req, res) => {
try {
const publicConfig = getPublicConfig();
res.json({
status: 'ok',
config: publicConfig,
note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values'
});
} catch (error) {
logger.error('[WebUI] Error getting config:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/config - Update server configuration
*/
app.post('/api/config', (req, res) => {
try {
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache } = req.body;
// Only allow updating specific fields (security)
const updates = {};
if (typeof debug === 'boolean') updates.debug = debug;
if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) {
updates.logLevel = logLevel;
}
if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) {
updates.maxRetries = maxRetries;
}
if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) {
updates.retryBaseMs = retryBaseMs;
}
if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) {
updates.retryMaxMs = retryMaxMs;
}
if (typeof persistTokenCache === 'boolean') {
updates.persistTokenCache = persistTokenCache;
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
status: 'error',
error: 'No valid configuration updates provided'
});
}
const success = saveConfig(updates);
if (success) {
res.json({
status: 'ok',
message: 'Configuration saved. Restart server to apply some changes.',
updates: updates,
config: getPublicConfig()
});
} else {
res.status(500).json({
status: 'error',
error: 'Failed to save configuration file'
});
}
} catch (error) {
logger.error('[WebUI] Error updating config:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* GET /api/settings - Get runtime settings
*/
app.get('/api/settings', async (req, res) => {
try {
const settings = accountManager.getSettings ? accountManager.getSettings() : {};
res.json({
status: 'ok',
settings: {
...settings,
port: process.env.PORT || DEFAULT_PORT
}
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
// ==========================================
// Claude CLI Configuration API
// ==========================================
/**
* GET /api/claude/config - Get Claude CLI configuration
*/
app.get('/api/claude/config', async (req, res) => {
try {
const claudeConfig = await readClaudeConfig();
res.json({
status: 'ok',
config: claudeConfig,
path: getClaudeConfigPath()
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/claude/config - Update Claude CLI configuration
*/
app.post('/api/claude/config', async (req, res) => {
try {
const updates = req.body;
if (!updates || typeof updates !== 'object') {
return res.status(400).json({ status: 'error', error: 'Invalid config updates' });
}
const newConfig = await updateClaudeConfig(updates);
res.json({
status: 'ok',
config: newConfig,
message: 'Claude configuration updated'
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/models/config - Update model configuration (hidden/pinned/alias)
*/
app.post('/api/models/config', (req, res) => {
try {
const { modelId, config: newModelConfig } = req.body;
if (!modelId || typeof newModelConfig !== 'object') {
return res.status(400).json({ status: 'error', error: 'Invalid parameters' });
}
// Load current config
const currentMapping = config.modelMapping || {};
// Update specific model config
currentMapping[modelId] = {
...currentMapping[modelId],
...newModelConfig
};
// Save back to main config
const success = saveConfig({ modelMapping: currentMapping });
if (success) {
// Update in-memory config reference
config.modelMapping = currentMapping;
res.json({ status: 'ok', modelConfig: currentMapping[modelId] });
} else {
throw new Error('Failed to save configuration');
}
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
// ==========================================
// Logs API
// ==========================================
/**
* GET /api/logs - Get log history
*/
app.get('/api/logs', (req, res) => {
res.json({
status: 'ok',
logs: logger.getHistory ? logger.getHistory() : []
});
});
/**
* GET /api/logs/stream - Stream logs via SSE
*/
app.get('/api/logs/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const sendLog = (log) => {
res.write(`data: ${JSON.stringify(log)}\n\n`);
};
// Send recent history if requested
if (req.query.history === 'true' && logger.getHistory) {
const history = logger.getHistory();
history.forEach(log => sendLog(log));
}
// Subscribe to new logs
if (logger.on) {
logger.on('log', sendLog);
}
// Cleanup on disconnect
req.on('close', () => {
if (logger.off) {
logger.off('log', sendLog);
}
});
});
// ==========================================
// OAuth API
// ==========================================
/**
* GET /api/auth/url - Get OAuth URL to start the flow
*/
app.get('/api/auth/url', (req, res) => {
try {
const { email } = req.query;
const { url, verifier, state } = getAuthorizationUrl(email);
// Store the verifier temporarily
pendingOAuthStates.set(state, { verifier, timestamp: Date.now() });
// Clean up old states (> 10 mins)
const now = Date.now();
for (const [key, val] of pendingOAuthStates.entries()) {
if (now - val.timestamp > 10 * 60 * 1000) {
pendingOAuthStates.delete(key);
}
}
res.json({ status: 'ok', url });
} catch (error) {
logger.error('[WebUI] Error generating auth URL:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* GET /oauth/callback - OAuth callback handler
*/
app.get('/oauth/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.status(400).send(`Authentication failed: ${error}`);
}
if (!code || !state) {
return res.status(400).send('Missing code or state parameter');
}
const storedState = pendingOAuthStates.get(state);
if (!storedState) {
return res.status(400).send('Invalid or expired state parameter. Please try again.');
}
// Remove used state
pendingOAuthStates.delete(state);
try {
const accountData = await completeOAuthFlow(code, storedState.verifier);
// Add or update the account
accountManager.addAccount({
email: accountData.email,
refreshToken: accountData.refreshToken,
projectId: accountData.projectId,
source: 'oauth'
});
// Return a simple HTML page that closes itself or redirects
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #09090b;
color: #e4e4e7;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
flex-direction: column;
}
h1 { color: #22c55e; }
</style>
</head>
<body>
<h1>Authentication Successful</h1>
<p>Account ${accountData.email} has been added.</p>
<p>You can close this window now.</p>
<script>
// Notify opener if opened via window.open
if (window.opener) {
window.opener.postMessage({ type: 'oauth-success', email: '${accountData.email}' }, '*');
setTimeout(() => window.close(), 2000);
} else {
// If redirected in same tab, redirect back to home after delay
setTimeout(() => window.location.href = '/', 3000);
}
</script>
</body>
</html>
`);
} catch (err) {
logger.error('[WebUI] OAuth callback error:', err);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #09090b;
color: #ef4444;
text-align: center;
padding: 50px;
}
</style>
</head>
<body>
<h1>Authentication Failed</h1>
<p>${err.message}</p>
</body>
</html>
`);
}
});
logger.info('[WebUI] Mounted at /');
}

View File

@@ -0,0 +1,217 @@
/**
* Frontend Test Suite - Accounts Page
* Tests the account manager component functionality
*
* Run: node tests/test-frontend-accounts.cjs
*/
const http = require('http');
const BASE_URL = 'http://localhost:8090';
function request(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const req = http.request(url, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({ status: res.statusCode, data, headers: res.headers });
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const tests = [
{
name: 'Accounts view loads successfully',
async run() {
const res = await request('/views/accounts.html');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
if (!res.data.includes('x-data="accountManager"')) {
throw new Error('AccountManager component not found');
}
return 'Accounts HTML loads with component';
}
},
{
name: 'Accounts API endpoint exists',
async run() {
const res = await request('/api/accounts');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.accounts || !Array.isArray(data.accounts)) {
throw new Error('accounts array not found in response');
}
if (!data.summary) {
throw new Error('summary object not found in response');
}
return `API returns ${data.accounts.length} accounts`;
}
},
{
name: 'Accounts view has table with required columns',
async run() {
const res = await request('/views/accounts.html');
const columns = ['enabled', 'identity', 'projectId', 'health', 'operations'];
const missing = columns.filter(col => !res.data.includes(col));
if (missing.length > 0) {
throw new Error(`Missing columns: ${missing.join(', ')}`);
}
return 'All table columns present';
}
},
{
name: 'Accounts view has toggle switch',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('toggleAccount')) {
throw new Error('Toggle account function not found');
}
if (!res.data.includes('acc.enabled')) {
throw new Error('Enabled state binding not found');
}
return 'Account toggle switch present';
}
},
{
name: 'Accounts view has refresh button',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('refreshAccount')) {
throw new Error('Refresh account function not found');
}
return 'Refresh button present';
}
},
{
name: 'Accounts view has delete button',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('deleteAccount')) {
throw new Error('Delete account function not found');
}
return 'Delete button present';
}
},
{
name: 'Accounts view has fix/re-auth button',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('fixAccount')) {
throw new Error('Fix account function not found');
}
return 'Fix/re-auth button present';
}
},
{
name: 'Accounts view has Add Node button',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('addNode') && !res.data.includes('add_account_modal')) {
throw new Error('Add account button not found');
}
return 'Add Node button present';
}
},
{
name: 'Account toggle API works',
async run() {
// First get an account
const accountsRes = await request('/api/accounts');
const accounts = JSON.parse(accountsRes.data).accounts;
if (accounts.length === 0) {
return 'Skipped: No accounts to test';
}
const email = accounts[0].email;
const currentEnabled = accounts[0].isInvalid !== true;
// Toggle the account (this is a real API call, be careful)
const toggleRes = await request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
method: 'POST',
body: { enabled: !currentEnabled }
});
if (toggleRes.status !== 200) {
throw new Error(`Toggle failed with status ${toggleRes.status}`);
}
// Toggle back to original state
await request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
method: 'POST',
body: { enabled: currentEnabled }
});
return `Toggle API works for ${email.split('@')[0]}`;
}
},
{
name: 'Account refresh API works',
async run() {
const accountsRes = await request('/api/accounts');
const accounts = JSON.parse(accountsRes.data).accounts;
if (accounts.length === 0) {
return 'Skipped: No accounts to test';
}
const email = accounts[0].email;
const refreshRes = await request(`/api/accounts/${encodeURIComponent(email)}/refresh`, {
method: 'POST'
});
if (refreshRes.status !== 200) {
throw new Error(`Refresh failed with status ${refreshRes.status}`);
}
return `Refresh API works for ${email.split('@')[0]}`;
}
}
];
async function runTests() {
console.log('🧪 Accounts Frontend Tests\n');
console.log('='.repeat(50));
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${test.name}`);
console.log(` ${result}\n`);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}\n`);
failed++;
}
}
console.log('='.repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test runner failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,85 @@
/**
* Frontend Test Runner
* Runs all frontend test suites
*
* Run: node tests/frontend/test-frontend-all.cjs
*/
const { execSync, spawn } = require('child_process');
const path = require('path');
const testFiles = [
'test-frontend-dashboard.cjs',
'test-frontend-logs.cjs',
'test-frontend-accounts.cjs',
'test-frontend-settings.cjs'
];
async function runTests() {
console.log('🚀 Running All Frontend Tests\n');
console.log('═'.repeat(60));
let totalPassed = 0;
let totalFailed = 0;
const results = [];
for (const testFile of testFiles) {
const testPath = path.join(__dirname, testFile);
console.log(`\n📋 Running: ${testFile}`);
console.log('─'.repeat(60));
try {
const output = execSync(`node "${testPath}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
console.log(output);
// Parse results from output
const match = output.match(/Results: (\d+) passed, (\d+) failed/);
if (match) {
const passed = parseInt(match[1]);
const failed = parseInt(match[2]);
totalPassed += passed;
totalFailed += failed;
results.push({ file: testFile, passed, failed, status: 'completed' });
}
} catch (error) {
console.log(error.stdout || '');
console.log(error.stderr || '');
// Try to parse results even on failure
const output = error.stdout || '';
const match = output.match(/Results: (\d+) passed, (\d+) failed/);
if (match) {
const passed = parseInt(match[1]);
const failed = parseInt(match[2]);
totalPassed += passed;
totalFailed += failed;
results.push({ file: testFile, passed, failed, status: 'completed with errors' });
} else {
results.push({ file: testFile, passed: 0, failed: 1, status: 'crashed' });
totalFailed++;
}
}
}
console.log('\n' + '═'.repeat(60));
console.log('📊 SUMMARY\n');
for (const result of results) {
const icon = result.failed === 0 ? '✅' : '❌';
console.log(`${icon} ${result.file}: ${result.passed} passed, ${result.failed} failed (${result.status})`);
}
console.log('\n' + '─'.repeat(60));
console.log(`Total: ${totalPassed} passed, ${totalFailed} failed`);
console.log('═'.repeat(60));
process.exit(totalFailed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test runner crashed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,160 @@
/**
* Frontend Test Suite - Dashboard Page
* Tests the dashboard component functionality
*
* Run: node tests/test-frontend-dashboard.cjs
*/
const http = require('http');
const BASE_URL = 'http://localhost:8090';
// Helper to make HTTP requests
function request(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const req = http.request(url, {
method: options.method || 'GET',
headers: options.headers || {}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({ status: res.statusCode, data, headers: res.headers });
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
// Test cases
const tests = [
{
name: 'Dashboard view loads successfully',
async run() {
const res = await request('/views/dashboard.html');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
if (!res.data.includes('x-data="dashboard"')) {
throw new Error('Dashboard component not found in HTML');
}
if (!res.data.includes('quotaChart')) {
throw new Error('Quota chart canvas not found');
}
return 'Dashboard HTML loads with component and chart';
}
},
{
name: 'Account limits API returns data',
async run() {
const res = await request('/account-limits');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.accounts || !Array.isArray(data.accounts)) {
throw new Error('accounts array not found in response');
}
if (!data.models || !Array.isArray(data.models)) {
throw new Error('models array not found in response');
}
return `API returns ${data.accounts.length} accounts and ${data.models.length} models`;
}
},
{
name: 'Dashboard has stats grid elements',
async run() {
const res = await request('/views/dashboard.html');
const html = res.data;
const requiredElements = [
'totalAccounts', // Total accounts stat
'stats.total', // Total stat binding
'stats.active', // Active stat binding
'stats.limited', // Limited stat binding
'quotaChart' // Chart canvas
];
const missing = requiredElements.filter(el => !html.includes(el));
if (missing.length > 0) {
throw new Error(`Missing elements: ${missing.join(', ')}`);
}
return 'All required dashboard elements present';
}
},
{
name: 'Dashboard has filter controls',
async run() {
const res = await request('/views/dashboard.html');
const html = res.data;
const filterElements = [
'filters.account', // Account filter
'filters.family', // Model family filter
'filters.search', // Search input
'computeQuotaRows' // Filter action
];
const missing = filterElements.filter(el => !html.includes(el));
if (missing.length > 0) {
throw new Error(`Missing filter elements: ${missing.join(', ')}`);
}
return 'All filter controls present';
}
},
{
name: 'Dashboard table has required columns',
async run() {
const res = await request('/views/dashboard.html');
const html = res.data;
const columns = [
'modelIdentity', // Model name column
'globalQuota', // Quota column
'nextReset', // Reset time column
'distribution' // Account distribution column
];
const missing = columns.filter(col => !html.includes(col));
if (missing.length > 0) {
throw new Error(`Missing table columns: ${missing.join(', ')}`);
}
return 'All table columns present';
}
}
];
// Run tests
async function runTests() {
console.log('🧪 Dashboard Frontend Tests\n');
console.log('='.repeat(50));
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${test.name}`);
console.log(` ${result}\n`);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}\n`);
failed++;
}
}
console.log('='.repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test runner failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,163 @@
/**
* Frontend Test Suite - Logs Page
* Tests the logs viewer component functionality
*
* Run: node tests/test-frontend-logs.cjs
*/
const http = require('http');
const BASE_URL = 'http://localhost:8090';
function request(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const req = http.request(url, {
method: options.method || 'GET',
headers: options.headers || {}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({ status: res.statusCode, data, headers: res.headers });
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const tests = [
{
name: 'Logs view loads successfully',
async run() {
const res = await request('/views/logs.html');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
if (!res.data.includes('x-data="logsViewer"')) {
throw new Error('LogsViewer component not found');
}
return 'Logs HTML loads with component';
}
},
{
name: 'Logs API endpoint exists',
async run() {
const res = await request('/api/logs');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.logs || !Array.isArray(data.logs)) {
throw new Error('logs array not found in response');
}
return `API returns ${data.logs.length} log entries`;
}
},
{
name: 'Logs SSE stream endpoint exists',
async run() {
return new Promise((resolve, reject) => {
const url = new URL('/api/logs/stream', BASE_URL);
const req = http.request(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`Expected 200, got ${res.statusCode}`));
return;
}
if (res.headers['content-type'] !== 'text/event-stream') {
reject(new Error(`Expected text/event-stream, got ${res.headers['content-type']}`));
return;
}
req.destroy(); // Close connection
resolve('SSE stream endpoint responds correctly');
});
req.on('error', reject);
req.end();
});
}
},
{
name: 'Logs view has auto-scroll toggle',
async run() {
const res = await request('/views/logs.html');
if (!res.data.includes('isAutoScroll')) {
throw new Error('Auto-scroll toggle not found');
}
if (!res.data.includes('autoScroll')) {
throw new Error('Auto-scroll translation key not found');
}
return 'Auto-scroll toggle present';
}
},
{
name: 'Logs view has clear logs button',
async run() {
const res = await request('/views/logs.html');
if (!res.data.includes('clearLogs')) {
throw new Error('Clear logs function not found');
}
return 'Clear logs button present';
}
},
{
name: 'Logs view has log container',
async run() {
const res = await request('/views/logs.html');
if (!res.data.includes('logs-container')) {
throw new Error('Logs container element not found');
}
if (!res.data.includes('x-for="(log, idx) in filteredLogs"')) {
throw new Error('Log iteration template not found');
}
return 'Log container and template present';
}
},
{
name: 'Logs view shows log levels with colors',
async run() {
const res = await request('/views/logs.html');
const levels = ['INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG'];
const colors = ['blue-400', 'yellow-400', 'red-500', 'neon-green', 'purple-400'];
for (const level of levels) {
if (!res.data.includes(`'${level}'`)) {
throw new Error(`Log level ${level} styling not found`);
}
}
return 'All log levels have color styling';
}
}
];
async function runTests() {
console.log('🧪 Logs Frontend Tests\n');
console.log('='.repeat(50));
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${test.name}`);
console.log(` ${result}\n`);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}\n`);
failed++;
}
}
console.log('='.repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test runner failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,348 @@
/**
* Frontend Test Suite - Settings Page
* Tests the settings and Claude configuration components
*
* Run: node tests/test-frontend-settings.cjs
*/
const http = require('http');
const BASE_URL = 'http://localhost:8090';
function request(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const req = http.request(url, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({ status: res.statusCode, data, headers: res.headers });
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const tests = [
// ==================== VIEW TESTS ====================
{
name: 'Settings view loads successfully',
async run() {
const res = await request('/views/settings.html');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
return 'Settings HTML loads successfully';
}
},
{
name: 'Settings view has UI preferences section',
async run() {
const res = await request('/views/settings.html');
const html = res.data;
const uiElements = [
'language', // Language selector
'refreshInterval', // Polling interval
'logLimit', // Log buffer size
'showExhausted', // Show exhausted models toggle
'compact' // Compact mode toggle
];
const missing = uiElements.filter(el => !html.includes(el));
if (missing.length > 0) {
throw new Error(`Missing UI elements: ${missing.join(', ')}`);
}
return 'All UI preference elements present';
}
},
{
name: 'Settings view has Claude CLI config section',
async run() {
const res = await request('/views/settings.html');
const html = res.data;
if (!html.includes('x-data="claudeConfig"')) {
throw new Error('ClaudeConfig component not found');
}
const claudeElements = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'ANTHROPIC_AUTH_TOKEN'
];
const missing = claudeElements.filter(el => !html.includes(el));
if (missing.length > 0) {
throw new Error(`Missing Claude config elements: ${missing.join(', ')}`);
}
return 'Claude CLI config section present';
}
},
{
name: 'Settings view has save buttons',
async run() {
const res = await request('/views/settings.html');
const html = res.data;
if (!html.includes('saveSettings')) {
throw new Error('Settings save function not found');
}
if (!html.includes('saveClaudeConfig')) {
throw new Error('Claude config save function not found');
}
return 'Save buttons present for both sections';
}
},
// ==================== API TESTS ====================
{
name: 'Server config API GET works',
async run() {
const res = await request('/api/config');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.config) {
throw new Error('config object not found in response');
}
return `Config API returns: debug=${data.config.debug}, logLevel=${data.config.logLevel}`;
}
},
{
name: 'Claude config API GET works',
async run() {
const res = await request('/api/claude/config');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.config) {
throw new Error('config object not found in response');
}
if (!data.path) {
throw new Error('config path not found in response');
}
return `Claude config loaded from: ${data.path}`;
}
},
{
name: 'Claude config has env section',
async run() {
const res = await request('/api/claude/config');
const data = JSON.parse(res.data);
if (!data.config.env) {
throw new Error('env section not found in config');
}
const envKeys = Object.keys(data.config.env);
return `Config has ${envKeys.length} env vars: ${envKeys.slice(0, 3).join(', ')}${envKeys.length > 3 ? '...' : ''}`;
}
},
{
name: 'Claude config API POST works (read-back test)',
async run() {
// First, read current config
const getRes = await request('/api/claude/config');
const originalConfig = JSON.parse(getRes.data).config;
// POST the same config back (safe operation)
const postRes = await request('/api/claude/config', {
method: 'POST',
body: originalConfig
});
if (postRes.status !== 200) {
throw new Error(`POST failed with status ${postRes.status}`);
}
const postData = JSON.parse(postRes.data);
if (postData.status !== 'ok') {
throw new Error(`POST returned error: ${postData.error}`);
}
return 'Claude config POST API works (config preserved)';
}
},
{
name: 'Server config API POST validates input',
async run() {
// Test with invalid logLevel
const res = await request('/api/config', {
method: 'POST',
body: { logLevel: 'invalid_level' }
});
if (res.status === 200) {
const data = JSON.parse(res.data);
// Check if the invalid value was rejected
if (data.updates && data.updates.logLevel === 'invalid_level') {
throw new Error('Invalid logLevel was accepted');
}
}
return 'Config API properly validates logLevel input';
}
},
{
name: 'Server config accepts valid debug value',
async run() {
// Get current config
const getRes = await request('/api/config');
const currentDebug = JSON.parse(getRes.data).config.debug;
// Toggle debug
const postRes = await request('/api/config', {
method: 'POST',
body: { debug: !currentDebug }
});
if (postRes.status !== 200) {
throw new Error(`POST failed with status ${postRes.status}`);
}
// Restore original value
await request('/api/config', {
method: 'POST',
body: { debug: currentDebug }
});
return 'Config API accepts valid debug boolean';
}
},
// ==================== SETTINGS STORE TESTS ====================
{
name: 'Settings API returns server port',
async run() {
const res = await request('/api/settings');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.settings || !data.settings.port) {
throw new Error('port not found in settings');
}
return `Server port: ${data.settings.port}`;
}
},
// ==================== INTEGRATION TESTS ====================
{
name: 'All views are accessible',
async run() {
const views = ['dashboard', 'logs', 'accounts', 'settings'];
const results = [];
for (const view of views) {
const res = await request(`/views/${view}.html`);
if (res.status !== 200) {
throw new Error(`${view} view returned ${res.status}`);
}
results.push(`${view}: OK`);
}
return results.join(', ');
}
},
{
name: 'All component JS files load',
async run() {
const components = [
'js/components/dashboard.js',
'js/components/account-manager.js',
'js/components/claude-config.js',
'js/components/logs-viewer.js'
];
for (const comp of components) {
const res = await request(`/${comp}`);
if (res.status !== 200) {
throw new Error(`${comp} returned ${res.status}`);
}
if (!res.data.includes('window.Components')) {
throw new Error(`${comp} doesn't register to window.Components`);
}
}
return 'All component files load and register correctly';
}
},
{
name: 'All store JS files load',
async run() {
const stores = [
'js/store.js',
'js/data-store.js',
'js/settings-store.js',
'js/utils.js'
];
for (const store of stores) {
const res = await request(`/${store}`);
if (res.status !== 200) {
throw new Error(`${store} returned ${res.status}`);
}
}
return 'All store files load correctly';
}
},
{
name: 'Main app.js loads',
async run() {
const res = await request('/app.js');
if (res.status !== 200) {
throw new Error(`app.js returned ${res.status}`);
}
if (!res.data.includes('alpine:init')) {
throw new Error('app.js missing alpine:init listener');
}
if (!res.data.includes('load-view')) {
throw new Error('app.js missing load-view directive');
}
return 'app.js loads with all required components';
}
}
];
async function runTests() {
console.log('🧪 Settings Frontend Tests\n');
console.log('='.repeat(50));
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${test.name}`);
console.log(` ${result}\n`);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}\n`);
failed++;
}
}
console.log('='.repeat(50));
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test runner failed:', err);
process.exit(1);
});