diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..e49cc73 --- /dev/null +++ b/config.example.json @@ -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\\\\.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 + } + } +} diff --git a/images/webui-accounts.png b/images/webui-accounts.png new file mode 100644 index 0000000..38647f8 Binary files /dev/null and b/images/webui-accounts.png differ diff --git a/images/webui-dashboard.png b/images/webui-dashboard.png new file mode 100644 index 0000000..72adf17 Binary files /dev/null and b/images/webui-dashboard.png differ diff --git a/images/webui-logs.png b/images/webui-logs.png new file mode 100644 index 0000000..49cc74a Binary files /dev/null and b/images/webui-logs.png differ diff --git a/images/webui-settings.png b/images/webui-settings.png new file mode 100644 index 0000000..9d3eef2 Binary files /dev/null and b/images/webui-settings.png differ diff --git a/package.json b/package.json index 365945e..eca1171 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "src/index.js", "type": "module", @@ -9,7 +9,8 @@ }, "files": [ "src", - "bin" + "bin", + "public" ], "scripts": { "start": "node src/index.js", @@ -49,8 +50,9 @@ "node": ">=18.0.0" }, "dependencies": { + "async-mutex": "^0.5.0", "better-sqlite3": "^12.5.0", "cors": "^2.8.5", "express": "^4.18.2" } -} +} \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..e415e56 --- /dev/null +++ b/public/app.js @@ -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 = `
+ Error loading view: ${viewName}
+ ${err.message} +
`; + }); + }); + + // 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'); + } + } + })); +}); \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..43b6c9b --- /dev/null +++ b/public/css/style.css @@ -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; +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..45de096 --- /dev/null +++ b/public/index.html @@ -0,0 +1,291 @@ + + + + + + + Antigravity Console + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
+ AG
+
+ ANTIGRAVITY + CLAUDE PROXY SYSTEM +
+
+ +
+ +
+
+
+ +
+ +
+ + + +
+
+ + +
+ + +
+
Main
+ + +
System
+ + + +
+
+ V 1.0.0 + GitHub +
+
+
+ + +
+ + + +
+ + +
+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/app-init.js b/public/js/app-init.js new file mode 100644 index 0000000..c93a32d --- /dev/null +++ b/public/js/app-init.js @@ -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'); + } + } + })); +}); diff --git a/public/js/components/account-manager.js b/public/js/components/account-manager.js new file mode 100644 index 0000000..c5a677a --- /dev/null +++ b/public/js/components/account-manager.js @@ -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'); + } + } +}); diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js new file mode 100644 index 0000000..85c15be --- /dev/null +++ b/public/js/components/claude-config.js @@ -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; + } + } +}); diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js new file mode 100644 index 0000000..361ee38 --- /dev/null +++ b/public/js/components/dashboard.js @@ -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 + } + } + }); + } +}); diff --git a/public/js/components/logs-viewer.js b/public/js/components/logs-viewer.js new file mode 100644 index 0000000..403968c --- /dev/null +++ b/public/js/components/logs-viewer.js @@ -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 = []; + } +}); diff --git a/public/js/data-store.js b/public/js/data-store.js new file mode 100644 index 0000000..d084122 --- /dev/null +++ b/public/js/data-store.js @@ -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'; + } + }); +}); diff --git a/public/js/settings-store.js b/public/js/settings-store.js new file mode 100644 index 0000000..5d895e8 --- /dev/null +++ b/public/js/settings-store.js @@ -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(); + } + } + }); +}); diff --git a/public/js/store.js b/public/js/store.js new file mode 100644 index 0000000..72b008a --- /dev/null +++ b/public/js/store.js @@ -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); + } + }); +}); diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..e3098e7 --- /dev/null +++ b/public/js/utils.js @@ -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`; + } +}; diff --git a/public/views/accounts.html b/public/views/accounts.html new file mode 100644 index 0000000..2743554 --- /dev/null +++ b/public/views/accounts.html @@ -0,0 +1,80 @@ +
+
+
+

+ Access + Credentials

+

Manage OAuth tokens + and session + states

+
+ +
+ +
+ + + + + + + + + + + + + +
EnabledIdentity (Email)Project IDHealthOperations
+
+
\ No newline at end of file diff --git a/public/views/dashboard.html b/public/views/dashboard.html new file mode 100644 index 0000000..070c02d --- /dev/null +++ b/public/views/dashboard.html @@ -0,0 +1,221 @@ +
+ +
+
+
+ + + + +
+
+
+
Registered Nodes
+
+ +
+
+ + + +
+
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + +
+ +
+ +
+
%
+
+
+ + +
+
+ Global Quota + +
+ + +
+
+
+
+ Claude +
+ +
+
+
+
+ Gemini +
+
+
+
+ + +
+
+
+ + +
+
+ +
+ +
+ + + +
+
+ + +
+ + + +
+
+ + +
+
+ + + +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
StatModel IdentityGlobal QuotaNext ResetAccount + Distribution
+
+ + ESTABLISHING + UPLINK... +
+
+ NO SIGNAL DETECTED +
+
+
diff --git a/public/views/logs.html b/public/views/logs.html new file mode 100644 index 0000000..dca9c37 --- /dev/null +++ b/public/views/logs.html @@ -0,0 +1,85 @@ +
+ +
+ + +
+
+
+
+
+
+ +
+ + +
+ +
+
+ + + +
+ +
+ + + +
+ + +
+ + + +
+
+ + +
+ + +
+
+ No logs match filter +
+
+
diff --git a/public/views/settings.html b/public/views/settings.html new file mode 100644 index 0000000..2b0e1e6 --- /dev/null +++ b/public/views/settings.html @@ -0,0 +1,519 @@ +
+ +
+
+

+ + + + + System Configuration +

+
+ +
+ + + + +
+
+ + +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ + +
+ 10s + 300s +
+
+ + +
+ + +
+ 500 + 5000 +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply. +
+ + +
+ +
+
+
ANTHROPIC_BASE_URL
+ +
+
+
ANTHROPIC_AUTH_TOKEN
+ +
+
+
+ + +
+ + +
+ +
+ +
+ +
+ + +
+ ANTHROPIC_MODEL +
+ + +
+ +
+ +
+ + +
+ CLAUDE_CODE_SUBAGENT_MODEL +
+
+ +
ALIAS OVERRIDES
+ + +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ +
+ +
+
+ + +
+
+
Manage visibility and ordering of models in the dashboard.
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + +
Model IDAliasActions
+ NO MODELS DETECTED +
+
+
+ + +
+
+
+
Port
+
+
+
+
UI Version
+
+
+
+
Debug Mode
+
Enabled (See Logs)
+
+
+
Environment
+
Production
+
+
+ +
+ +
+ Server settings are read-only. Modify config.json or .env and restart the server to change. +
+
+ + +
+

Danger Zone / Advanced

+
+
+
Reload Account Config
+
Force reload accounts.json from disk
+
+ +
+
+
+ +
+
\ No newline at end of file diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..6563a7a --- /dev/null +++ b/src/config.js @@ -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 }; \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index 06f9af7..ad3ee3d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,6 +5,7 @@ import { homedir, platform, arch } from 'os'; import { join } from 'path'; +import { config } from './config.js'; /** * 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 export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; -export const TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes -export const REQUEST_BODY_LIMIT = '50mb'; +// Configurable constants - values from config.json take precedence +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 DEFAULT_PORT = 8080; +export const DEFAULT_PORT = config?.port || 8080; // Multi-account configuration -export const ACCOUNT_CONFIG_PATH = join( +export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join( homedir(), '.config/antigravity-proxy/accounts.json' ); @@ -74,12 +76,12 @@ export const ACCOUNT_CONFIG_PATH = join( // Uses platform-specific path detection export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath(); -export const DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown -export const MAX_RETRIES = 5; // Max retry attempts across accounts -export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed +export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute +export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5 +export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10 // 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 export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length diff --git a/src/server.js b/src/server.js index 587b393..a653c88 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,13 @@ import express from 'express'; import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; 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 { REQUEST_BODY_LIMIT } from './constants.js'; import { AccountManager } from './account-manager/index.js'; @@ -57,6 +63,9 @@ async function ensureInitialized() { app.use(cors()); 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 */ diff --git a/src/utils/claude-config.js b/src/utils/claude-config.js new file mode 100644 index 0000000..f7986e2 --- /dev/null +++ b/src/utils/claude-config.js @@ -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} 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} 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)); +} diff --git a/src/utils/logger.js b/src/utils/logger.js index 5d475bd..c4eec2a 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,15 +1,18 @@ /** * Logger Utility - * + * * Provides structured logging with colors and debug support. * Simple ANSI codes used to avoid dependencies. */ +import { EventEmitter } from 'events'; +import util from 'util'; + const COLORS = { RESET: '\x1b[0m', BRIGHT: '\x1b[1m', DIM: '\x1b[2m', - + RED: '\x1b[31m', GREEN: '\x1b[32m', YELLOW: '\x1b[33m', @@ -20,14 +23,17 @@ const COLORS = { GRAY: '\x1b[90m' }; -class Logger { +class Logger extends EventEmitter { constructor() { + super(); this.isDebugEnabled = false; + this.history = []; + this.maxHistory = 1000; } /** * Set debug mode - * @param {boolean} enabled + * @param {boolean} enabled */ setDebug(enabled) { this.isDebugEnabled = !!enabled; @@ -40,19 +46,44 @@ class Logger { return new Date().toISOString(); } + /** + * Get log history + */ + getHistory() { + return this.history; + } + /** * Format and print a log message - * @param {string} level - * @param {string} color - * @param {string} message - * @param {...any} args + * @param {string} level + * @param {string} color + * @param {string} message + * @param {...any} args */ print(level, color, message, ...args) { // 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}`; - - 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) { console.log(message, ...args); } - + /** * Print a section header */ diff --git a/src/utils/retry.js b/src/utils/retry.js new file mode 100644 index 0000000..bbdb30f --- /dev/null +++ b/src/utils/retry.js @@ -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} 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 +}; diff --git a/src/webui/index.js b/src/webui/index.js new file mode 100644 index 0000000..d59d9e1 --- /dev/null +++ b/src/webui/index.js @@ -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(` + + + + Authentication Successful + + + +

Authentication Successful

+

Account ${accountData.email} has been added.

+

You can close this window now.

+ + + + `); + } catch (err) { + logger.error('[WebUI] OAuth callback error:', err); + res.status(500).send(` + + + + Authentication Failed + + + +

Authentication Failed

+

${err.message}

+ + + `); + } + }); + + logger.info('[WebUI] Mounted at /'); +} diff --git a/tests/frontend/test-frontend-accounts.cjs b/tests/frontend/test-frontend-accounts.cjs new file mode 100644 index 0000000..7e2f1b3 --- /dev/null +++ b/tests/frontend/test-frontend-accounts.cjs @@ -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); +}); diff --git a/tests/frontend/test-frontend-all.cjs b/tests/frontend/test-frontend-all.cjs new file mode 100644 index 0000000..64baf80 --- /dev/null +++ b/tests/frontend/test-frontend-all.cjs @@ -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); +}); diff --git a/tests/frontend/test-frontend-dashboard.cjs b/tests/frontend/test-frontend-dashboard.cjs new file mode 100644 index 0000000..f33c09a --- /dev/null +++ b/tests/frontend/test-frontend-dashboard.cjs @@ -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); +}); diff --git a/tests/frontend/test-frontend-logs.cjs b/tests/frontend/test-frontend-logs.cjs new file mode 100644 index 0000000..3594529 --- /dev/null +++ b/tests/frontend/test-frontend-logs.cjs @@ -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); +}); diff --git a/tests/frontend/test-frontend-settings.cjs b/tests/frontend/test-frontend-settings.cjs new file mode 100644 index 0000000..036ae12 --- /dev/null +++ b/tests/frontend/test-frontend-settings.cjs @@ -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); +});