diff --git a/.gitignore b/.gitignore index de07adf..5d5848d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ log.txt # Local config (may contain tokens) .claude/ +.deepvcode/ + +# Runtime data +data/ # Test artifacts tests/utils/*.png diff --git a/package-lock.json b/package-lock.json index f40d1eb..2a68883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "antigravity-claude-proxy", - "version": "1.0.2", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antigravity-claude-proxy", - "version": "1.0.2", + "version": "1.2.6", "license": "MIT", "dependencies": { + "async-mutex": "^0.5.0", "better-sqlite3": "^12.5.0", "cors": "^2.8.5", "express": "^4.18.2" @@ -39,6 +40,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1304,6 +1314,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/public/app.js b/public/app.js index e415e56..0159708 100644 --- a/public/app.js +++ b/public/app.js @@ -63,8 +63,8 @@ document.addEventListener('alpine:init', () => { // Chart Defaults if (typeof Chart !== 'undefined') { - Chart.defaults.color = '#71717a'; - Chart.defaults.borderColor = '#27272a'; + Chart.defaults.color = window.utils.getThemeColor('--color-text-dim'); + Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border'); Chart.defaults.font.family = '"JetBrains Mono", monospace'; } @@ -111,8 +111,12 @@ document.addEventListener('alpine:init', () => { 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'); + const actionKey = reAuthEmail ? 'reauthenticated' : 'added'; + const action = Alpine.store('global').t(actionKey); + const successfully = Alpine.store('global').t('successfully'); + const msg = `${Alpine.store('global').t('accounts')} ${event.data.email} ${action} ${successfully}`; + + Alpine.store('global').showToast(msg, 'success'); Alpine.store('data').fetchData(); document.getElementById('add_account_modal')?.close(); } @@ -120,10 +124,10 @@ document.addEventListener('alpine:init', () => { window.addEventListener('message', messageHandler); setTimeout(() => window.removeEventListener('message', messageHandler), 300000); } else { - Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error'); + Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Failed to start OAuth flow: ' + e.message, 'error'); + Alpine.store('global').showToast(Alpine.store('global').t('failedToStartOAuth') + ': ' + e.message, 'error'); } } })); diff --git a/public/css/style.css b/public/css/style.css index 43b6c9b..ca27aea 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,3 +1,43 @@ +:root { + --color-space-950: #050505; + --color-space-900: #0a0a0a; + --color-space-850: #121212; + --color-space-800: #171717; + --color-space-border: #27272a; + --color-neon-purple: #a855f7; + --color-neon-green: #22c55e; + --color-neon-cyan: #06b6d4; + --color-neon-yellow: #eab308; + --color-neon-red: #ef4444; + --color-text-main: #d1d5db; /* gray-300 */ + --color-text-dim: #71717a; /* zinc-400 */ + --color-text-muted: #6b7280; /* gray-500 */ + --color-text-bright: #ffffff; + + /* Gradient Accents */ + --color-green-400: #4ade80; + --color-yellow-400: #facc15; + --color-red-400: #f87171; + + /* Chart Colors */ + --color-chart-1: #a855f7; + --color-chart-2: #c084fc; + --color-chart-3: #e879f9; + --color-chart-4: #d946ef; + --color-chart-5: #22c55e; + --color-chart-6: #4ade80; + --color-chart-7: #86efac; + --color-chart-8: #10b981; + --color-chart-9: #06b6d4; + --color-chart-10: #f59e0b; + --color-chart-11: #ef4444; + --color-chart-12: #ec4899; + --color-chart-13: #8b5cf6; + --color-chart-14: #14b8a6; + --color-chart-15: #f97316; + --color-chart-16: #6366f1; +} + [x-cloak] { display: none !important; } @@ -13,12 +53,11 @@ } ::-webkit-scrollbar-thumb { - background: #3f3f46; - border-radius: 3px; + @apply bg-space-800 rounded-full; } ::-webkit-scrollbar-thumb:hover { - background: #52525b; + @apply bg-space-border; } /* Animations */ @@ -43,32 +82,30 @@ /* Utility */ .glass-panel { - background: rgba(23, 23, 23, 0.7); + background: theme('colors.space.900 / 70%'); backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid theme('colors.white / 8%'); } .nav-item.active { - background: linear-gradient(90deg, rgba(168, 85, 247, 0.15) 0%, transparent 100%); - border-left: 3px solid #a855f7; - color: #fff; + background: linear-gradient(90deg, theme('colors.neon.purple / 15%') 0%, transparent 100%); + @apply border-l-4 border-neon-purple text-white; } .nav-item { - border-left: 3px solid transparent; - transition: all 0.2s; + @apply border-l-4 border-transparent transition-all duration-200; } .progress-gradient-success::-webkit-progress-value { - background-image: linear-gradient(to right, #22c55e, #4ade80); + background-image: linear-gradient(to right, var(--color-neon-green), var(--color-green-400)); } .progress-gradient-warning::-webkit-progress-value { - background-image: linear-gradient(to right, #eab308, #facc15); + background-image: linear-gradient(to right, var(--color-neon-yellow), var(--color-yellow-400)); } .progress-gradient-error::-webkit-progress-value { - background-image: linear-gradient(to right, #dc2626, #f87171); + background-image: linear-gradient(to right, var(--color-neon-red), var(--color-red-400)); } /* Dashboard Grid */ @@ -80,13 +117,40 @@ /* 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; + @apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs; } .tooltip-left:before { margin-right: 0.5rem; +} + +/* -------------------------------------------------------------------------- */ +/* Refactored Global Utilities */ +/* -------------------------------------------------------------------------- */ + +/* View Containers */ +.view-container { + @apply max-w-7xl mx-auto p-6 space-y-6 animate-fade-in; +} + +/* Section Headers */ +.section-header { + @apply flex justify-between items-center mb-6; +} +.section-title { + @apply text-2xl font-bold text-white tracking-tight; +} +.section-desc { + @apply text-gray-500 text-sm; +} + +/* Component Unification */ +.standard-table { + @apply table w-full border-separate border-spacing-0; +} +.standard-table thead { + @apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border; +} +.standard-table tbody tr { + @apply hover:bg-white/5 transition-colors border-b border-space-border/30 last:border-0; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 45de096..abe47d9 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + @@ -26,18 +26,36 @@ colors: { // Deep Space Palette space: { - 950: '#050505', // Almost black - 900: '#0a0a0a', - 800: '#171717', - border: '#27272a' + 950: 'var(--color-space-950)', // Deep background + 900: 'var(--color-space-900)', // Panel background + 850: 'var(--color-space-850)', // Hover states + 800: 'var(--color-space-800)', // UI elements + border: 'var(--color-space-border)' }, neon: { - purple: '#a855f7', - cyan: '#06b6d4', - green: '#22c55e' + purple: 'var(--color-neon-purple)', + cyan: 'var(--color-neon-cyan)', + green: 'var(--color-neon-green)', + yellow: 'var(--color-neon-yellow)', + red: 'var(--color-neon-red)' } } } + }, + daisyui: { + themes: [{ + antigravity: { + "primary": "var(--color-neon-purple)", // Neon Purple + "secondary": "var(--color-neon-green)", // Neon Green + "accent": "var(--color-neon-cyan)", // Neon Cyan + "neutral": "var(--color-space-800)", // space-800 + "base-100": "var(--color-space-950)", // space-950 + "info": "var(--color-neon-cyan)", + "success": "var(--color-neon-green)", + "warning": "var(--color-neon-yellow)", + "error": "var(--color-neon-red)", + } + }] } } @@ -96,8 +114,8 @@ class="w-8 h-8 rounded bg-gradient-to-br from-neon-purple to-blue-600 flex items-center justify-center text-white font-bold shadow-[0_0_15px_rgba(168,85,247,0.4)]"> AG
- ANTIGRAVITY - CLAUDE PROXY SYSTEM + ANTIGRAVITY + CLAUDE PROXY SYSTEM
@@ -111,14 +129,14 @@ :class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')"> + x-text="$store.global.connectionStatus === 'connected' ? $store.global.t('online') : ($store.global.connectionStatus === 'disconnected' ? $store.global.t('offline') : $store.global.t('connecting'))">
-
OR
+
OR
-
+
Use CLI Command
@@ -263,12 +277,12 @@
diff --git a/public/js/app-init.js b/public/js/app-init.js index c93a32d..030d86c 100644 --- a/public/js/app-init.js +++ b/public/js/app-init.js @@ -25,8 +25,8 @@ document.addEventListener('alpine:init', () => { // Chart Defaults if (typeof Chart !== 'undefined') { - Chart.defaults.color = '#71717a'; - Chart.defaults.borderColor = '#27272a'; + Chart.defaults.color = window.utils.getThemeColor('--color-text-dim'); + Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border'); Chart.defaults.font.family = '"JetBrains Mono", monospace'; } diff --git a/public/js/components/account-manager.js b/public/js/components/account-manager.js index c5a677a..04501c8 100644 --- a/public/js/components/account-manager.js +++ b/public/js/components/account-manager.js @@ -6,99 +6,105 @@ window.Components = window.Components || {}; window.Components.accountManager = () => ({ async refreshAccount(email) { - Alpine.store('global').showToast(`Refreshing ${email}...`, 'info'); - const password = Alpine.store('global').webuiPassword; + const store = Alpine.store('global'); + store.showToast(store.t('refreshingAccount', { email }), 'info'); + const password = store.webuiPassword; try { const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password); - if (newPassword) Alpine.store('global').webuiPassword = newPassword; + if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { - Alpine.store('global').showToast(`Refreshed ${email}`, 'success'); + store.showToast(store.t('refreshedAccount', { email }), 'success'); Alpine.store('data').fetchData(); } else { - Alpine.store('global').showToast(data.error || 'Refresh failed', 'error'); + store.showToast(data.error || store.t('refreshFailed'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Refresh failed: ' + e.message, 'error'); + store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error'); } }, async toggleAccount(email, enabled) { - const password = Alpine.store('global').webuiPassword; + const store = Alpine.store('global'); + const password = store.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; + if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { - Alpine.store('global').showToast(`Account ${email} ${enabled ? 'enabled' : 'disabled'}`, 'success'); + const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus'); + store.showToast(store.t('accountToggled', { email, status }), 'success'); Alpine.store('data').fetchData(); } else { - Alpine.store('global').showToast(data.error || 'Toggle failed', 'error'); + store.showToast(data.error || store.t('toggleFailed'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Toggle failed: ' + e.message, 'error'); + store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error'); } }, async fixAccount(email) { - Alpine.store('global').showToast(`Re-authenticating ${email}...`, 'info'); - const password = Alpine.store('global').webuiPassword; + const store = Alpine.store('global'); + store.showToast(store.t('reauthenticating', { email }), 'info'); + const password = store.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; + if (newPassword) store.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'); + store.showToast(data.error || store.t('authUrlFailed'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Failed: ' + e.message, 'error'); + store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error'); } }, async deleteAccount(email) { - if (!confirm(Alpine.store('global').t('confirmDelete'))) return; - const password = Alpine.store('global').webuiPassword; + const store = Alpine.store('global'); + if (!confirm(store.t('confirmDelete'))) return; + const password = store.webuiPassword; try { const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password); - if (newPassword) Alpine.store('global').webuiPassword = newPassword; + if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { - Alpine.store('global').showToast(`Deleted ${email}`, 'success'); + store.showToast(store.t('deletedAccount', { email }), 'success'); Alpine.store('data').fetchData(); } else { - Alpine.store('global').showToast(data.error || 'Delete failed', 'error'); + store.showToast(data.error || store.t('deleteFailed'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Delete failed: ' + e.message, 'error'); + store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error'); } }, async reloadAccounts() { - const password = Alpine.store('global').webuiPassword; + const store = Alpine.store('global'); + const password = store.webuiPassword; try { const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password); - if (newPassword) Alpine.store('global').webuiPassword = newPassword; + if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { - Alpine.store('global').showToast('Accounts reloaded', 'success'); + store.showToast(store.t('accountsReloaded'), 'success'); Alpine.store('data').fetchData(); } else { - Alpine.store('global').showToast(data.error || 'Reload failed', 'error'); + store.showToast(data.error || store.t('reloadFailed'), 'error'); } } catch (e) { - Alpine.store('global').showToast('Reload failed: ' + e.message, 'error'); + store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error'); } } }); diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js index 85c15be..0d1614c 100644 --- a/public/js/components/claude-config.js +++ b/public/js/components/claude-config.js @@ -44,9 +44,9 @@ window.Components.claudeConfig = () => ({ if (newPassword) Alpine.store('global').webuiPassword = newPassword; if (!response.ok) throw new Error(`HTTP ${response.status}`); - Alpine.store('global').showToast('Claude config saved!', 'success'); + Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success'); } catch (e) { - Alpine.store('global').showToast('Failed to save config: ' + e.message, 'error'); + Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error'); } finally { this.loading = false; } diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js index 361ee38..d41f841 100644 --- a/public/js/components/dashboard.js +++ b/public/js/components/dashboard.js @@ -4,17 +4,50 @@ */ window.Components = window.Components || {}; +// Helper to get CSS variable values (alias to window.utils.getThemeColor) +const getThemeColor = (name) => window.utils.getThemeColor(name); + +// Color palette for different families and models +const FAMILY_COLORS = { + get claude() { return getThemeColor('--color-neon-purple'); }, + get gemini() { return getThemeColor('--color-neon-green'); }, + get other() { return getThemeColor('--color-neon-cyan'); } +}; + +const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => getThemeColor(`--color-chart-${i + 1}`)); + window.Components.dashboard = () => ({ - stats: { total: 0, active: 0, limited: 0, overallHealth: 0 }, - charts: { quotaDistribution: null }, + stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false }, + charts: { quotaDistribution: null, usageTrend: null }, + + // Usage stats + usageStats: { total: 0, today: 0, thisHour: 0 }, + historyData: {}, + + // Hierarchical model tree: { claude: ['opus-4-5', 'sonnet-4-5'], gemini: ['3-flash'] } + modelTree: {}, + families: [], // ['claude', 'gemini'] + + // Display mode: 'family' or 'model' + displayMode: 'model', + + // Selection state + selectedFamilies: [], + selectedModels: {}, // { claude: ['opus-4-5'], gemini: ['3-flash'] } + + showModelFilter: false, init() { + // Load saved preferences from localStorage + this.loadPreferences(); + // Update stats when dashboard becomes active this.$watch('$store.global.activeTab', (val) => { if (val === 'dashboard') { this.$nextTick(() => { this.updateStats(); this.updateCharts(); + this.fetchHistory(); }); } }); @@ -32,22 +65,456 @@ window.Components.dashboard = () => ({ this.$nextTick(() => { this.updateStats(); this.updateCharts(); + this.fetchHistory(); }); } + + // Refresh history every 5 minutes + setInterval(() => { + if (this.$store.global.activeTab === 'dashboard') { + this.fetchHistory(); + } + }, 300000); + }, + + loadPreferences() { + try { + const saved = localStorage.getItem('dashboard_chart_prefs'); + if (saved) { + const prefs = JSON.parse(saved); + this.displayMode = prefs.displayMode || 'model'; + this.selectedFamilies = prefs.selectedFamilies || []; + this.selectedModels = prefs.selectedModels || {}; + } + } catch (e) { + console.error('Failed to load dashboard preferences:', e); + } + }, + + savePreferences() { + try { + localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ + displayMode: this.displayMode, + selectedFamilies: this.selectedFamilies, + selectedModels: this.selectedModels + })); + } catch (e) { + console.error('Failed to save dashboard preferences:', e); + } + }, + + async fetchHistory() { + try { + const password = Alpine.store('global').webuiPassword; + const { response, newPassword } = await window.utils.request('/api/stats/history', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (response.ok) { + const history = await response.json(); + this.historyData = history; + this.processHistory(history); + this.stats.hasTrendData = true; + } + } catch (error) { + console.error('Failed to fetch usage history:', error); + this.stats.hasTrendData = true; + } + }, + + processHistory(history) { + // Build model tree from hierarchical data + const tree = {}; + let total = 0, today = 0, thisHour = 0; + + const now = new Date(); + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + const currentHour = new Date(now); + currentHour.setMinutes(0, 0, 0); + + Object.entries(history).forEach(([iso, hourData]) => { + const timestamp = new Date(iso); + + // Process each family in the hour data + Object.entries(hourData).forEach(([key, value]) => { + // Skip metadata keys + if (key === '_total' || key === 'total') return; + + // Handle hierarchical format: { claude: { "opus-4-5": 10, "_subtotal": 10 } } + if (typeof value === 'object' && value !== null) { + if (!tree[key]) tree[key] = new Set(); + + Object.keys(value).forEach(modelName => { + if (modelName !== '_subtotal') { + tree[key].add(modelName); + } + }); + } + // Skip old flat format keys (claude, gemini as numbers) + }); + + // Calculate totals + const hourTotal = hourData._total || hourData.total || 0; + total += hourTotal; + + if (timestamp >= todayStart) { + today += hourTotal; + } + if (timestamp.getTime() === currentHour.getTime()) { + thisHour = hourTotal; + } + }); + + this.usageStats = { total, today, thisHour }; + + // Convert Sets to sorted arrays + this.modelTree = {}; + Object.entries(tree).forEach(([family, models]) => { + this.modelTree[family] = Array.from(models).sort(); + }); + this.families = Object.keys(this.modelTree).sort(); + + // Auto-select new families/models that haven't been configured + this.autoSelectNew(); + + this.updateTrendChart(); + }, + + autoSelectNew() { + // If no preferences saved, select all + if (this.selectedFamilies.length === 0 && Object.keys(this.selectedModels).length === 0) { + this.selectedFamilies = [...this.families]; + this.families.forEach(family => { + this.selectedModels[family] = [...(this.modelTree[family] || [])]; + }); + this.savePreferences(); + return; + } + + // Add new families/models that appeared + this.families.forEach(family => { + if (!this.selectedFamilies.includes(family)) { + this.selectedFamilies.push(family); + } + if (!this.selectedModels[family]) { + this.selectedModels[family] = []; + } + (this.modelTree[family] || []).forEach(model => { + if (!this.selectedModels[family].includes(model)) { + this.selectedModels[family].push(model); + } + }); + }); + }, + + autoSelectTopN(n = 5) { + // Calculate usage for each model over past 24 hours + const usage = {}; + const now = Date.now(); + const dayAgo = now - 24 * 60 * 60 * 1000; + + Object.entries(this.historyData).forEach(([iso, hourData]) => { + const timestamp = new Date(iso).getTime(); + if (timestamp < dayAgo) return; + + Object.entries(hourData).forEach(([family, familyData]) => { + if (typeof familyData === 'object' && family !== '_total') { + Object.entries(familyData).forEach(([model, count]) => { + if (model !== '_subtotal') { + const key = `${family}:${model}`; + usage[key] = (usage[key] || 0) + count; + } + }); + } + }); + }); + + // Sort by usage and take top N + const sorted = Object.entries(usage) + .sort((a, b) => b[1] - a[1]) + .slice(0, n); + + // Clear current selection + this.selectedFamilies = []; + this.selectedModels = {}; + + // Select top N models + sorted.forEach(([key]) => { + const [family, model] = key.split(':'); + if (!this.selectedFamilies.includes(family)) { + this.selectedFamilies.push(family); + } + if (!this.selectedModels[family]) { + this.selectedModels[family] = []; + } + this.selectedModels[family].push(model); + }); + + this.savePreferences(); + this.refreshChart(); + }, + + // Toggle display mode between family and model level + setDisplayMode(mode) { + this.displayMode = mode; + this.savePreferences(); + this.updateTrendChart(); + }, + + // Toggle family selection + toggleFamily(family) { + const index = this.selectedFamilies.indexOf(family); + if (index > -1) { + this.selectedFamilies.splice(index, 1); + } else { + this.selectedFamilies.push(family); + } + this.savePreferences(); + this.updateTrendChart(); + }, + + // Toggle model selection within a family + toggleModel(family, model) { + if (!this.selectedModels[family]) { + this.selectedModels[family] = []; + } + const index = this.selectedModels[family].indexOf(model); + if (index > -1) { + this.selectedModels[family].splice(index, 1); + } else { + this.selectedModels[family].push(model); + } + this.savePreferences(); + this.updateTrendChart(); + }, + + // Check if family is selected + isFamilySelected(family) { + return this.selectedFamilies.includes(family); + }, + + // Check if model is selected + isModelSelected(family, model) { + return this.selectedModels[family]?.includes(model) || false; + }, + + // Select all families and models + selectAll() { + this.selectedFamilies = [...this.families]; + this.families.forEach(family => { + this.selectedModels[family] = [...(this.modelTree[family] || [])]; + }); + this.savePreferences(); + this.updateTrendChart(); + }, + + // Deselect all + deselectAll() { + this.selectedFamilies = []; + this.selectedModels = {}; + this.savePreferences(); + this.updateTrendChart(); + }, + + // Get color for family + getFamilyColor(family) { + return FAMILY_COLORS[family] || FAMILY_COLORS.other; + }, + + // Get color for model (with index for variation within family) + getModelColor(family, modelIndex) { + const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8); + return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length]; + }, + + // Get count of selected items for display + getSelectedCount() { + if (this.displayMode === 'family') { + return `${this.selectedFamilies.length}/${this.families.length}`; + } + let selected = 0, total = 0; + this.families.forEach(family => { + const models = this.modelTree[family] || []; + total += models.length; + selected += (this.selectedModels[family] || []).length; + }); + return `${selected}/${total}`; + }, + + updateTrendChart() { + const ctx = document.getElementById('usageTrendChart'); + if (!ctx || typeof Chart === 'undefined') return; + + if (this.charts.usageTrend) { + this.charts.usageTrend.destroy(); + } + + const history = this.historyData; + const labels = []; + const datasets = []; + + if (this.displayMode === 'family') { + // Aggregate by family + const dataByFamily = {}; + this.selectedFamilies.forEach(family => { + dataByFamily[family] = []; + }); + + Object.entries(history).forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + + this.selectedFamilies.forEach(family => { + const familyData = hourData[family]; + const count = familyData?._subtotal || 0; + dataByFamily[family].push(count); + }); + }); + + // Build datasets for families + this.selectedFamilies.forEach(family => { + const color = this.getFamilyColor(family); + const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1); + const label = Alpine.store('global').t(familyKey); + datasets.push(this.createDataset( + label, + dataByFamily[family], + color, + ctx + )); + }); + } else { + // Show individual models + const dataByModel = {}; + + // Initialize data arrays + this.families.forEach(family => { + (this.selectedModels[family] || []).forEach(model => { + const key = `${family}:${model}`; + dataByModel[key] = []; + }); + }); + + Object.entries(history).forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + + this.families.forEach(family => { + const familyData = hourData[family] || {}; + (this.selectedModels[family] || []).forEach(model => { + const key = `${family}:${model}`; + dataByModel[key].push(familyData[model] || 0); + }); + }); + }); + + // Build datasets for models + this.families.forEach(family => { + (this.selectedModels[family] || []).forEach((model, modelIndex) => { + const key = `${family}:${model}`; + const color = this.getModelColor(family, modelIndex); + datasets.push(this.createDataset(model, dataByModel[key], color, ctx)); + }); + }); + } + + this.charts.usageTrend = new Chart(ctx, { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: getThemeColor('--color-space-950') || 'rgba(24, 24, 27, 0.9)', + titleColor: getThemeColor('--color-text-main'), + bodyColor: getThemeColor('--color-text-bright'), + borderColor: getThemeColor('--color-space-border'), + borderWidth: 1, + padding: 10, + displayColors: true, + callbacks: { + label: function(context) { + return context.dataset.label + ': ' + context.parsed.y; + } + } + } + }, + scales: { + x: { + display: true, + grid: { display: false }, + ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } } + }, + y: { + display: true, + beginAtZero: true, + grid: { display: true, color: getThemeColor('--color-space-border') + '1a' || 'rgba(255,255,255,0.05)' }, + ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } } + } + } + } + }); + }, + + createDataset(label, data, color, ctx) { + const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, this.hexToRgba(color, 0.3)); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + return { + label, + data, + borderColor: color, + backgroundColor: gradient, + borderWidth: 2, + tension: 0.4, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + pointBackgroundColor: color + }; + }, + + hexToRgba(hex, alpha) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`; + } + return hex; }, updateStats() { const accounts = Alpine.store('data').accounts; let active = 0, limited = 0; + + const isCore = (id) => /sonnet|opus|pro|flash/i.test(id); + accounts.forEach(acc => { if (acc.status === 'ok') { - const hasQuota = Object.values(acc.limits || {}).some(l => l && l.remainingFraction > 0); - if (hasQuota) active++; else limited++; + const limits = Object.entries(acc.limits || {}); + let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id)); + + if (!hasActiveCore) { + const hasAnyCore = limits.some(([id]) => isCore(id)); + if (!hasAnyCore) { + hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05); + } + } + + if (hasActiveCore) active++; else limited++; } else { limited++; } }); - this.stats = { total: accounts.length, active, limited }; + this.stats.total = accounts.length; + this.stats.active = active; + this.stats.limited = limited; }, updateCharts() { @@ -59,32 +526,70 @@ window.Components.dashboard = () => ({ } 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; + // Dynamic family aggregation (supports any model family) + const familyStats = {}; 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++; + if (!familyStats[row.family]) { + familyStats[row.family] = { used: 0, total: 0 }; + } + row.quotaInfo.forEach(info => { + familyStats[row.family].used += info.pct; + familyStats[row.family].total += 100; + }); }); - this.stats.overallHealth = totalModelCount > 0 ? Math.round(totalHealthSum / totalModelCount) : 0; + // Calculate global health + const globalTotal = Object.values(familyStats).reduce((sum, f) => sum + f.total, 0); + const globalUsed = Object.values(familyStats).reduce((sum, f) => sum + f.used, 0); + this.stats.overallHealth = globalTotal > 0 ? Math.round((globalUsed / globalTotal) * 100) : 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, - ]; + // Generate chart data dynamically + const familyColors = { + 'claude': getThemeColor('--color-neon-purple'), + 'gemini': getThemeColor('--color-neon-green'), + 'other': getThemeColor('--color-neon-cyan'), + 'unknown': '#666666' + }; + + const families = Object.keys(familyStats).sort(); + const segmentSize = families.length > 0 ? 100 / families.length : 100; + + const data = []; + const colors = []; + const labels = []; + + families.forEach(family => { + const stats = familyStats[family]; + const health = stats.total > 0 ? Math.round((stats.used / stats.total) * 100) : 0; + const activeVal = (health / 100) * segmentSize; + const inactiveVal = segmentSize - activeVal; + + const familyColor = familyColors[family] || familyColors['unknown']; + + // Get translation keys if available, otherwise capitalize + const familyName = family.charAt(0).toUpperCase() + family.slice(1); + const store = Alpine.store('global'); + + // Labels using translations if possible + const activeLabel = family === 'claude' ? store.t('claudeActive') : + family === 'gemini' ? store.t('geminiActive') : + `${familyName} Active`; + + const depletedLabel = family === 'claude' ? store.t('claudeEmpty') : + family === 'gemini' ? store.t('geminiEmpty') : + `${familyName} Depleted`; + + // Active segment + data.push(activeVal); + colors.push(familyColor); + labels.push(activeLabel); + + // Inactive segment + data.push(inactiveVal); + colors.push(this.hexToRgba(familyColor, 0.1)); + labels.push(depletedLabel); + }); this.charts.quotaDistribution = new Chart(ctx, { type: 'doughnut', @@ -92,24 +597,22 @@ window.Components.dashboard = () => ({ labels: labels, datasets: [{ data: data, - backgroundColor: ['#a855f7', '#22c55e', '#52525b'], - borderColor: '#09090b', // Matches bg-space-900 roughly + backgroundColor: colors, + borderColor: getThemeColor('--color-space-950'), borderWidth: 2, hoverOffset: 0, - borderRadius: 2 + borderRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, - cutout: '85%', // Thinner ring + cutout: '85%', + rotation: -90, + circumference: 360, plugins: { - legend: { - display: false // Hide default legend - }, - tooltip: { - enabled: false // Disable tooltip for cleaner look, or style it? Let's keep it simple. - }, + legend: { display: false }, + tooltip: { enabled: false }, title: { display: false } }, animation: { diff --git a/public/js/data-store.js b/public/js/data-store.js index d084122..867c937 100644 --- a/public/js/data-store.js +++ b/public/js/data-store.js @@ -59,7 +59,8 @@ document.addEventListener('alpine:init', () => { } catch (error) { console.error('Fetch error:', error); this.connectionStatus = 'disconnected'; - Alpine.store('global').showToast('Connection Lost', 'error'); + const store = Alpine.store('global'); + store.showToast(store.t('connectionLost'), 'error'); } finally { this.loading = false; } diff --git a/public/js/settings-store.js b/public/js/settings-store.js index 5d895e8..17916ad 100644 --- a/public/js/settings-store.js +++ b/public/js/settings-store.js @@ -44,7 +44,8 @@ document.addEventListener('alpine:init', () => { localStorage.setItem('antigravity_settings', JSON.stringify(toSave)); if (!silent) { - Alpine.store('global').showToast('Configuration Saved', 'success'); + const store = Alpine.store('global'); + store.showToast(store.t('configSaved'), 'success'); } // Trigger updates diff --git a/public/js/store.js b/public/js/store.js index 72b008a..2aa6c99 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -56,7 +56,14 @@ document.addEventListener('alpine:init', () => { delete: "Delete", confirmDelete: "Are you sure you want to remove this account?", connectGoogle: "Connect Google Account", - manualReload: "Reload from Disk", + reauthenticated: "re-authenticated", + added: "added", + successfully: "successfully", + failedToGetAuthUrl: "Failed to get auth URL", + failedToStartOAuth: "Failed to start OAuth flow", + family: "Family", + model: "Model", + activeSuffix: "Active", // Tabs tabInterface: "Interface", tabClaude: "Claude CLI", @@ -104,6 +111,68 @@ document.addEventListener('alpine:init', () => { authToken: "Auth Token", saveConfig: "Save to ~/.claude/settings.json", envVar: "Env", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE PROXY SYSTEM", + connectGoogleDesc: "Connect a Google Workspace account to increase your API quota limit. The account will be used to proxy Claude requests via Antigravity.", + useCliCommand: "Use CLI Command", + close: "Close", + requestVolume: "Request Volume", + filter: "Filter", + all: "All", + none: "None", + noDataTracked: "No data tracked yet", + selectFamilies: "Select families to display", + selectModels: "Select models to display", + noLogsMatch: "No logs match filter", + connecting: "CONNECTING", + main: "Main", + system: "System", + refreshData: "Refresh Data", + connectionLost: "Connection Lost", + lastUpdated: "Last Updated", + grepLogs: "grep logs...", + noMatchingModels: "No matching models", + typeToSearch: "Type to search or select...", + or: "OR", + refreshingAccount: "Refreshing {email}...", + refreshedAccount: "Refreshed {email}", + refreshFailed: "Refresh failed", + accountToggled: "Account {email} {status}", + toggleFailed: "Toggle failed", + reauthenticating: "Re-authenticating {email}...", + authUrlFailed: "Failed to get auth URL", + deletedAccount: "Deleted {email}", + deleteFailed: "Delete failed", + accountsReloaded: "Accounts reloaded", + reloadFailed: "Reload failed", + claudeConfigSaved: "Claude configuration saved", + saveConfigFailed: "Failed to save configuration", + claudeActive: "Claude Active", + claudeEmpty: "Claude Empty", + geminiActive: "Gemini Active", + geminiEmpty: "Gemini Empty", + fix: "FIX", + synced: "SYNCED", + syncing: "SYNCING...", + // Additional + reloading: "Reloading...", + reloaded: "Reloaded", + lines: "lines", + enabledSeeLogs: "Enabled (See Logs)", + production: "Production", + configSaved: "Configuration Saved", + enterPassword: "Enter Web UI Password:", + ready: "READY", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Other", + enabledStatus: "enabled", + disabledStatus: "disabled", + logLevelInfo: "INFO", + logLevelSuccess: "SUCCESS", + logLevelWarn: "WARN", + logLevelError: "ERR", }, zh: { dashboard: "仪表盘", @@ -148,6 +217,14 @@ document.addEventListener('alpine:init', () => { delete: "删除", confirmDelete: "确定要移除此账号吗?", connectGoogle: "连接 Google 账号", + reauthenticated: "已重新认证", + added: "已添加", + successfully: "成功", + failedToGetAuthUrl: "获取认证链接失败", + failedToStartOAuth: "启动 OAuth 流程失败", + family: "系列", + model: "模型", + activeSuffix: "活跃", manualReload: "重新加载配置", // Tabs tabInterface: "界面设置", @@ -196,14 +273,82 @@ document.addEventListener('alpine:init', () => { authToken: "认证令牌", saveConfig: "保存到 ~/.claude/settings.json", envVar: "环境变量", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE 代理系统", + connectGoogleDesc: "连接 Google Workspace 账号以增加 API 配额。该账号将用于通过 Antigravity 代理 Claude 请求。", + useCliCommand: "使用命令行", + close: "关闭", + requestVolume: "请求量", + filter: "筛选", + all: "全选", + none: "清空", + noDataTracked: "暂无追踪数据", + selectFamilies: "选择要显示的系列", + selectModels: "选择要显示的模型", + noLogsMatch: "没有符合过滤条件的日志", + connecting: "正在连接", + main: "主菜单", + system: "系统", + refreshData: "刷新数据", + connectionLost: "连接已断开", + lastUpdated: "最后更新", + grepLogs: "过滤日志...", + noMatchingModels: "没有匹配的模型", + typeToSearch: "输入以搜索或选择...", + or: "或", + refreshingAccount: "正在刷新 {email}...", + refreshedAccount: "已完成刷新 {email}", + refreshFailed: "刷新失败", + accountToggled: "账号 {email} 已{status}", + toggleFailed: "切换失败", + reauthenticating: "正在重新认证 {email}...", + authUrlFailed: "获取认证链接失败", + deletedAccount: "已删除 {email}", + deleteFailed: "删除失败", + accountsReloaded: "账号配置已重载", + reloadFailed: "重载失败", + claudeConfigSaved: "Claude 配置已保存", + saveConfigFailed: "保存配置失败", + claudeActive: "Claude 活跃", + claudeEmpty: "Claude 耗尽", + geminiActive: "Gemini 活跃", + geminiEmpty: "Gemini 耗尽", + fix: "修复", + synced: "已同步", + syncing: "正在同步...", + // Additional + reloading: "正在重载...", + reloaded: "已重载", + lines: "行", + enabledSeeLogs: "已启用 (见日志)", + production: "生产环境", + configSaved: "配置已保存", + enterPassword: "请输入 Web UI 密码:", + ready: "就绪", + familyClaude: "Claude 系列", + familyGemini: "Gemini 系列", + familyOther: "其他系列", + enabledStatus: "已启用", + disabledStatus: "已禁用", + logLevelInfo: "信息", + logLevelSuccess: "成功", + logLevelWarn: "警告", + logLevelError: "错误", } }, // Toast Messages toast: null, - t(key) { - return this.translations[this.lang][key] || key; + t(key, params = {}) { + let str = this.translations[this.lang][key] || key; + if (typeof str === 'string') { + Object.keys(params).forEach(p => { + str = str.replace(`{${p}}`, params[p]); + }); + } + return str; }, setLang(l) { diff --git a/public/js/utils.js b/public/js/utils.js index e3098e7..2926d0b 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -13,7 +13,8 @@ window.utils = { let response = await fetch(url, options); if (response.status === 401) { - const password = prompt('Enter Web UI Password:'); + const store = Alpine.store('global'); + const password = prompt(store ? store.t('enterPassword') : '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 @@ -31,11 +32,16 @@ window.utils = { }, formatTimeUntil(isoTime) { + const store = Alpine.store('global'); const diff = new Date(isoTime) - new Date(); - if (diff <= 0) return 'READY'; + if (diff <= 0) return store ? store.t('ready') : '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`; + }, + + getThemeColor(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } }; diff --git a/public/views/accounts.html b/public/views/accounts.html index 2743554..f64eaf0 100644 --- a/public/views/accounts.html +++ b/public/views/accounts.html @@ -1,14 +1,14 @@ -
-
+
+
-

- Access - Credentials

-

Manage OAuth tokens - and session - states

+

+ Access Credentials +

+

+ Manage OAuth tokens and session states +

-
- - +
+ @@ -28,17 +28,14 @@ - +
Enabled Identity (Email)Operations