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:
52
config.example.json
Normal file
52
config.example.json
Normal 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
BIN
images/webui-accounts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
images/webui-dashboard.png
Normal file
BIN
images/webui-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
BIN
images/webui-logs.png
Normal file
BIN
images/webui-logs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
BIN
images/webui-settings.png
Normal file
BIN
images/webui-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
@@ -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
130
public/app.js
Normal 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
92
public/css/style.css
Normal 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
291
public/index.html
Normal 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
106
public/js/app-init.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
104
public/js/components/account-manager.js
Normal file
104
public/js/components/account-manager.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
54
public/js/components/claude-config.js
Normal file
54
public/js/components/claude-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
122
public/js/components/dashboard.js
Normal file
122
public/js/components/dashboard.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
87
public/js/components/logs-viewer.js
Normal file
87
public/js/components/logs-viewer.js
Normal 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
169
public/js/data-store.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Data Store
|
||||||
|
* Holds Accounts, Models, and Computed Quota Rows
|
||||||
|
* Shared between Dashboard and AccountManager
|
||||||
|
*/
|
||||||
|
|
||||||
|
// utils is loaded globally as window.utils in utils.js
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.store('data', {
|
||||||
|
accounts: [],
|
||||||
|
models: [], // Source of truth
|
||||||
|
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||||
|
quotaRows: [], // Filtered view
|
||||||
|
loading: false,
|
||||||
|
connectionStatus: 'connecting',
|
||||||
|
lastUpdated: '-',
|
||||||
|
|
||||||
|
// Filters state
|
||||||
|
filters: {
|
||||||
|
account: 'all',
|
||||||
|
family: 'all',
|
||||||
|
search: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings for calculation
|
||||||
|
// We need to access global settings? Or duplicate?
|
||||||
|
// Let's assume settings are passed or in another store.
|
||||||
|
// For simplicity, let's keep relevant filters here.
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Watch filters to recompute
|
||||||
|
// Alpine stores don't have $watch automatically unless inside a component?
|
||||||
|
// We can manually call compute when filters change.
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
// Get password from global store
|
||||||
|
const password = Alpine.store('global').webuiPassword;
|
||||||
|
const { response, newPassword } = await window.utils.request('/account-limits', {}, password);
|
||||||
|
|
||||||
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.accounts = data.accounts || [];
|
||||||
|
if (data.models && data.models.length > 0) {
|
||||||
|
this.models = data.models;
|
||||||
|
}
|
||||||
|
this.modelConfig = data.modelConfig || {};
|
||||||
|
|
||||||
|
this.computeQuotaRows();
|
||||||
|
|
||||||
|
this.connectionStatus = 'connected';
|
||||||
|
this.lastUpdated = new Date().toLocaleTimeString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
this.connectionStatus = 'disconnected';
|
||||||
|
Alpine.store('global').showToast('Connection Lost', 'error');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computeQuotaRows() {
|
||||||
|
const models = this.models || [];
|
||||||
|
const rows = [];
|
||||||
|
const showExhausted = Alpine.store('settings')?.showExhausted ?? true; // Need settings store
|
||||||
|
// Temporary debug flag or settings flag to show hidden models
|
||||||
|
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
||||||
|
|
||||||
|
models.forEach(modelId => {
|
||||||
|
// Config
|
||||||
|
const config = this.modelConfig[modelId] || {};
|
||||||
|
const family = this.getModelFamily(modelId);
|
||||||
|
|
||||||
|
// Smart Visibility Logic:
|
||||||
|
// 1. If explicit config exists, use it.
|
||||||
|
// 2. If no config, default 'unknown' families to HIDDEN to prevent clutter.
|
||||||
|
// 3. Known families (Claude/Gemini) default to VISIBLE.
|
||||||
|
let isHidden = config.hidden;
|
||||||
|
if (isHidden === undefined) {
|
||||||
|
isHidden = (family === 'other' || family === 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden models unless "Show Hidden" is enabled
|
||||||
|
if (isHidden && !showHidden) return;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if (this.filters.family !== 'all' && this.filters.family !== family) return;
|
||||||
|
if (this.filters.search) {
|
||||||
|
const searchLower = this.filters.search.toLowerCase();
|
||||||
|
const aliasMatch = config.alias && config.alias.toLowerCase().includes(searchLower);
|
||||||
|
const idMatch = modelId.toLowerCase().includes(searchLower);
|
||||||
|
if (!aliasMatch && !idMatch) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Collection
|
||||||
|
const quotaInfo = [];
|
||||||
|
let minQuota = 100;
|
||||||
|
let totalQuotaSum = 0;
|
||||||
|
let validAccountCount = 0;
|
||||||
|
let minResetTime = null;
|
||||||
|
|
||||||
|
this.accounts.forEach(acc => {
|
||||||
|
if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
|
||||||
|
|
||||||
|
const limit = acc.limits?.[modelId];
|
||||||
|
if (!limit) return;
|
||||||
|
|
||||||
|
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
||||||
|
minQuota = Math.min(minQuota, pct);
|
||||||
|
|
||||||
|
// Accumulate for average
|
||||||
|
totalQuotaSum += pct;
|
||||||
|
validAccountCount++;
|
||||||
|
|
||||||
|
if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
|
||||||
|
minResetTime = limit.resetTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaInfo.push({
|
||||||
|
email: acc.email.split('@')[0],
|
||||||
|
fullEmail: acc.email,
|
||||||
|
pct: pct,
|
||||||
|
resetTime: limit.resetTime
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (quotaInfo.length === 0) return;
|
||||||
|
const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
|
||||||
|
|
||||||
|
if (!showExhausted && minQuota === 0) return;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
modelId,
|
||||||
|
displayName: config.alias || modelId, // Use alias if available
|
||||||
|
family,
|
||||||
|
minQuota,
|
||||||
|
avgQuota, // Added Average Quota
|
||||||
|
minResetTime,
|
||||||
|
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
||||||
|
quotaInfo,
|
||||||
|
pinned: !!config.pinned,
|
||||||
|
hidden: !!isHidden // Use computed visibility
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: Pinned first, then by avgQuota (descending)
|
||||||
|
this.quotaRows = rows.sort((a, b) => {
|
||||||
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
|
return b.avgQuota - a.avgQuota;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger Dashboard Update if active
|
||||||
|
// Ideally dashboard watches this store.
|
||||||
|
},
|
||||||
|
|
||||||
|
getModelFamily(modelId) {
|
||||||
|
const lower = modelId.toLowerCase();
|
||||||
|
if (lower.includes('claude')) return 'claude';
|
||||||
|
if (lower.includes('gemini')) return 'gemini';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
57
public/js/settings-store.js
Normal file
57
public/js/settings-store.js
Normal 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
222
public/js/store.js
Normal 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
41
public/js/utils.js
Normal 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`;
|
||||||
|
}
|
||||||
|
};
|
||||||
80
public/views/accounts.html
Normal file
80
public/views/accounts.html
Normal 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
221
public/views/dashboard.html
Normal 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
85
public/views/logs.html
Normal 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
519
public/views/settings.html
Normal 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
84
src/config.js
Normal 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 };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
111
src/utils/claude-config.js
Normal 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));
|
||||||
|
}
|
||||||
@@ -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
161
src/utils/retry.js
Normal 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
502
src/webui/index.js
Normal 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 /');
|
||||||
|
}
|
||||||
217
tests/frontend/test-frontend-accounts.cjs
Normal file
217
tests/frontend/test-frontend-accounts.cjs
Normal 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);
|
||||||
|
});
|
||||||
85
tests/frontend/test-frontend-all.cjs
Normal file
85
tests/frontend/test-frontend-all.cjs
Normal 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);
|
||||||
|
});
|
||||||
160
tests/frontend/test-frontend-dashboard.cjs
Normal file
160
tests/frontend/test-frontend-dashboard.cjs
Normal 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);
|
||||||
|
});
|
||||||
163
tests/frontend/test-frontend-logs.cjs
Normal file
163
tests/frontend/test-frontend-logs.cjs
Normal 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);
|
||||||
|
});
|
||||||
348
tests/frontend/test-frontend-settings.cjs
Normal file
348
tests/frontend/test-frontend-settings.cjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user