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/CLAUDE.md b/CLAUDE.md index 285a142..a3b8a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,9 @@ src/ │ ├── token-extractor.js # Legacy token extraction from DB │ └── database.js # SQLite database access │ +├── webui/ # Web Management Interface +│ └── index.js # Express router and API endpoints +│ ├── cli/ # CLI tools │ └── accounts.js # Account management CLI │ @@ -105,10 +108,33 @@ src/ └── native-module-helper.js # Auto-rebuild for native modules ``` +**Frontend Structure (public/):** + +``` +public/ +├── index.html # Main entry point +├── js/ +│ ├── app.js # Main application logic (Alpine.js) +│ ├── store.js # Global state management +│ ├── components/ # UI Components +│ │ ├── dashboard.js # Real-time stats & charts +│ │ ├── account-manager.js # Account list & OAuth handling +│ │ ├── logs-viewer.js # Live log streaming +│ │ └── claude-config.js # CLI settings editor +│ └── utils/ # Frontend utilities +└── views/ # HTML partials (loaded dynamically) + ├── dashboard.html + ├── accounts.html + ├── settings.html + └── logs.html +``` + **Key Modules:** -- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) +- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI +- **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs - **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support + - `model-api.js`: Model listing, quota retrieval (`getModelQuotas()`), and subscription tier detection (`getSubscriptionTier()`) - **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown - **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules - **src/format/**: Format conversion between Anthropic and Google Generative AI formats @@ -123,6 +149,17 @@ src/ - Session ID derived from first user message hash for cache continuity - Account state persisted to `~/.config/antigravity-proxy/accounts.json` +**Account Data Model:** +Each account object in `accounts.json` contains: +- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed` +- **Credentials**: `refreshToken` (OAuth) or `apiKey` (manual) +- **Subscription**: `{ tier, projectId, detectedAt }` - automatically detected via `loadCodeAssist` API + - `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`) +- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache + - `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API +- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime) +- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication + **Prompt Caching:** - Cache is organization-scoped (requires same account + session ID) - Session ID is SHA256 hash of first user message content (stable across turns) @@ -152,6 +189,20 @@ src/ - If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required - Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js` +**Web Management UI:** + +- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN) +- **Architecture**: Single Page Application (SPA) with dynamic view loading +- **State Management**: Alpine.store for global state (accounts, settings, logs) +- **Features**: + - Real-time dashboard with Chart.js visualization and subscription tier distribution + - Account list with tier badges (Ultra/Pro/Free) and quota progress bars + - OAuth flow handling via popup window + - Live log streaming via Server-Sent Events (SSE) + - Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`) +- **Security**: Optional password protection via `WEBUI_PASSWORD` env var +- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden) + ## Testing Notes - Tests require the server to be running (`npm start` in separate terminal) @@ -186,6 +237,12 @@ src/ - `sleep(ms)` - Promise-based delay - `isNetworkError(error)` - Check if error is a transient network error +**Data Persistence:** +- Subscription and quota data are automatically fetched when `/account-limits` is called +- Updated data is saved to `accounts.json` asynchronously (non-blocking) +- On server restart, accounts load with last known subscription/quota state +- Quota is refreshed on each WebUI poll (default: 30s with jitter) + **Logger:** Structured logging via `src/utils/logger.js`: - `logger.info(msg)` - Standard info (blue) - `logger.success(msg)` - Success messages (green) @@ -195,6 +252,17 @@ src/ - `logger.setDebug(true)` - Enable debug mode - `logger.isDebugEnabled` - Check if debug mode is on +**WebUI APIs:** + +- `/api/accounts/*` - Account management (list, add, remove, refresh) +- `/api/config/*` - Server configuration (read/write) +- `/api/claude/config` - Claude CLI settings +- `/api/logs/stream` - SSE endpoint for real-time logs +- `/api/auth/url` - Generate Google OAuth URL +- `/account-limits` - Fetch account quotas and subscription data + - Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }` + - Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats) + ## Maintenance When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync. diff --git a/README.md b/README.md index 9924c8a..0207373 100644 --- a/README.md +++ b/README.md @@ -59,62 +59,7 @@ npm start ## Quick Start -### 1. Add Account(s) - -You have two options: - -**Option A: Use Antigravity (Single Account)** - -If you have Antigravity installed and logged in, the proxy will automatically extract your token. No additional setup needed. - -**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)** - -Add one or more Google accounts for load balancing. - -#### Desktop/Laptop (with browser) - -```bash -# If installed via npm -antigravity-claude-proxy accounts add - -# If using npx -npx antigravity-claude-proxy@latest accounts add - -# If cloned locally -npm run accounts:add -``` - -This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts. - -#### Headless Server (Docker, SSH, no desktop) - -```bash -# If installed via npm -antigravity-claude-proxy accounts add --no-browser - -# If using npx -npx antigravity-claude-proxy@latest accounts add -- --no-browser - -# If cloned locally -npm run accounts:add -- --no-browser -``` - -This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal. - -#### Manage accounts - -```bash -# List all accounts -antigravity-claude-proxy accounts list - -# Verify accounts are working -antigravity-claude-proxy accounts verify - -# Interactive account management -antigravity-claude-proxy accounts -``` - -### 2. Start the Proxy Server +### 1. Start the Proxy Server ```bash # If installed via npm @@ -129,6 +74,34 @@ npm start The server runs on `http://localhost:8080` by default. +### 2. Link Account(s) + +Choose one of the following methods to authorize the proxy: + +#### **Method A: Web Dashboard (Recommended)** + +1. With the proxy running, open `http://localhost:8080` in your browser. +2. Navigate to the **Accounts** tab and click **Add Account**. +3. Complete the Google OAuth authorization in the popup window. + +#### **Method B: CLI (Desktop or Headless)** + +If you prefer the terminal or are on a remote server: + +```bash +# Desktop (opens browser) +antigravity-claude-proxy accounts add + +# Headless (Docker/SSH) +antigravity-claude-proxy accounts add --no-browser +``` + +> For full CLI account management options, run `antigravity-claude-proxy accounts --help`. + +#### **Method C: Automatic (Antigravity Users)** + +If you have the **Antigravity** app installed and logged in, the proxy will automatically detect your local session. No additional setup is required. + To use a custom port: ```bash @@ -151,6 +124,18 @@ curl "http://localhost:8080/account-limits?format=table" ### Configure Claude Code +You can configure these settings in two ways: + +#### **Via Web Console (Recommended)** + +1. Open the WebUI at `http://localhost:8080`. +2. Go to **Settings** → **Claude CLI**. +3. Select your preferred models and click **Apply to Claude CLI**. + +> [!TIP] > **Configuration Precedence**: System environment variables (set in shell profile like `.zshrc`) take precedence over the `settings.json` file. If you use the Web Console to manage settings, ensure you haven't manually exported conflicting variables in your terminal. + +#### **Manual Configuration** + Create or edit the Claude Code settings file: **macOS:** `~/.claude/settings.json` @@ -267,18 +252,18 @@ Then run `claude` for official API or `claude-antigravity` for this proxy. ### Claude Models -| Model ID | Description | -|----------|-------------| +| Model ID | Description | +| ---------------------------- | ---------------------------------------- | | `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking | -| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking | -| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking | +| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking | +| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking | ### Gemini Models -| Model ID | Description | -|----------|-------------| -| `gemini-3-flash` | Gemini 3 Flash with thinking | -| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking | +| Model ID | Description | +| ------------------- | ------------------------------- | +| `gemini-3-flash` | Gemini 3 Flash with thinking | +| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking | | `gemini-3-pro-high` | Gemini 3 Pro High with thinking | Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations. @@ -295,23 +280,74 @@ When you add multiple accounts, the proxy automatically: - **Invalid account detection**: Accounts needing re-authentication are marked and skipped - **Prompt caching support**: Stable session IDs enable cache hits across conversation turns -Check account status anytime: +Check account status, subscription tiers, and quota anytime: ```bash +# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress) +# CLI Table: curl "http://localhost:8080/account-limits?format=table" ``` +#### CLI Management Reference + +If you prefer using the terminal for management: + +```bash +# List all accounts +antigravity-claude-proxy accounts list + +# Verify account health +antigravity-claude-proxy accounts verify + +# Interactive CLI menu +antigravity-claude-proxy accounts +``` + +--- + +## Web Management Console + +The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port). + +![Antigravity Console](images/webui-dashboard.png) + +### Key Features + +- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution. +- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators. +- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance. +- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser. +- **Live Logs**: Stream server logs with level-based filtering and search. +- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly. +- **Bilingual Interface**: Full support for English and Chinese (switch via Settings). + +--- + +## Advanced Configuration + +While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file. + +### Configurable Options + +- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config. +- **Custom Port**: Change the default `8080` port. +- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`. +- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`. +- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts. + +Refer to `config.example.json` for a complete list of fields and documentation. + --- ## API Endpoints -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) | -| `/v1/messages` | POST | Anthropic Messages API | -| `/v1/models` | GET | List available models | -| `/refresh-token` | POST | Force token refresh | +| Endpoint | Method | Description | +| ----------------- | ------ | --------------------------------------------------------------------- | +| `/health` | GET | Health check | +| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) | +| `/v1/messages` | POST | Anthropic Messages API | +| `/v1/models` | GET | List available models | +| `/refresh-token` | POST | Force token refresh | --- @@ -345,6 +381,7 @@ npm run test:caching # Prompt caching ### "Could not extract token from Antigravity" If using single-account mode with Antigravity: + 1. Make sure Antigravity app is installed and running 2. Ensure you're logged in to Antigravity @@ -353,11 +390,13 @@ Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add` ### 401 Authentication Errors The token might have expired. Try: + ```bash curl -X POST http://localhost:8080/refresh-token ``` Or re-authenticate the account: + ```bash antigravity-claude-proxy accounts ``` @@ -369,6 +408,7 @@ With multiple accounts, the proxy automatically switches to the next available a ### Account Shows as "Invalid" Re-authenticate the account: + ```bash antigravity-claude-proxy accounts # Choose "Re-authenticate" for the invalid account @@ -435,4 +475,4 @@ MIT ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left) 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-dashboard.png b/images/webui-dashboard.png new file mode 100644 index 0000000..6caea31 Binary files /dev/null and b/images/webui-dashboard.png differ 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/package.json b/package.json index 03e28b4..21ddf33 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", @@ -52,8 +53,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..84a674d --- /dev/null +++ b/public/app.js @@ -0,0 +1,198 @@ +/** + * 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('models', window.Components.models); + 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?t=${Date.now()}`) + .then(response => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .then(html => { + // Update cache (optional, or remove if we want always-fresh) + // keeping cache for session performance, but initial load will now bypass browser cache + 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 = window.utils.getThemeColor('--color-text-dim'); + Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border'); + 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') { + // Show info toast that OAuth is in progress + Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info'); + + // Open OAuth window + const oauthWindow = window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes'); + + // Poll for account changes instead of relying on postMessage + // (since OAuth callback is now on port 51121, not this server) + const initialAccountCount = Alpine.store('data').accounts.length; + let pollCount = 0; + const maxPolls = 60; // 2 minutes (2 second intervals) + let cancelled = false; + + // Show progress modal + Alpine.store('global').oauthProgress = { + active: true, + current: 0, + max: maxPolls, + cancel: () => { + cancelled = true; + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast(Alpine.store('global').t('oauthCancelled'), 'info'); + if (oauthWindow && !oauthWindow.closed) { + oauthWindow.close(); + } + } + }; + + const pollInterval = setInterval(async () => { + if (cancelled) { + clearInterval(pollInterval); + return; + } + + pollCount++; + Alpine.store('global').oauthProgress.current = pollCount; + + // Check if OAuth window was closed manually + if (oauthWindow && oauthWindow.closed && !cancelled) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast(Alpine.store('global').t('oauthWindowClosed'), 'warning'); + return; + } + + // Refresh account list + await Alpine.store('data').fetchData(); + + // Check if new account was added + const currentAccountCount = Alpine.store('data').accounts.length; + if (currentAccountCount > initialAccountCount) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + + const actionKey = reAuthEmail ? 'accountReauthSuccess' : 'accountAddedSuccess'; + Alpine.store('global').showToast( + Alpine.store('global').t(actionKey), + 'success' + ); + document.getElementById('add_account_modal')?.close(); + + if (oauthWindow && !oauthWindow.closed) { + oauthWindow.close(); + } + } + + // Stop polling after max attempts + if (pollCount >= maxPolls) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast( + Alpine.store('global').t('oauthTimeout'), + 'warning' + ); + } + }, 2000); // Poll every 2 seconds + } else { + Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error'); + } + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('failedToStartOAuth') + ': ' + 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..3b1d734 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,372 @@ +:root { + /* === Background Layers === */ + --color-space-950: #09090b; + --color-space-900: #0f0f11; + --color-space-850: #121214; + --color-space-800: #18181b; + --color-space-border: #27272a; + + /* === Neon Accents (Full Saturation) === */ + --color-neon-purple: #a855f7; + --color-neon-green: #22c55e; + --color-neon-cyan: #06b6d4; + --color-neon-yellow: #eab308; + --color-neon-red: #ef4444; + + /* === Soft Neon (Reduced Saturation for Fills) === */ + --color-neon-purple-soft: #9333ea; + --color-neon-green-soft: #16a34a; + --color-neon-cyan-soft: #0891b2; + + /* === Text Hierarchy (WCAG AA Compliant) === */ + --color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */ + --color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */ + --color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */ + --color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */ + + /* === Legacy Aliases (Backward Compatibility) === */ + --color-text-main: var(--color-text-secondary); + --color-text-dim: var(--color-text-tertiary); + --color-text-muted: var(--color-text-tertiary); + --color-text-bright: var(--color-text-primary); + + /* 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; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(9, 9, 11, 0.3); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #27272a 0%, #18181b 100%); + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%); + border-color: rgba(168, 85, 247, 0.3); +} + +/* 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: linear-gradient( + 135deg, + rgba(15, 15, 17, 0.75) 0%, + rgba(18, 18, 20, 0.7) 100% + ); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.02) inset, + 0 4px 24px rgba(0, 0, 0, 0.4); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.glass-panel:hover { + border-color: rgba(255, 255, 255, 0.12); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.nav-item.active { + background: linear-gradient( + 90deg, + theme("colors.neon.purple / 15%") 0%, + transparent 100% + ); + @apply border-l-4 border-neon-purple text-white; +} + +.nav-item { + @apply border-l-4 border-transparent transition-all duration-200; +} + +.progress-gradient-success::-webkit-progress-value { + 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, + var(--color-neon-yellow), + var(--color-yellow-400) + ); +} + +.progress-gradient-error::-webkit-progress-value { + background-image: linear-gradient( + to right, + var(--color-neon-red), + var(--color-red-400) + ); +} + +/* Dashboard Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; +} + +/* Tooltip Customization */ +.tooltip:before { + @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 */ +/* -------------------------------------------------------------------------- */ + +/* Standard Layout Constants */ +:root { + --view-padding: 2rem; /* 32px - Standard Padding */ + --view-gap: 2rem; /* 32px - Standard component gap */ + --card-radius: 0.75rem; /* 12px */ +} + +@media (max-width: 768px) { + :root { + --view-padding: 1rem; + --view-gap: 1.25rem; + } +} + +/* Base View Container */ +.view-container { + display: flex; + flex-direction: column; + margin-left: auto; + margin-right: auto; + width: 100%; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); /* Align with navbar height */ + max-width: 1400px; + scrollbar-gutter: stable; +} + +/* Specialized container for data-heavy pages (Logs) */ +.view-container-full { + @apply w-full animate-fade-in flex flex-col; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); + max-width: 100%; +} + +/* Centered container for form-heavy pages (Settings/Accounts) */ +.view-container-centered { + @apply mx-auto w-full animate-fade-in flex flex-col; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); + max-width: 900px; /* Comfortable reading width for forms */ +} + +/* Standard Section Header */ +.view-header { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: 0.5rem; + gap: 1rem; +} + +@media (min-width: 768px) { + .view-header { + flex-direction: row; + align-items: flex-end; + } +} + +.view-header-title { + @apply flex flex-col; +} + +.view-header-title h2 { + @apply text-2xl font-bold text-white tracking-tight; +} + +.view-header-title p { + @apply text-sm text-gray-500 mt-1; +} + +.view-header-actions { + @apply flex items-center gap-3; +} + +/* Standard Card Panel */ +.view-card { + position: relative; + overflow: hidden; + border-radius: var(--card-radius); + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(135deg, + rgba(15, 15, 17, 0.75) 0%, + rgba(18, 18, 20, 0.70) 100% + ); + backdrop-filter: blur(12px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02) inset, + 0 4px 24px rgba(0, 0, 0, 0.4); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.view-card:hover { + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.view-card-header { + @apply flex items-center justify-between mb-4 pb-4 border-b border-space-border/30; +} + +/* 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 transition-all duration-200 border-b border-space-border/30 last:border-0; +} + +.standard-table tbody tr:hover { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0.05) 50%, + rgba(255, 255, 255, 0.03) 100% + ); + border-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* Custom Range Slider - Simplified */ +.custom-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: var(--color-space-800); + border-radius: 999px; + outline: none; + cursor: pointer; +} + +.custom-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--range-color, var(--color-neon-purple)); + cursor: pointer; + transition: transform 0.1s ease; +} + +.custom-range::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.custom-range::-moz-range-thumb { + width: 14px; + height: 14px; + border: none; + border-radius: 50%; + background: var(--range-color, var(--color-neon-purple)); + cursor: pointer; + transition: transform 0.1s ease; +} + +.custom-range::-moz-range-thumb:hover { + transform: scale(1.15); +} + +/* Color Variants */ +.custom-range-purple { + --range-color: var(--color-neon-purple); +} +.custom-range-green { + --range-color: var(--color-neon-green); +} +.custom-range-cyan { + --range-color: var(--color-neon-cyan); +} +.custom-range-yellow { + --range-color: var(--color-neon-yellow); +} +.custom-range-accent { + --range-color: var(--color-neon-cyan); +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..36dd62c --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + AG + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c948359 --- /dev/null +++ b/public/index.html @@ -0,0 +1,379 @@ + + + + + + + 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..c2b4593 --- /dev/null +++ b/public/js/app-init.js @@ -0,0 +1,137 @@ +/** + * App Initialization (Non-module version) + * This must load BEFORE Alpine initializes + */ + +document.addEventListener('alpine:init', () => { + // App component registration + + // 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() { + // App component initialization + + // Theme setup + document.documentElement.setAttribute('data-theme', 'black'); + document.documentElement.classList.add('dark'); + + // Chart Defaults + if (typeof Chart !== 'undefined') { + 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'; + } + + // Start Data Polling + this.startAutoRefresh(); + document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh()); + + // Initial Fetch + Alpine.store('data').fetchData(); + }, + + refreshTimer: null, + isTabVisible: true, + + fetchData() { + Alpine.store('data').fetchData(); + }, + + startAutoRefresh() { + if (this.refreshTimer) clearInterval(this.refreshTimer); + const baseInterval = parseInt(Alpine.store('settings').refreshInterval); + if (baseInterval > 0) { + // Setup visibility change listener (only once) + if (!this._visibilitySetup) { + this._visibilitySetup = true; + document.addEventListener('visibilitychange', () => { + this.isTabVisible = !document.hidden; + if (this.isTabVisible) { + // Tab became visible - fetch immediately and restart timer + Alpine.store('data').fetchData(); + this.startAutoRefresh(); + } + }); + } + + // Schedule next refresh with jitter + const scheduleNext = () => { + // Add ±20% random jitter to prevent synchronized requests + const jitter = (Math.random() - 0.5) * 0.4; // -0.2 to +0.2 + const interval = baseInterval * (1 + jitter); + + // Slow down when tab is hidden (reduce frequency by 3x) + const actualInterval = this.isTabVisible + ? interval + : interval * 3; + + this.refreshTimer = setTimeout(() => { + Alpine.store('data').fetchData(); + scheduleNext(); // Reschedule with new jitter + }, actualInterval * 1000); + }; + + scheduleNext(); + } + }, + + // 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..8324b9a --- /dev/null +++ b/public/js/components/account-manager.js @@ -0,0 +1,202 @@ +/** + * Account Manager Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.accountManager = () => ({ + searchQuery: '', + deleteTarget: '', + + get filteredAccounts() { + const accounts = Alpine.store('data').accounts || []; + if (!this.searchQuery || this.searchQuery.trim() === '') { + return accounts; + } + + const query = this.searchQuery.toLowerCase().trim(); + return accounts.filter(acc => { + return acc.email.toLowerCase().includes(query) || + (acc.projectId && acc.projectId.toLowerCase().includes(query)) || + (acc.source && acc.source.toLowerCase().includes(query)); + }); + }, + + formatEmail(email) { + if (!email || email.length <= 40) return email; + + const [user, domain] = email.split('@'); + if (!domain) return email; + + // Preserve domain integrity, truncate username if needed + if (user.length > 20) { + return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`; + } + return email; + }, + + async refreshAccount(email) { + 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) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('refreshedAccount', { email }), 'success'); + Alpine.store('data').fetchData(); + } else { + store.showToast(data.error || store.t('refreshFailed'), 'error'); + } + } catch (e) { + store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error'); + } + }, + + async toggleAccount(email, enabled) { + const store = Alpine.store('global'); + const password = store.webuiPassword; + + // Optimistic update: immediately update UI + const dataStore = Alpine.store('data'); + const account = dataStore.accounts.find(a => a.email === email); + if (account) { + account.enabled = enabled; + } + + 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) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus'); + store.showToast(store.t('accountToggled', { email, status }), 'success'); + // Refresh to confirm server state + await dataStore.fetchData(); + } else { + store.showToast(data.error || store.t('toggleFailed'), 'error'); + // Rollback optimistic update on error + if (account) { + account.enabled = !enabled; + } + await dataStore.fetchData(); + } + } catch (e) { + store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error'); + // Rollback optimistic update on error + if (account) { + account.enabled = !enabled; + } + await dataStore.fetchData(); + } + }, + + async fixAccount(email) { + 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) 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 { + store.showToast(data.error || store.t('authUrlFailed'), 'error'); + } + } catch (e) { + store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error'); + } + }, + + confirmDeleteAccount(email) { + this.deleteTarget = email; + document.getElementById('delete_account_modal').showModal(); + }, + + async executeDelete() { + const email = this.deleteTarget; + const store = Alpine.store('global'); + const password = store.webuiPassword; + + try { + const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('deletedAccount', { email }), 'success'); + Alpine.store('data').fetchData(); + document.getElementById('delete_account_modal').close(); + this.deleteTarget = ''; + } else { + store.showToast(data.error || store.t('deleteFailed'), 'error'); + } + } catch (e) { + store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error'); + } + }, + + async reloadAccounts() { + 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) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('accountsReloaded'), 'success'); + Alpine.store('data').fetchData(); + } else { + store.showToast(data.error || store.t('reloadFailed'), 'error'); + } + } catch (e) { + store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error'); + } + }, + + /** + * Get main model quota for display + * Prioritizes flagship models (Opus > Sonnet > Flash) + * @param {Object} account - Account object with limits + * @returns {Object} { percent: number|null, model: string } + */ + getMainModelQuota(account) { + const limits = account.limits || {}; + const modelIds = Object.keys(limits); + + if (modelIds.length === 0) { + return { percent: null, model: '-' }; + } + + // Priority: opus > sonnet > flash > others + const priorityModels = [ + modelIds.find(m => m.toLowerCase().includes('opus')), + modelIds.find(m => m.toLowerCase().includes('sonnet')), + modelIds.find(m => m.toLowerCase().includes('flash')), + modelIds[0] // Fallback to first model + ]; + + const selectedModel = priorityModels.find(m => m) || modelIds[0]; + const quota = limits[selectedModel]; + + if (!quota || quota.remainingFraction === null) { + return { percent: null, model: selectedModel }; + } + + return { + percent: Math.round(quota.remainingFraction * 100), + model: selectedModel + }; + } +}); diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js new file mode 100644 index 0000000..3d91702 --- /dev/null +++ b/public/js/components/claude-config.js @@ -0,0 +1,143 @@ +/** + * 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, + gemini1mSuffix: false, + + // Model fields that may contain Gemini model names + geminiModelFields: [ + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL' + ], + + init() { + // Only fetch config if this is the active sub-tab + if (this.activeTab === 'claude') { + this.fetchConfig(); + } + + // Watch local activeTab (from parent settings scope, skip initial trigger) + this.$watch('activeTab', (tab, oldTab) => { + if (tab === 'claude' && oldTab !== undefined) { + this.fetchConfig(); + } + }); + + this.$watch('$store.data.models', (val) => { + this.models = val || []; + }); + this.models = Alpine.store('data').models || []; + }, + + /** + * Detect if any Gemini model has [1m] suffix + */ + detectGemini1mSuffix() { + for (const field of this.geminiModelFields) { + const val = this.config.env[field]; + if (val && val.toLowerCase().includes('gemini') && val.includes('[1m]')) { + return true; + } + } + return false; + }, + + /** + * Toggle [1m] suffix for all Gemini models + */ + toggleGemini1mSuffix(enabled) { + for (const field of this.geminiModelFields) { + const val = this.config.env[field]; + // Fix: Case-insensitive check for gemini + if (val && /gemini/i.test(val)) { + if (enabled && !val.includes('[1m]')) { + this.config.env[field] = val.trim() + ' [1m]'; + } else if (!enabled && val.includes('[1m]')) { + this.config.env[field] = val.replace(/\s*\[1m\]$/i, '').trim(); + } + } + } + this.gemini1mSuffix = enabled; + }, + + /** + * Helper to select a model from the dropdown + * @param {string} field - The config.env field to update + * @param {string} modelId - The selected model ID + */ + selectModel(field, modelId) { + if (!this.config.env) this.config.env = {}; + + let finalModelId = modelId; + // If 1M mode is enabled and it's a Gemini model, append the suffix + if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) { + if (!finalModelId.includes('[1m]')) { + finalModelId = finalModelId.trim() + ' [1m]'; + } + } + + this.config.env[field] = finalModelId; + }, + + 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 = {}; + + // Default MCP CLI to true if not set + if (this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI === undefined) { + this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI = 'true'; + } + + // Detect existing [1m] suffix state, default to true + const hasExistingSuffix = this.detectGemini1mSuffix(); + const hasGeminiModels = this.geminiModelFields.some(f => + this.config.env[f]?.toLowerCase().includes('gemini') + ); + + // Default to enabled: if no suffix found but Gemini models exist, apply suffix + if (!hasExistingSuffix && hasGeminiModels) { + this.toggleGemini1mSuffix(true); + } else { + this.gemini1mSuffix = hasExistingSuffix || !hasGeminiModels; + } + } 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(Alpine.store('global').t('claudeConfigSaved'), 'success'); + } catch (e) { + 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 new file mode 100644 index 0000000..74ccd4b --- /dev/null +++ b/public/js/components/dashboard.js @@ -0,0 +1,220 @@ +/** + * Dashboard Component (Refactored) + * Orchestrates stats, charts, and filters modules + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.dashboard = () => ({ + // Core state + stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false }, + hasFilteredTrendData: true, + charts: { quotaDistribution: null, usageTrend: null }, + usageStats: { total: 0, today: 0, thisHour: 0 }, + historyData: {}, + modelTree: {}, + families: [], + + // Filter state (from module) + ...window.DashboardFilters.getInitialState(), + + // Debounced chart update to prevent rapid successive updates + _debouncedUpdateTrendChart: null, + + init() { + // Create debounced version of updateTrendChart (300ms delay for stability) + this._debouncedUpdateTrendChart = window.utils.debounce(() => { + window.DashboardCharts.updateTrendChart(this); + }, 300); + + // Load saved preferences from localStorage + window.DashboardFilters.loadPreferences(this); + + // Update stats when dashboard becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'dashboard' && oldVal !== undefined) { + this.$nextTick(() => { + this.updateStats(); + this.updateCharts(); + this.updateTrendChart(); + }); + } + }); + + // Watch for data changes + this.$watch('$store.data.accounts', () => { + if (this.$store.global.activeTab === 'dashboard') { + this.updateStats(); + this.$nextTick(() => this.updateCharts()); + } + }); + + // Watch for history updates from data-store (automatically loaded with account data) + this.$watch('$store.data.usageHistory', (newHistory) => { + if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) { + this.historyData = newHistory; + this.processHistory(newHistory); + this.stats.hasTrendData = true; + } + }); + + // Initial update if already on dashboard + if (this.$store.global.activeTab === 'dashboard') { + this.$nextTick(() => { + this.updateStats(); + this.updateCharts(); + + // Load history if already in store + const history = Alpine.store('data').usageHistory; + if (history && Object.keys(history).length > 0) { + this.historyData = history; + this.processHistory(history); + 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); + } + }); + } + }); + + // 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(); + }, + + // Delegation methods for stats + updateStats() { + window.DashboardStats.updateStats(this); + }, + + // Delegation methods for charts + updateCharts() { + window.DashboardCharts.updateCharts(this); + }, + + updateTrendChart() { + // Use debounced version to prevent rapid successive updates + if (this._debouncedUpdateTrendChart) { + this._debouncedUpdateTrendChart(); + } else { + // Fallback if debounced version not initialized + window.DashboardCharts.updateTrendChart(this); + } + }, + + // Delegation methods for filters + loadPreferences() { + window.DashboardFilters.loadPreferences(this); + }, + + savePreferences() { + window.DashboardFilters.savePreferences(this); + }, + + setDisplayMode(mode) { + window.DashboardFilters.setDisplayMode(this, mode); + }, + + setTimeRange(range) { + window.DashboardFilters.setTimeRange(this, range); + }, + + getTimeRangeLabel() { + return window.DashboardFilters.getTimeRangeLabel(this); + }, + + toggleFamily(family) { + window.DashboardFilters.toggleFamily(this, family); + }, + + toggleModel(family, model) { + window.DashboardFilters.toggleModel(this, family, model); + }, + + isFamilySelected(family) { + return window.DashboardFilters.isFamilySelected(this, family); + }, + + isModelSelected(family, model) { + return window.DashboardFilters.isModelSelected(this, family, model); + }, + + selectAll() { + window.DashboardFilters.selectAll(this); + }, + + deselectAll() { + window.DashboardFilters.deselectAll(this); + }, + + getFamilyColor(family) { + return window.DashboardFilters.getFamilyColor(family); + }, + + getModelColor(family, modelIndex) { + return window.DashboardFilters.getModelColor(family, modelIndex); + }, + + getSelectedCount() { + return window.DashboardFilters.getSelectedCount(this); + }, + + autoSelectNew() { + window.DashboardFilters.autoSelectNew(this); + }, + + autoSelectTopN(n = 5) { + window.DashboardFilters.autoSelectTopN(this, n); + } +}); diff --git a/public/js/components/dashboard/charts.js b/public/js/components/dashboard/charts.js new file mode 100644 index 0000000..0767865 --- /dev/null +++ b/public/js/components/dashboard/charts.js @@ -0,0 +1,520 @@ +/** + * Dashboard Charts Module + * Handles Chart.js visualizations (quota distribution & usage trend) + */ +window.DashboardCharts = window.DashboardCharts || {}; + +// 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}`) +); + +// Export constants for filter module +window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS }; + +// Module-level lock to prevent concurrent chart updates (fixes race condition) +let _trendChartUpdateLock = false; + +/** + * Convert hex color to rgba + * @param {string} hex - Hex color string + * @param {number} alpha - Alpha value (0-1) + * @returns {string} rgba color string + */ +window.DashboardCharts.hexToRgba = function (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; +}; + +/** + * Check if canvas is ready for Chart creation + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {boolean} True if canvas is ready + */ +function isCanvasReady(canvas) { + if (!canvas || !canvas.isConnected) return false; + if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false; + + try { + const ctx = canvas.getContext("2d"); + return !!ctx; + } catch (e) { + return false; + } +} + +/** + * Create a Chart.js dataset with gradient fill + * @param {string} label - Dataset label + * @param {Array} data - Data points + * @param {string} color - Line color + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {object} Chart.js dataset configuration + */ +window.DashboardCharts.createDataset = function (label, data, color, canvas) { + let gradient; + + try { + // Safely create gradient with fallback + if (canvas && canvas.getContext) { + const ctx = canvas.getContext("2d"); + if (ctx && ctx.createLinearGradient) { + gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12)); + gradient.addColorStop( + 0.6, + window.DashboardCharts.hexToRgba(color, 0.05) + ); + gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + } + } + } catch (e) { + console.warn("Failed to create gradient, using solid color fallback:", e); + gradient = null; + } + + // Fallback to solid color if gradient creation failed + const backgroundColor = + gradient || window.DashboardCharts.hexToRgba(color, 0.08); + + return { + label, + data, + borderColor: color, + backgroundColor: backgroundColor, + borderWidth: 2.5, + tension: 0.35, + fill: true, + pointRadius: 2.5, + pointHoverRadius: 6, + pointBackgroundColor: color, + pointBorderColor: "rgba(9, 9, 11, 0.8)", + pointBorderWidth: 1.5, + }; +}; + +/** + * Update quota distribution donut chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateCharts = function (component) { + // Safely destroy existing chart instance FIRST + if (component.charts.quotaDistribution) { + try { + component.charts.quotaDistribution.destroy(); + } catch (e) { + console.error("Failed to destroy quota chart:", e); + } + component.charts.quotaDistribution = null; + } + + const canvas = document.getElementById("quotaChart"); + + // Safety checks + if (!canvas) { + console.warn("quotaChart canvas not found"); + return; + } + if (typeof Chart === "undefined") { + console.warn("Chart.js not loaded"); + return; + } + if (!isCanvasReady(canvas)) { + console.warn("quotaChart canvas not ready, skipping update"); + return; + } + + // Use UNFILTERED data for global health chart + const rows = Alpine.store("data").getUnfilteredQuotaData(); + if (!rows || rows.length === 0) return; + + const healthByFamily = {}; + let totalHealthSum = 0; + let totalModelCount = 0; + + rows.forEach((row) => { + const family = row.family || "unknown"; + if (!healthByFamily[family]) { + healthByFamily[family] = { total: 0, weighted: 0 }; + } + + // Calculate average health from quotaInfo (each entry has { pct }) + // Health = average of all account quotas for this model + const quotaInfo = row.quotaInfo || []; + if (quotaInfo.length > 0) { + const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length; + healthByFamily[family].total++; + healthByFamily[family].weighted += avgHealth; + totalHealthSum += avgHealth; + totalModelCount++; + } + }); + + // Update overall health for dashboard display + component.stats.overallHealth = totalModelCount > 0 + ? Math.round(totalHealthSum / totalModelCount) + : 0; + + const familyColors = { + claude: getThemeColor("--color-neon-purple"), + gemini: getThemeColor("--color-neon-green"), + unknown: getThemeColor("--color-neon-cyan"), + }; + + const data = []; + const colors = []; + const labels = []; + + const totalFamilies = Object.keys(healthByFamily).length; + const segmentSize = 100 / totalFamilies; + + Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => { + const health = weighted / total; + const activeVal = (health / 100) * segmentSize; + const inactiveVal = segmentSize - activeVal; + + const familyColor = familyColors[family] || familyColors["unknown"]; + + // Get translation keys + const store = Alpine.store("global"); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const familyName = store.t(familyKey); + + // Labels using translations if possible + const activeLabel = + family === "claude" + ? store.t("claudeActive") + : family === "gemini" + ? store.t("geminiActive") + : `${familyName} ${store.t("activeSuffix")}`; + + const depletedLabel = + family === "claude" + ? store.t("claudeEmpty") + : family === "gemini" + ? store.t("geminiEmpty") + : `${familyName} ${store.t("depleted")}`; + + // Active segment + data.push(activeVal); + colors.push(familyColor); + labels.push(activeLabel); + + // Inactive segment + data.push(inactiveVal); + colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.1)); + labels.push(depletedLabel); + }); + + try { + component.charts.quotaDistribution = new Chart(canvas, { + type: "doughnut", + data: { + labels: labels, + datasets: [ + { + data: data, + backgroundColor: colors, + borderColor: getThemeColor("--color-space-950"), + borderWidth: 2, + hoverOffset: 0, + borderRadius: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "85%", + rotation: -90, + circumference: 360, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + title: { display: false }, + }, + animation: { + animateScale: true, + animateRotate: true, + }, + }, + }); + } catch (e) { + console.error("Failed to create quota chart:", e); + } +}; + +/** + * Update usage trend line chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateTrendChart = function (component) { + // Prevent concurrent updates (fixes race condition on rapid toggling) + if (_trendChartUpdateLock) { + console.log("[updateTrendChart] Update already in progress, skipping"); + return; + } + _trendChartUpdateLock = true; + + console.log("[updateTrendChart] Starting update..."); + + // Safely destroy existing chart instance FIRST + if (component.charts.usageTrend) { + console.log("[updateTrendChart] Destroying existing chart"); + try { + // Stop all animations before destroying to prevent null context errors + component.charts.usageTrend.stop(); + component.charts.usageTrend.destroy(); + } catch (e) { + console.error("[updateTrendChart] Failed to destroy chart:", e); + } + component.charts.usageTrend = null; + } + + const canvas = document.getElementById("usageTrendChart"); + + // Safety checks + if (!canvas) { + console.error("[updateTrendChart] Canvas not found in DOM!"); + return; + } + if (typeof Chart === "undefined") { + console.error("[updateTrendChart] Chart.js not loaded"); + return; + } + + console.log("[updateTrendChart] Canvas element:", { + exists: !!canvas, + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + parentElement: canvas.parentElement?.tagName, + }); + + if (!isCanvasReady(canvas)) { + console.error("[updateTrendChart] Canvas not ready!", { + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + }); + _trendChartUpdateLock = false; + return; + } + + // Clear canvas to ensure clean state after destroy + try { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } catch (e) { + console.warn("[updateTrendChart] Failed to clear canvas:", e); + } + + console.log( + "[updateTrendChart] Canvas is ready, proceeding with chart creation" + ); + + // Use filtered history data based on time range + const history = window.DashboardFilters.getFilteredHistoryData(component); + if (!history || Object.keys(history).length === 0) { + console.warn("No history data available for trend chart (after filtering)"); + component.hasFilteredTrendData = false; + _trendChartUpdateLock = false; + return; + } + + component.hasFilteredTrendData = true; + + // Sort entries by timestamp for correct order + const sortedEntries = Object.entries(history).sort( + ([a], [b]) => new Date(a).getTime() - new Date(b).getTime() + ); + + // Determine if data spans multiple days (for smart label formatting) + const timestamps = sortedEntries.map(([iso]) => new Date(iso)); + const isMultiDay = timestamps.length > 1 && + timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString(); + + // Helper to format X-axis labels based on time range and multi-day status + const formatLabel = (date) => { + const timeRange = component.timeRange || '24h'; + + if (timeRange === '7d') { + // Week view: show MM/DD + return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }); + } else if (isMultiDay || timeRange === 'all') { + // Multi-day data: show MM/DD HH:MM + return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' + + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + // Same day: show HH:MM only + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + }; + + const labels = []; + const datasets = []; + + if (component.displayMode === "family") { + // Aggregate by family + const dataByFamily = {}; + component.selectedFamilies.forEach((family) => { + dataByFamily[family] = []; + }); + + sortedEntries.forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(formatLabel(date)); + + component.selectedFamilies.forEach((family) => { + const familyData = hourData[family]; + const count = familyData?._subtotal || 0; + dataByFamily[family].push(count); + }); + }); + + // Build datasets for families + component.selectedFamilies.forEach((family) => { + const color = window.DashboardFilters.getFamilyColor(family); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const label = Alpine.store("global").t(familyKey); + datasets.push( + window.DashboardCharts.createDataset( + label, + dataByFamily[family], + color, + canvas + ) + ); + }); + } else { + // Show individual models + const dataByModel = {}; + + // Initialize data arrays + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key] = []; + }); + }); + + sortedEntries.forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(formatLabel(date)); + + component.families.forEach((family) => { + const familyData = hourData[family] || {}; + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key].push(familyData[model] || 0); + }); + }); + }); + + // Build datasets for models + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model, modelIndex) => { + const key = `${family}:${model}`; + const color = window.DashboardFilters.getModelColor(family, modelIndex); + datasets.push( + window.DashboardCharts.createDataset( + model, + dataByModel[key], + color, + canvas + ) + ); + }); + }); + } + + try { + component.charts.usageTrend = new Chart(canvas, { + type: "line", + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 300, // Reduced animation for faster updates + }, + 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 }, + }, + }, + }, + }, + }); + } catch (e) { + console.error("Failed to create trend chart:", e); + } finally { + // Always release lock + _trendChartUpdateLock = false; + } +}; diff --git a/public/js/components/dashboard/filters.js b/public/js/components/dashboard/filters.js new file mode 100644 index 0000000..a420518 --- /dev/null +++ b/public/js/components/dashboard/filters.js @@ -0,0 +1,347 @@ +/** + * Dashboard Filters Module + * Handles model/family filter selection and persistence + */ +window.DashboardFilters = window.DashboardFilters || {}; + +/** + * Get initial filter state + * @returns {object} Initial state for filter properties + */ +window.DashboardFilters.getInitialState = function() { + return { + timeRange: '24h', // '1h', '6h', '24h', '7d', 'all' + displayMode: 'model', + selectedFamilies: [], + selectedModels: {}, + showModelFilter: false, + showTimeRangeDropdown: false, + showDisplayModeDropdown: false + }; +}; + +/** + * Load filter preferences from localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.loadPreferences = function(component) { + try { + const saved = localStorage.getItem('dashboard_chart_prefs'); + if (saved) { + const prefs = JSON.parse(saved); + component.timeRange = prefs.timeRange || '24h'; + component.displayMode = prefs.displayMode || 'model'; + component.selectedFamilies = prefs.selectedFamilies || []; + component.selectedModels = prefs.selectedModels || {}; + } + } catch (e) { + console.error('Failed to load dashboard preferences:', e); + } +}; + +/** + * Save filter preferences to localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.savePreferences = function(component) { + try { + localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ + timeRange: component.timeRange, + displayMode: component.displayMode, + selectedFamilies: component.selectedFamilies, + selectedModels: component.selectedModels + })); + } catch (e) { + console.error('Failed to save dashboard preferences:', e); + } +}; + +/** + * Set display mode (family or model) + * @param {object} component - Dashboard component instance + * @param {string} mode - 'family' or 'model' + */ +window.DashboardFilters.setDisplayMode = function(component, mode) { + component.displayMode = mode; + component.showDisplayModeDropdown = false; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Set time range filter + * @param {object} component - Dashboard component instance + * @param {string} range - '1h', '6h', '24h', '7d', 'all' + */ +window.DashboardFilters.setTimeRange = function(component, range) { + component.timeRange = range; + component.showTimeRangeDropdown = false; + window.DashboardFilters.savePreferences(component); + component.updateTrendChart(); +}; + +/** + * Get time range cutoff timestamp + * @param {string} range - Time range code + * @returns {number|null} Cutoff timestamp or null for 'all' + */ +window.DashboardFilters.getTimeRangeCutoff = function(range) { + const now = Date.now(); + switch (range) { + case '1h': return now - 1 * 60 * 60 * 1000; + case '6h': return now - 6 * 60 * 60 * 1000; + case '24h': return now - 24 * 60 * 60 * 1000; + case '7d': return now - 7 * 24 * 60 * 60 * 1000; + default: return null; // 'all' + } +}; + +/** + * Get filtered history data based on time range + * @param {object} component - Dashboard component instance + * @returns {object} Filtered history data + */ +window.DashboardFilters.getFilteredHistoryData = function(component) { + const history = component.historyData; + if (!history || Object.keys(history).length === 0) return {}; + + const cutoff = window.DashboardFilters.getTimeRangeCutoff(component.timeRange); + if (!cutoff) return history; // 'all' - return everything + + const filtered = {}; + Object.entries(history).forEach(([iso, data]) => { + const timestamp = new Date(iso).getTime(); + if (timestamp >= cutoff) { + filtered[iso] = data; + } + }); + return filtered; +}; + +/** + * Get time range label for display + * @param {object} component - Dashboard component instance + * @returns {string} Translated label + */ +window.DashboardFilters.getTimeRangeLabel = function(component) { + const store = Alpine.store('global'); + switch (component.timeRange) { + case '1h': return store.t('last1Hour'); + case '6h': return store.t('last6Hours'); + case '24h': return store.t('last24Hours'); + case '7d': return store.t('last7Days'); + default: return store.t('allTime'); + } +}; + +/** + * Toggle family selection + * @param {object} component - Dashboard component instance + * @param {string} family - Family name (e.g., 'claude', 'gemini') + */ +window.DashboardFilters.toggleFamily = function(component, family) { + const index = component.selectedFamilies.indexOf(family); + if (index > -1) { + component.selectedFamilies.splice(index, 1); + } else { + component.selectedFamilies.push(family); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Toggle model selection within a family + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + */ +window.DashboardFilters.toggleModel = function(component, family, model) { + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + const index = component.selectedModels[family].indexOf(model); + if (index > -1) { + component.selectedModels[family].splice(index, 1); + } else { + component.selectedModels[family].push(model); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Check if family is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @returns {boolean} + */ +window.DashboardFilters.isFamilySelected = function(component, family) { + return component.selectedFamilies.includes(family); +}; + +/** + * Check if model is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + * @returns {boolean} + */ +window.DashboardFilters.isModelSelected = function(component, family, model) { + return component.selectedModels[family]?.includes(model) || false; +}; + +/** + * Select all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.selectAll = function(component) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Deselect all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.deselectAll = function(component) { + component.selectedFamilies = []; + component.selectedModels = {}; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Get color for a family + * @param {string} family - Family name + * @returns {string} Color value + */ +window.DashboardFilters.getFamilyColor = function(family) { + const FAMILY_COLORS = window.DashboardConstants?.FAMILY_COLORS || {}; + return FAMILY_COLORS[family] || FAMILY_COLORS.other; +}; + +/** + * Get color for a model (with index for variation within family) + * @param {string} family - Family name + * @param {number} modelIndex - Index of model within family + * @returns {string} Color value + */ +window.DashboardFilters.getModelColor = function(family, modelIndex) { + const MODEL_COLORS = window.DashboardConstants?.MODEL_COLORS || []; + const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8); + return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length]; +}; + +/** + * Get count of selected items for display + * @param {object} component - Dashboard component instance + * @returns {string} Selected count string (e.g., "3/5") + */ +window.DashboardFilters.getSelectedCount = function(component) { + if (component.displayMode === 'family') { + return `${component.selectedFamilies.length}/${component.families.length}`; + } + let selected = 0, total = 0; + component.families.forEach(family => { + const models = component.modelTree[family] || []; + total += models.length; + selected += (component.selectedModels[family] || []).length; + }); + return `${selected}/${total}`; +}; + +/** + * Auto-select new families/models that haven't been configured + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.autoSelectNew = function(component) { + // If no preferences saved, select all + if (component.selectedFamilies.length === 0 && Object.keys(component.selectedModels).length === 0) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + return; + } + + // Add new families/models that appeared + component.families.forEach(family => { + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + (component.modelTree[family] || []).forEach(model => { + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + }); +}; + +/** + * Auto-select top N models by usage (past 24 hours) + * @param {object} component - Dashboard component instance + * @param {number} n - Number of models to select (default: 5) + */ +window.DashboardFilters.autoSelectTopN = function(component, 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(component.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 + component.selectedFamilies = []; + component.selectedModels = {}; + + // Select top models and their families + sorted.forEach(([key, _]) => { + const [family, model] = key.split(':'); + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; diff --git a/public/js/components/dashboard/stats.js b/public/js/components/dashboard/stats.js new file mode 100644 index 0000000..53ad22a --- /dev/null +++ b/public/js/components/dashboard/stats.js @@ -0,0 +1,57 @@ +/** + * Dashboard Stats Module + * Handles account statistics calculation + */ +window.DashboardStats = window.DashboardStats || {}; + +/** + * Update account statistics (active, limited, total) + * @param {object} component - Dashboard component instance + */ +window.DashboardStats.updateStats = function(component) { + const accounts = Alpine.store('data').accounts; + let active = 0, limited = 0; + + const isCore = (id) => /sonnet|opus|pro|flash/i.test(id); + + // Only count enabled accounts in statistics + const enabledAccounts = accounts.filter(acc => acc.enabled !== false); + + enabledAccounts.forEach(acc => { + if (acc.status === 'ok') { + 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++; + } + }); + + // TOTAL shows only enabled accounts + // Disabled accounts are excluded from all statistics + component.stats.total = enabledAccounts.length; + component.stats.active = active; + component.stats.limited = limited; + + // Calculate subscription tier distribution + const subscription = { ultra: 0, pro: 0, free: 0 }; + enabledAccounts.forEach(acc => { + const tier = acc.subscription?.tier || 'free'; + if (tier === 'ultra') { + subscription.ultra++; + } else if (tier === 'pro') { + subscription.pro++; + } else { + subscription.free++; + } + }); + component.stats.subscription = subscription; +}; diff --git a/public/js/components/logs-viewer.js b/public/js/components/logs-viewer.js new file mode 100644 index 0000000..969032f --- /dev/null +++ b/public/js/components/logs-viewer.js @@ -0,0 +1,100 @@ +/** + * 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.trim(); + if (!query) { + return this.logs.filter(log => this.filters[log.level]); + } + + // Try regex first, fallback to plain text search + let matcher; + try { + const regex = new RegExp(query, 'i'); + matcher = (msg) => regex.test(msg); + } catch (e) { + // Invalid regex, fallback to case-insensitive string search + const lowerQuery = query.toLowerCase(); + matcher = (msg) => msg.toLowerCase().includes(lowerQuery); + } + + return this.logs.filter(log => { + // Level Filter + if (!this.filters[log.level]) return false; + + // Search Filter + return matcher(log.message); + }); + }, + + 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 || window.AppConstants.LIMITS.DEFAULT_LOG_LIMIT; + 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/components/model-manager.js b/public/js/components/model-manager.js new file mode 100644 index 0000000..607cc42 --- /dev/null +++ b/public/js/components/model-manager.js @@ -0,0 +1,47 @@ +/** + * Model Manager Component + * Handles model configuration (pinning, hiding, aliasing, mapping) + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.modelManager = () => ({ + // Track which model is currently being edited (null = none) + editingModelId: null, + + init() { + // Component is ready + }, + + /** + * Start editing a model's mapping + * @param {string} modelId - The model to edit + */ + startEditing(modelId) { + this.editingModelId = modelId; + }, + + /** + * Stop editing + */ + stopEditing() { + this.editingModelId = null; + }, + + /** + * Check if a model is being edited + * @param {string} modelId - The model to check + */ + isEditing(modelId) { + return this.editingModelId === modelId; + }, + + /** + * Update model configuration (delegates to shared utility) + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) + */ + async updateModelConfig(modelId, configUpdates) { + return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); + } +}); diff --git a/public/js/components/models.js b/public/js/components/models.js new file mode 100644 index 0000000..68f6b40 --- /dev/null +++ b/public/js/components/models.js @@ -0,0 +1,36 @@ +/** + * Models Component + * Displays model quota/status list + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.models = () => ({ + init() { + // Ensure data is fetched when this tab becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'models' && oldVal !== undefined) { + // Trigger recompute to ensure filters are applied + this.$nextTick(() => { + Alpine.store('data').computeQuotaRows(); + }); + } + }); + + // Initial compute if already on models tab + if (this.$store.global.activeTab === 'models') { + this.$nextTick(() => { + Alpine.store('data').computeQuotaRows(); + }); + } + }, + + /** + * Update model configuration (delegates to shared utility) + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden) + */ + async updateModelConfig(modelId, configUpdates) { + return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); + } +}); diff --git a/public/js/components/server-config.js b/public/js/components/server-config.js new file mode 100644 index 0000000..5ab7f8b --- /dev/null +++ b/public/js/components/server-config.js @@ -0,0 +1,252 @@ +/** + * Server Config Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.serverConfig = () => ({ + serverConfig: {}, + loading: false, + advancedExpanded: false, + debounceTimers: {}, // Store debounce timers for each config field + + init() { + // Initial fetch if this is the active sub-tab + if (this.activeTab === 'server') { + this.fetchServerConfig(); + } + + // Watch local activeTab (from parent settings scope, skip initial trigger) + this.$watch('activeTab', (tab, oldTab) => { + if (tab === 'server' && oldTab !== undefined) { + this.fetchServerConfig(); + } + }); + }, + + async fetchServerConfig() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/config', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error('Failed to fetch config'); + const data = await response.json(); + this.serverConfig = data.config || {}; + } catch (e) { + console.error('Failed to fetch server config:', e); + } + }, + + + + // Password management + passwordDialog: { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }, + + showPasswordDialog() { + this.passwordDialog = { + show: true, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + hidePasswordDialog() { + this.passwordDialog = { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + async changePassword() { + const store = Alpine.store('global'); + const { oldPassword, newPassword, confirmPassword } = this.passwordDialog; + + if (newPassword !== confirmPassword) { + store.showToast(store.t('passwordsNotMatch'), 'error'); + return; + } + if (newPassword.length < 6) { + store.showToast(store.t('passwordTooShort'), 'error'); + return; + } + + try { + const { response } = await window.utils.request('/api/config/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPassword, newPassword }) + }, store.webuiPassword); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to change password'); + } + + // Update stored password + store.webuiPassword = newPassword; + store.showToast('Password changed successfully', 'success'); + this.hidePasswordDialog(); + } catch (e) { + store.showToast('Failed to change password: ' + e.message, 'error'); + } + }, + + // Toggle Debug Mode with instant save + async toggleDebug(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousValue = this.serverConfig.debug; + this.serverConfig.debug = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ debug: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? 'enabled' : 'disabled'; + store.showToast(`Debug mode ${status}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || 'Failed to update debug mode'); + } + } catch (e) { + // Rollback on error + this.serverConfig.debug = previousValue; + store.showToast('Failed to update debug mode: ' + e.message, 'error'); + } + }, + + // Toggle Token Cache with instant save + async toggleTokenCache(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousValue = this.serverConfig.persistTokenCache; + this.serverConfig.persistTokenCache = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ persistTokenCache: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? 'enabled' : 'disabled'; + store.showToast(`Token cache ${status}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || 'Failed to update token cache'); + } + } catch (e) { + // Rollback on error + this.serverConfig.persistTokenCache = previousValue; + store.showToast('Failed to update token cache: ' + e.message, 'error'); + } + }, + + // Generic debounced save method for numeric configs with validation + async saveConfigField(fieldName, value, displayName, validator = null) { + const store = Alpine.store('global'); + + // Validate input if validator provided + if (validator) { + const validation = window.Validators.validate(value, validator, true); + if (!validation.isValid) { + // Rollback to previous value + this.serverConfig[fieldName] = this.serverConfig[fieldName]; + return; + } + value = validation.value; + } else { + value = parseInt(value); + } + + // Clear existing timer for this field + if (this.debounceTimers[fieldName]) { + clearTimeout(this.debounceTimers[fieldName]); + } + + // Optimistic update + const previousValue = this.serverConfig[fieldName]; + this.serverConfig[fieldName] = value; + + // Set new timer + this.debounceTimers[fieldName] = setTimeout(async () => { + try { + const payload = {}; + payload[fieldName] = value; + + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(`${displayName} updated to ${value}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || `Failed to update ${displayName}`); + } + } catch (e) { + // Rollback on error + this.serverConfig[fieldName] = previousValue; + store.showToast(`Failed to update ${displayName}: ` + e.message, 'error'); + } + }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE); + }, + + // Individual toggle methods for each Advanced Tuning field with validation + toggleMaxRetries(value) { + const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxRetries', value, 'Max Retries', + (v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries')); + }, + + toggleRetryBaseMs(value) { + const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryBaseMs', value, 'Retry Base Delay', + (v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay')); + }, + + toggleRetryMaxMs(value) { + const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryMaxMs', value, 'Retry Max Delay', + (v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay')); + }, + + toggleDefaultCooldownMs(value) { + const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown', + (v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX)); + }, + + toggleMaxWaitBeforeErrorMs(value) { + const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold', + (v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX)); + } +}); diff --git a/public/js/config/constants.js b/public/js/config/constants.js new file mode 100644 index 0000000..a5a1b6c --- /dev/null +++ b/public/js/config/constants.js @@ -0,0 +1,82 @@ +/** + * Application Constants + * Centralized configuration values and magic numbers + */ +window.AppConstants = window.AppConstants || {}; + +/** + * Time intervals (in milliseconds) + */ +window.AppConstants.INTERVALS = { + // Dashboard refresh interval (5 minutes) + DASHBOARD_REFRESH: 300000, + + // OAuth message handler timeout (5 minutes) + OAUTH_MESSAGE_TIMEOUT: 300000, + + // Server config debounce delay + CONFIG_DEBOUNCE: 500, + + // General short delay (for UI transitions) + SHORT_DELAY: 2000 +}; + +/** + * Data limits and quotas + */ +window.AppConstants.LIMITS = { + // Default log limit + DEFAULT_LOG_LIMIT: 2000, + + // Minimum quota value + MIN_QUOTA: 100, + + // Percentage base (for calculations) + PERCENTAGE_BASE: 100 +}; + +/** + * Validation ranges + */ +window.AppConstants.VALIDATION = { + // Port range + PORT_MIN: 1, + PORT_MAX: 65535, + + // Timeout range (0 - 5 minutes) + TIMEOUT_MIN: 0, + TIMEOUT_MAX: 300000, + + // Log limit range + LOG_LIMIT_MIN: 100, + LOG_LIMIT_MAX: 10000, + + // Retry configuration ranges + MAX_RETRIES_MIN: 0, + MAX_RETRIES_MAX: 20, + + RETRY_BASE_MS_MIN: 100, + RETRY_BASE_MS_MAX: 10000, + + RETRY_MAX_MS_MIN: 1000, + RETRY_MAX_MS_MAX: 60000, + + // Cooldown range (0 - 10 minutes) + DEFAULT_COOLDOWN_MIN: 0, + DEFAULT_COOLDOWN_MAX: 600000, + + // Max wait threshold (1 - 30 minutes) + MAX_WAIT_MIN: 60000, + MAX_WAIT_MAX: 1800000 +}; + +/** + * UI Constants + */ +window.AppConstants.UI = { + // Toast auto-dismiss duration + TOAST_DURATION: 3000, + + // Loading spinner delay + LOADING_DELAY: 200 +}; diff --git a/public/js/data-store.js b/public/js/data-store.js new file mode 100644 index 0000000..4bcb24d --- /dev/null +++ b/public/js/data-store.js @@ -0,0 +1,214 @@ +/** + * 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 + usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) + 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; + + // Include history for dashboard (single API call optimization) + const url = '/account-limits?includeHistory=true'; + const { response, newPassword } = await window.utils.request(url, {}, 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 || {}; + + // Store usage history if included (for dashboard) + if (data.history) { + this.usageHistory = data.history; + } + + this.computeQuotaRows(); + + this.connectionStatus = 'connected'; + this.lastUpdated = new Date().toLocaleTimeString(); + } catch (error) { + console.error('Fetch error:', error); + this.connectionStatus = 'disconnected'; + const store = Alpine.store('global'); + store.showToast(store.t('connectionLost'), 'error'); + } finally { + this.loading = false; + } + }, + + computeQuotaRows() { + const models = this.models || []; + const rows = []; + const showExhausted = Alpine.store('settings')?.showExhausted ?? true; + + models.forEach(modelId => { + // Config + const config = this.modelConfig[modelId] || {}; + const family = this.getModelFamily(modelId); + + // Visibility Logic for Models Page (quotaRows): + // 1. If explicitly hidden via config, ALWAYS hide (clean interface) + // 2. If no config, default 'unknown' families to HIDDEN + // 3. Known families (Claude/Gemini) default to VISIBLE + // Note: To manage hidden models, use Settings → Models tab + let isHidden = config.hidden; + if (isHidden === undefined) { + isHidden = (family === 'other' || family === 'unknown'); + } + + // Models Page: ALWAYS hide hidden models (use Settings to restore) + if (isHidden) return; + + // Filters + if (this.filters.family !== 'all' && this.filters.family !== family) return; + if (this.filters.search) { + const searchLower = this.filters.search.toLowerCase(); + const idMatch = modelId.toLowerCase().includes(searchLower); + if (!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: modelId, // Simplified: no longer using alias + 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'; + }, + + /** + * Get quota data without filters applied (for Dashboard global charts) + * Returns array of { modelId, family, quotaInfo: [{pct}] } + */ + getUnfilteredQuotaData() { + const models = this.models || []; + const rows = []; + const showHidden = Alpine.store('settings')?.showHiddenModels ?? false; + + models.forEach(modelId => { + const config = this.modelConfig[modelId] || {}; + const family = this.getModelFamily(modelId); + + // Smart visibility (same logic as computeQuotaRows) + let isHidden = config.hidden; + if (isHidden === undefined) { + isHidden = (family === 'other' || family === 'unknown'); + } + if (isHidden && !showHidden) return; + + const quotaInfo = []; + // Use ALL accounts (no account filter) + this.accounts.forEach(acc => { + const limit = acc.limits?.[modelId]; + if (!limit) return; + const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0; + quotaInfo.push({ pct }); + }); + + if (quotaInfo.length === 0) return; + + rows.push({ modelId, family, quotaInfo }); + }); + + return rows; + } + }); +}); diff --git a/public/js/settings-store.js b/public/js/settings-store.js new file mode 100644 index 0000000..17916ad --- /dev/null +++ b/public/js/settings-store.js @@ -0,0 +1,58 @@ +/** + * 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) { + const store = Alpine.store('global'); + store.showToast(store.t('configSaved'), '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..15ee217 --- /dev/null +++ b/public/js/store.js @@ -0,0 +1,545 @@ +/** + * 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", + models: "Models", + 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", + maxDisplayLogs: "Max Displayed Logs", + 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", + accountManagement: "Account Management", + manageTokens: "Manage Google Account tokens and authorization states", + addAccount: "Add Account", + status: "STATUS", + enabled: "ENABLED", + health: "STATUS", + accountEmail: "ACCOUNT (EMAIL)", + source: "SOURCE", + projectId: "PROJECT ID", + sessionState: "SESSION STATE", + operations: "OPERATIONS", + delete: "Delete", + confirmDelete: "Are you sure you want to remove this account?", + cannotDeleteDatabase: "Cannot delete: This account is from Antigravity database (read-only)", + connectGoogle: "Connect Google Account", + reauthenticated: "re-authenticated", + added: "added", + successfully: "successfully", + accountAddedSuccess: "Account added successfully", + accountReauthSuccess: "Account re-authenticated successfully", + failedToGetAuthUrl: "Failed to get auth URL", + failedToStartOAuth: "Failed to start OAuth flow", + oauthInProgress: "OAuth in progress. Please complete authentication in the popup window...", + family: "Family", + model: "Model", + activeSuffix: "Active", + // Tabs + tabInterface: "Interface", + tabClaude: "Claude CLI", + tabModels: "Models", + tabServer: "Server Settings", + // Dashboard + linkedAccounts: "Linked Accounts", + noSignal: "NO SIGNAL DETECTED", + establishingUplink: "ESTABLISHING UPLINK...", + // Settings - Models + modelsDesc: "Configure model visibility, pinning, and request routing.", + modelsPageDesc: "Real-time quota and status for all available models.", + showHidden: "Show Hidden Models", + modelId: "Model ID", + actions: "Actions", + pinToTop: "Pin to top", + toggleVisibility: "Toggle Visibility", + noModels: "NO MODELS DETECTED", + modelMappingHint: "Server-side model routing. Claude Code users: see 'Claude CLI' tab for client-side setup.", + modelMapping: "Mapping (Target Model ID)", + // Settings - Claude + proxyConnection: "Proxy Connection", + modelSelection: "Model Selection", + defaultModelAliases: "DEFAULT MODEL ALIASES", + opusAlias: "Opus Alias", + sonnetAlias: "Sonnet Alias", + haikuAlias: "Haiku Alias", + claudeSettingsAlert: "Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply.", + applyToClaude: "Apply to Claude CLI", + // Settings - Server + port: "Port", + uiVersion: "UI Version", + debugMode: "Debug Mode", + environment: "Environment", + serverReadOnly: "Settings managed via config.json. Restart server to apply changes.", + advancedSettings: "Advanced Settings", + 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", + // 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", + synced: "SYNCED", + syncing: "SYNCING...", + // Time range labels + last1Hour: "Last 1H", + last6Hours: "Last 6H", + last24Hours: "Last 24H", + last7Days: "Last 7D", + allTime: "All Time", + groupBy: "Group By", + // Additional + reloading: "Reloading...", + reloaded: "Reloaded", + lines: "lines", + enabledSeeLogs: "Enabled (See Logs)", + production: "Production", + configSaved: "Configuration Saved", + enterPassword: "Enter Web UI Password:", + ready: "READY", + depleted: "Depleted", + timeH: "H", + timeM: "M", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Other", + enabledStatus: "enabled", + disabledStatus: "disabled", + logLevelInfo: "INFO", + logLevelSuccess: "SUCCESS", + logLevelWarn: "WARN", + logLevelError: "ERR", + totalColon: "Total:", + todayColon: "Today:", + hour1Colon: "1H:", + frequentModels: "Frequent", + smartTitle: "Auto-select top 5 most used models (24h)", + activeCount: "{count} Active", + allCaps: "ALL", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "Mapping (Target Model ID)", + systemInfo: "System Information", + refresh: "Refresh", + runtimeConfig: "Runtime Configuration", + debugDesc: "Enable detailed logging (See Logs tab)", + networkRetry: "Network Retry Settings", + maxRetries: "Max Retries", + retryBaseDelay: "Retry Base Delay (ms)", + retryMaxDelay: "Retry Max Delay (ms)", + persistentSessions: "Persistent Sessions", + persistTokenDesc: "Save OAuth sessions to disk for faster restarts", + rateLimiting: "Account Rate Limiting & Timeouts", + defaultCooldown: "Default Cooldown Time", + maxWaitThreshold: "Max Wait Threshold (Sticky)", + maxWaitDesc: "Maximum time to wait for a sticky account to reset before switching.", + saveConfigServer: "Save Configuration", + serverRestartAlert: "Changes saved to {path}. Restart server to apply some settings.", + changePassword: "Change WebUI Password", + changePasswordDesc: "Update the password for accessing this dashboard", + currentPassword: "Current Password", + newPassword: "New Password", + confirmNewPassword: "Confirm New Password", + passwordEmptyDesc: "Leave empty if no password set", + passwordLengthDesc: "At least 6 characters", + passwordConfirmDesc: "Re-enter new password", + cancel: "Cancel", + passwordsNotMatch: "Passwords do not match", + passwordTooShort: "Password must be at least 6 characters", + // Dashboard drill-down + clickToViewAllAccounts: "Click to view all accounts", + clickToViewModels: "Click to view Models page", + clickToViewLimitedAccounts: "Click to view rate-limited accounts", + clickToFilterClaude: "Click to filter Claude models", + clickToFilterGemini: "Click to filter Gemini models", + // Accounts page + searchAccounts: "Search accounts...", + noAccountsYet: "No Accounts Yet", + noAccountsDesc: "Get started by adding a Google account via OAuth, or use the CLI command to import credentials.", + addFirstAccount: "Add Your First Account", + noSearchResults: "No accounts match your search", + clearSearch: "Clear Search", + disabledAccountsNote: "Disabled accounts will not be used for request routing but remain in the configuration. Dashboard statistics only include enabled accounts.", + dangerousOperation: "⚠️ Dangerous Operation", + confirmDeletePrompt: "Are you sure you want to delete account", + deleteWarning: "⚠️ This action cannot be undone. All configuration and historical records will be permanently deleted.", + // OAuth progress + oauthWaiting: "Waiting for OAuth authorization...", + oauthWaitingDesc: "Please complete the authentication in the popup window. This may take up to 2 minutes.", + oauthCancelled: "OAuth authorization cancelled", + oauthTimeout: "⏱️ OAuth authorization timed out. Please try again.", + oauthWindowClosed: "OAuth window was closed. Authorization may be incomplete.", + cancelOAuth: "Cancel", + // MCP CLI & Gemini 1M + mcpCliExperimental: "Experimental MCP CLI", + mcpCliDesc: "Enables experimental MCP integration for reliable tool usage with reduced context consumption.", + gemini1mMode: "Gemini 1M Context Mode", + gemini1mDesc: "Appends [1m] suffix to Gemini models for 1M context window support.", + gemini1mWarning: "⚠ Large context may reduce Gemini-3-Pro performance.", + clickToSet: "Click to configure...", + }, + zh: { + dashboard: "仪表盘", + models: "模型列表", + accounts: "账号管理", + logs: "运行日志", + settings: "系统设置", + online: "在线", + offline: "离线", + totalAccounts: "账号总数", + active: "活跃状态", + operational: "运行中", + rateLimited: "受限状态", + cooldown: "冷却中", + searchPlaceholder: "搜索模型...", + allAccounts: "所有账号", + stat: "状态", + modelIdentity: "模型标识", + globalQuota: "全局配额", + nextReset: "重置时间", + distribution: "账号分布", + systemConfig: "系统配置", + language: "语言设置", + pollingInterval: "数据轮询间隔", + maxDisplayLogs: "最大日志显示行数", + showExhausted: "显示耗尽模型", + showExhaustedDesc: "即使配额为 0% 也显示模型。", + compactMode: "紧凑模式", + compactModeDesc: "减少表格间距以显示更多信息。", + saveChanges: "保存更改", + autoScroll: "自动滚动", + clearLogs: "清除日志", + accountManagement: "账号管理", + manageTokens: "管理已授权的 Google 账号及其状态", + addAccount: "添加账号", + status: "状态", + enabled: "启用", + health: "状态", + accountEmail: "账号 (邮箱)", + source: "来源", + projectId: "项目 ID", + sessionState: "会话状态", + operations: "操作", + delete: "删除", + confirmDelete: "确定要移除此账号吗?", + cannotDeleteDatabase: "无法删除:此账号来自 Antigravity 数据库(只读)", + connectGoogle: "连接 Google 账号", + reauthenticated: "已重新认证", + added: "已添加", + successfully: "成功", + accountAddedSuccess: "账号添加成功", + accountReauthSuccess: "账号重新认证成功", + failedToGetAuthUrl: "获取认证链接失败", + failedToStartOAuth: "启动 OAuth 流程失败", + oauthInProgress: "OAuth 授权进行中,请在弹出窗口中完成认证...", + family: "系列", + model: "模型", + activeSuffix: "活跃", + manualReload: "重新加载配置", + // Tabs + tabInterface: "界面设置", + tabClaude: "Claude CLI", + tabModels: "模型管理", + tabServer: "服务器设置", + // Dashboard + linkedAccounts: "已关联账号", + noSignal: "无信号连接", + establishingUplink: "正在建立上行链路...", + // Settings - Models + modelsDesc: "配置模型的可见性、置顶和请求路由。", + modelsPageDesc: "所有可用模型的实时配额和状态。", + showHidden: "显示隐藏模型", + modelId: "模型 ID", + actions: "操作", + pinToTop: "置顶", + toggleVisibility: "切换可见性", + noModels: "未检测到模型", + modelMappingHint: "服务端模型路由功能。Claude Code 用户请使用 'Claude CLI' 标签页以便捷配置。", + modelMapping: "映射 (目标模型 ID)", + // Settings - Claude + proxyConnection: "代理连接", + modelSelection: "模型选择", + defaultModelAliases: "默认模型映射 (别名)", + opusAlias: "Opus 别名", + sonnetAlias: "Sonnet 别名", + haikuAlias: "Haiku 别名", + claudeSettingsAlert: "以下设置直接修改 ~/.claude/settings.json。重启 Claude CLI 生效。", + applyToClaude: "应用到 Claude CLI", + // Settings - Server + port: "端口", + uiVersion: "UI 版本", + debugMode: "调试模式", + environment: "运行环境", + serverReadOnly: "配置由 config.json 管理。重启服务器以应用更改。", + advancedSettings: "高级设置", + reloadConfigTitle: "重载账号配置", + reloadConfigDesc: "强制从磁盘重新读取 accounts.json", + reload: "重载", + // Config Specific + primaryModel: "主模型", + subAgentModel: "子代理模型", + advancedOverrides: "默认模型覆盖 (高级)", + opusModel: "Opus 模型", + sonnetModel: "Sonnet 模型", + haikuModel: "Haiku 模型", + 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 耗尽", + synced: "已同步", + syncing: "正在同步...", + // 时间范围标签 + last1Hour: "最近 1 小时", + last6Hours: "最近 6 小时", + last24Hours: "最近 24 小时", + last7Days: "最近 7 天", + allTime: "最后全部", + groupBy: "分组方式", + // Additional + reloading: "正在重载...", + reloaded: "已重载", + lines: "行", + enabledSeeLogs: "已启用 (见日志)", + production: "生产环境", + configSaved: "配置已保存", + enterPassword: "请输入 Web UI 密码:", + ready: "就绪", + depleted: "已耗尽", + timeH: "时", + timeM: "分", + familyClaude: "Claude 系列", + familyGemini: "Gemini 系列", + familyOther: "其他系列", + enabledStatus: "已启用", + disabledStatus: "已禁用", + logLevelInfo: "信息", + logLevelSuccess: "成功", + logLevelWarn: "警告", + logLevelError: "错误", + totalColon: "总计:", + todayColon: "今日:", + hour1Colon: "1小时:", + frequentModels: "常用推荐", + smartTitle: "自动选出过去 24 小时最常用的 5 个模型", + activeCount: "{count} 活跃", + allCaps: "全部", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "映射 (目标模型 ID)", + systemInfo: "系统信息", + refresh: "刷新", + runtimeConfig: "运行时配置", + debugDesc: "启用详细日志记录 (见运行日志)", + networkRetry: "网络重试设置", + maxRetries: "最大重试次数", + retryBaseDelay: "重试基础延迟 (毫秒)", + retryMaxDelay: "重试最大延迟 (毫秒)", + persistentSessions: "持久化登录会话", + persistTokenDesc: "将登录会话保存到磁盘以实现快速重启", + rateLimiting: "账号限流与超时", + defaultCooldown: "默认冷却时间", + maxWaitThreshold: "最大等待阈值 (粘性会话)", + maxWaitDesc: "粘性账号在失败或切换前等待重置的最长时间。", + saveConfigServer: "保存配置", + serverRestartAlert: "配置已保存至 {path}。部分更改可能需要重启服务器。", + changePassword: "修改 WebUI 密码", + changePasswordDesc: "更新访问此仪表盘的密码", + currentPassword: "当前密码", + newPassword: "新密码", + confirmNewPassword: "确认新密码", + passwordEmptyDesc: "如果未设置密码请留空", + passwordLengthDesc: "至少 6 个字符", + passwordConfirmDesc: "请再次输入新密码", + cancel: "取消", + passwordsNotMatch: "密码不匹配", + passwordTooShort: "密码至少需要 6 个字符", + // Dashboard drill-down + clickToViewAllAccounts: "点击查看所有账号", + clickToViewModels: "点击查看模型页面", + clickToViewLimitedAccounts: "点击查看受限账号", + clickToFilterClaude: "点击筛选 Claude 模型", + clickToFilterGemini: "点击筛选 Gemini 模型", + // 账号页面 + searchAccounts: "搜索账号...", + noAccountsYet: "还没有添加任何账号", + noAccountsDesc: "点击上方的 \"添加账号\" 按钮通过 OAuth 添加 Google 账号,或者使用 CLI 命令导入凭证。", + addFirstAccount: "添加第一个账号", + noSearchResults: "没有找到匹配的账号", + clearSearch: "清除搜索", + disabledAccountsNote: "已禁用的账号不会用于请求路由,但仍保留在配置中。仪表盘统计数据仅包含已启用的账号。", + dangerousOperation: "⚠️ 危险操作", + confirmDeletePrompt: "确定要删除账号", + deleteWarning: "⚠️ 此操作不可撤销,账号的所有配置和历史记录将永久删除。", + // OAuth 进度 + oauthWaiting: "等待 OAuth 授权中...", + oauthWaitingDesc: "请在弹出窗口中完成认证。此过程最长可能需要 2 分钟。", + oauthCancelled: "已取消 OAuth 授权", + oauthTimeout: "⏱️ OAuth 授权超时,请重试。", + oauthWindowClosed: "OAuth 窗口已关闭,授权可能未完成。", + cancelOAuth: "取消", + // MCP CLI & Gemini 1M + mcpCliExperimental: "实验性 MCP CLI", + mcpCliDesc: "启用实验性 MCP 集成,减少上下文消耗,提高工具调用可靠性。", + gemini1mMode: "Gemini 1M 上下文模式", + gemini1mDesc: "为 Gemini 模型添加 [1m] 后缀以支持 1M 上下文窗口。", + gemini1mWarning: "⚠ 大上下文可能降低 Gemini-3-Pro 性能。", + clickToSet: "点击进行配置...", + } + }, + + // Toast Messages + toast: null, + + // OAuth Progress + oauthProgress: { + active: false, + current: 0, + max: 60, + cancel: null + }, + + 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) { + 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..bdfa9c3 --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,69 @@ +/** + * 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 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 + // 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 store = Alpine.store('global'); + const diff = new Date(isoTime) - new Date(); + if (diff <= 0) return store ? store.t('ready') : 'READY'; + const mins = Math.floor(diff / 60000); + const hrs = Math.floor(mins / 60); + + const hSuffix = store ? store.t('timeH') : 'H'; + const mSuffix = store ? store.t('timeM') : 'M'; + + if (hrs > 0) return `${hrs}${hSuffix} ${mins % 60}${mSuffix}`; + return `${mins}${mSuffix}`; + }, + + getThemeColor(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + }, + + /** + * Debounce function - delays execution until after specified wait time + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {Function} Debounced function + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } +}; diff --git a/public/js/utils/error-handler.js b/public/js/utils/error-handler.js new file mode 100644 index 0000000..8289485 --- /dev/null +++ b/public/js/utils/error-handler.js @@ -0,0 +1,107 @@ +/** + * Error Handling Utilities + * Provides standardized error handling with toast notifications + */ +window.ErrorHandler = window.ErrorHandler || {}; + +/** + * Safely execute an async function with error handling + * @param {Function} fn - Async function to execute + * @param {string} errorMessage - User-friendly error message prefix + * @param {object} options - Additional options + * @param {boolean} options.rethrow - Whether to rethrow the error after handling (default: false) + * @param {Function} options.onError - Custom error handler callback + * @returns {Promise} Result of the function or undefined on error + */ +window.ErrorHandler.safeAsync = async function(fn, errorMessage = 'Operation failed', options = {}) { + const { rethrow = false, onError = null } = options; + const store = Alpine.store('global'); + + try { + return await fn(); + } catch (error) { + // Log error for debugging + console.error(`[ErrorHandler] ${errorMessage}:`, error); + + // Show toast notification + const fullMessage = `${errorMessage}: ${error.message || 'Unknown error'}`; + store.showToast(fullMessage, 'error'); + + // Call custom error handler if provided + if (onError && typeof onError === 'function') { + try { + onError(error); + } catch (handlerError) { + console.error('[ErrorHandler] Custom error handler failed:', handlerError); + } + } + + // Rethrow if requested + if (rethrow) { + throw error; + } + + return undefined; + } +}; + +/** + * Wrap a component method with error handling + * @param {Function} method - Method to wrap + * @param {string} errorMessage - Error message prefix + * @returns {Function} Wrapped method + */ +window.ErrorHandler.wrapMethod = function(method, errorMessage = 'Operation failed') { + return async function(...args) { + return window.ErrorHandler.safeAsync( + () => method.apply(this, args), + errorMessage + ); + }; +}; + +/** + * Show a success toast notification + * @param {string} message - Success message + */ +window.ErrorHandler.showSuccess = function(message) { + const store = Alpine.store('global'); + store.showToast(message, 'success'); +}; + +/** + * Show an info toast notification + * @param {string} message - Info message + */ +window.ErrorHandler.showInfo = function(message) { + const store = Alpine.store('global'); + store.showToast(message, 'info'); +}; + +/** + * Show an error toast notification + * @param {string} message - Error message + * @param {Error} error - Optional error object + */ +window.ErrorHandler.showError = function(message, error = null) { + const store = Alpine.store('global'); + const fullMessage = error ? `${message}: ${error.message}` : message; + store.showToast(fullMessage, 'error'); +}; + +/** + * Validate and execute an API call with error handling + * @param {Function} apiCall - Async function that makes the API call + * @param {string} successMessage - Message to show on success (optional) + * @param {string} errorMessage - Message to show on error + * @returns {Promise} API response or undefined on error + */ +window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, errorMessage = 'API call failed') { + const result = await window.ErrorHandler.safeAsync(apiCall, errorMessage); + + if (result !== undefined && successMessage) { + window.ErrorHandler.showSuccess(successMessage); + } + + return result; +}; diff --git a/public/js/utils/model-config.js b/public/js/utils/model-config.js new file mode 100644 index 0000000..46a2cc7 --- /dev/null +++ b/public/js/utils/model-config.js @@ -0,0 +1,42 @@ +/** + * Model Configuration Utilities + * Shared functions for model configuration updates + */ +window.ModelConfigUtils = window.ModelConfigUtils || {}; + +/** + * Update model configuration with authentication and optimistic updates + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) + * @returns {Promise} + */ +window.ModelConfigUtils.updateModelConfig = async function(modelId, configUpdates) { + return window.ErrorHandler.safeAsync(async () => { + const store = Alpine.store('global'); + + const { response, newPassword } = await window.utils.request('/api/models/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modelId, config: configUpdates }) + }, store.webuiPassword); + + // Update password if server provided a new one + if (newPassword) { + store.webuiPassword = newPassword; + } + + if (!response.ok) { + throw new Error('Failed to update model config'); + } + + // Optimistic update of local state + const dataStore = Alpine.store('data'); + dataStore.modelConfig[modelId] = { + ...dataStore.modelConfig[modelId], + ...configUpdates + }; + + // Recompute quota rows to reflect changes + dataStore.computeQuotaRows(); + }, 'Failed to update model config'); +}; diff --git a/public/js/utils/validators.js b/public/js/utils/validators.js new file mode 100644 index 0000000..cedbb2f --- /dev/null +++ b/public/js/utils/validators.js @@ -0,0 +1,168 @@ +/** + * Input Validation Utilities + * Provides validation functions for user inputs + */ +window.Validators = window.Validators || {}; + +/** + * Validate a number is within a range + * @param {number} value - Value to validate + * @param {number} min - Minimum allowed value (inclusive) + * @param {number} max - Maximum allowed value (inclusive) + * @param {string} fieldName - Name of the field for error messages + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateRange = function(value, min, max, fieldName = 'Value') { + const numValue = Number(value); + + if (isNaN(numValue)) { + return { + isValid: false, + value: min, + error: `${fieldName} must be a valid number` + }; + } + + if (numValue < min) { + return { + isValid: false, + value: min, + error: `${fieldName} must be at least ${min}` + }; + } + + if (numValue > max) { + return { + isValid: false, + value: max, + error: `${fieldName} must be at most ${max}` + }; + } + + return { + isValid: true, + value: numValue, + error: null + }; +}; + +/** + * Validate a port number + * @param {number} port - Port number to validate + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validatePort = function(port) { + const { PORT_MIN, PORT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(port, PORT_MIN, PORT_MAX, 'Port'); +}; + +/** + * Validate a string is not empty + * @param {string} value - String to validate + * @param {string} fieldName - Name of the field for error messages + * @returns {object} { isValid: boolean, value: string, error: string|null } + */ +window.Validators.validateNotEmpty = function(value, fieldName = 'Field') { + const trimmedValue = String(value || '').trim(); + + if (trimmedValue.length === 0) { + return { + isValid: false, + value: trimmedValue, + error: `${fieldName} cannot be empty` + }; + } + + return { + isValid: true, + value: trimmedValue, + error: null + }; +}; + +/** + * Validate a boolean value + * @param {any} value - Value to validate as boolean + * @returns {object} { isValid: boolean, value: boolean, error: string|null } + */ +window.Validators.validateBoolean = function(value) { + if (typeof value === 'boolean') { + return { + isValid: true, + value: value, + error: null + }; + } + + // Try to coerce common values + if (value === 'true' || value === 1 || value === '1') { + return { isValid: true, value: true, error: null }; + } + + if (value === 'false' || value === 0 || value === '0') { + return { isValid: true, value: false, error: null }; + } + + return { + isValid: false, + value: false, + error: 'Value must be true or false' + }; +}; + +/** + * Validate a timeout/duration value (in milliseconds) + * @param {number} value - Timeout value in ms + * @param {number} minMs - Minimum allowed timeout (default: from constants) + * @param {number} maxMs - Maximum allowed timeout (default: from constants) + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateTimeout = function(value, minMs = null, maxMs = null) { + const { TIMEOUT_MIN, TIMEOUT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(value, minMs ?? TIMEOUT_MIN, maxMs ?? TIMEOUT_MAX, 'Timeout'); +}; + +/** + * Validate log limit + * @param {number} value - Log limit value + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateLogLimit = function(value) { + const { LOG_LIMIT_MIN, LOG_LIMIT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(value, LOG_LIMIT_MIN, LOG_LIMIT_MAX, 'Log limit'); +}; + +/** + * Validate and sanitize input with custom validator + * @param {any} value - Value to validate + * @param {Function} validator - Validator function + * @param {boolean} showError - Whether to show error toast (default: true) + * @returns {object} Validation result + */ +window.Validators.validate = function(value, validator, showError = true) { + const result = validator(value); + + if (!result.isValid && showError && result.error) { + window.ErrorHandler.showError(result.error); + } + + return result; +}; + +/** + * Create a validated input handler for Alpine.js + * @param {Function} validator - Validator function + * @param {Function} onValid - Callback when validation passes + * @returns {Function} Handler function + */ +window.Validators.createHandler = function(validator, onValid) { + return function(value) { + const result = window.Validators.validate(value, validator, true); + + if (result.isValid && onValid) { + onValid.call(this, result.value); + } + + return result.value; + }; +}; diff --git a/public/views/accounts.html b/public/views/accounts.html new file mode 100644 index 0000000..b34a451 --- /dev/null +++ b/public/views/accounts.html @@ -0,0 +1,260 @@ +
+ +
+ +
+

+ Account Management +

+ + Manage Google Account tokens and authorization states + +
+ + +
+ +
+ + + + +
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
EnabledAccount (Email)SourceTierQuotaHealthOperations
+
+ + +
+

+ + + + +

+
+ + + + + + +
\ No newline at end of file diff --git a/public/views/dashboard.html b/public/views/dashboard.html new file mode 100644 index 0000000..2287ef2 --- /dev/null +++ b/public/views/dashboard.html @@ -0,0 +1,437 @@ +
+ +
+ +
+

+ Dashboard +

+ + CLAUDE PROXY SYSTEM + +
+ + +
+
+ + +
+ Live + + + +
+
+ + +
+
+ +
+ + + + +
+ +
+
+
+ + + + +
+ +
+ + + +
+
+ +
+
+ + + +
+
+
+
+ + + + +
+
+ +
+
+ + + +
+
+
+
+ + + + +
+
+ + +
+ +
+ +
+
%
+
+
+ + +
+
+ Global Quota +
+ + +
+
+
+
+ Claude + + + +
+
+
+
+
+ Gemini + + + +
+
+
+
+
+
+ + +
+ +
+
+
+ + + +

Request Volume

+
+ + +
+
+ Total: + +
+
+ Today: + +
+
+ 1H: + +
+
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + + + +
+
+
+ + +
+ + + + +
+ + +
+ + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/public/views/logs.html b/public/views/logs.html new file mode 100644 index 0000000..f88b1d2 --- /dev/null +++ b/public/views/logs.html @@ -0,0 +1,97 @@ +
+
+ +
+ + +
+
+
+
+
+
+ +
+ + +
+ +
+
+ + + +
+ +
+ + + +
+ + +
+ + + +
+
+ + +
+ + +
+
+ No logs match filter +
+
+
+
diff --git a/public/views/models.html b/public/views/models.html new file mode 100644 index 0000000..ed3a0e9 --- /dev/null +++ b/public/views/models.html @@ -0,0 +1,251 @@ +
+ +
+ +
+

+ Models +

+ + Real-time quota and status for all available models. + +
+ + +
+
+ + + +
+ + +
+
+ + +
+
+ +
+ +
+ + + +
+
+ + +
+ + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
StatModel IdentityGlobal + QuotaNext Reset + Account + DistributionActions +
+
+ + ESTABLISHING + UPLINK... +
+
+ NO SIGNAL DETECTED +
+
+
+
\ No newline at end of file diff --git a/public/views/settings.html b/public/views/settings.html new file mode 100644 index 0000000..ed3a2f0 --- /dev/null +++ b/public/views/settings.html @@ -0,0 +1,1021 @@ +
+ +
+
+
+

+ + + + + System Configuration +

+
+ +
+ + + + +
+
+ + +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ 10s + 300s +
+
+ + +
+ +
+ + +
+
+ 500 + 5000 +
+
+
+ +
+ +
+
+
+
+ Show Exhausted Models + Display models even if they have 0% + remaining quota. +
+ +
+
+ +
+
+
+ Compact Mode + Reduce + padding in tables for higher information density. +
+ +
+
+
+
+ + +
+
+ + + + Settings below directly + modify ~/.claude/settings.json. Restart Claude CLI + to apply. +
+ + +
+ +
+
+
ANTHROPIC_BASE_URL
+ +
+
+
ANTHROPIC_AUTH_TOKEN
+ +
+
+
+ + +
+ + +
+ +
+ +
+ +
+ + +
+ ANTHROPIC_MODEL +
+ + +
+ +
+ +
+ + +
+ CLAUDE_CODE_SUBAGENT_MODEL +
+
+ +
DEFAULT MODEL ALIASES
+ + +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ Experimental MCP CLI + + Enables experimental MCP integration for reliable tool usage with reduced context consumption. + +
+ +
+
+ + +
+
+
+ Gemini 1M Context Mode + + Appends [1m] suffix to Gemini models for 1M context window support. + + + ⚠ Large context may reduce Gemini-3-Pro performance. + +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+
Configure model visibility, pinning, and request mapping.
+
Model mapping: server-side redirection. Claude Code users: see 'Claude CLI' tab for client-side setup.
+
+
+ Show Hidden Models + +
+
+ + +
+ + + + + + + + + + + + + + +
Model IDMapping (Target Model ID)Actions
+ NO MODELS DETECTED +
+
+
+ + +
+ +
+
+
+
+ + + +
+
+

WebUI Password

+

+ Authentication for accessing this dashboard

+
+
+ +
+
+ + +
+
+ Common Settings +
+
+ + +
+
+
+ Debug Mode + Detailed + logging in the Logs tab +
+ +
+
+ + +
+
+
+ Persist Token Cache + Save OAuth tokens to disk for faster + restarts +
+ +
+
+
+ + +
+
+
+
+ + + +
+
+ Advanced Settings + Settings managed via config.json +
+
+ + + +
+ +
+
+ + +
+
+ Network Retry Settings +
+ +
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+ Rate Limiting & Timeouts +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+

Maximum time to wait for a sticky account to + reset before switching.

+
+
+
+
+ + +
+ + + + All + changes are saved automatically. Some settings may require server restart. +
+ + +
+
+

Change + WebUI Password

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/src/account-manager/index.js b/src/account-manager/index.js index b730c1c..8259996 100644 --- a/src/account-manager/index.js +++ b/src/account-manager/index.js @@ -71,6 +71,16 @@ export class AccountManager { this.#initialized = true; } + /** + * Reload accounts from disk (force re-initialization) + * Useful when accounts.json is modified externally (e.g., by WebUI) + */ + async reload() { + this.#initialized = false; + await this.initialize(); + logger.info('[AccountManager] Accounts reloaded from disk'); + } + /** * Get the number of accounts * @returns {number} Number of configured accounts @@ -278,6 +288,8 @@ export class AccountManager { accounts: this.#accounts.map(a => ({ email: a.email, source: a.source, + enabled: a.enabled !== false, // Default to true if undefined + projectId: a.projectId || null, modelRateLimits: a.modelRateLimits || {}, isInvalid: a.isInvalid || false, invalidReason: a.invalidReason || null, diff --git a/src/account-manager/rate-limits.js b/src/account-manager/rate-limits.js index fe756fa..06b4b08 100644 --- a/src/account-manager/rate-limits.js +++ b/src/account-manager/rate-limits.js @@ -39,6 +39,9 @@ export function getAvailableAccounts(accounts, modelId = null) { return accounts.filter(acc => { if (acc.isInvalid) return false; + // WebUI: Skip disabled accounts + if (acc.enabled === false) return false; + if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) { const limit = acc.modelRateLimits[modelId]; if (limit.isRateLimited && limit.resetTime > Date.now()) { diff --git a/src/account-manager/selection.js b/src/account-manager/selection.js index ef4d8bd..ad41307 100644 --- a/src/account-manager/selection.js +++ b/src/account-manager/selection.js @@ -19,6 +19,9 @@ import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js'; function isAccountUsable(account, modelId) { if (!account || account.isInvalid) return false; + // WebUI: Skip disabled accounts + if (account.enabled === false) return false; + if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) { const limit = account.modelRateLimits[modelId]; if (limit.isRateLimited && limit.resetTime > Date.now()) { diff --git a/src/account-manager/storage.js b/src/account-manager/storage.js index b8ee6b8..eaf77bc 100644 --- a/src/account-manager/storage.js +++ b/src/account-manager/storage.js @@ -27,10 +27,14 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) { const accounts = (config.accounts || []).map(acc => ({ ...acc, lastUsed: acc.lastUsed || null, + enabled: acc.enabled !== false, // Default to true if not specified // Reset invalid flag on startup - give accounts a fresh chance to refresh isInvalid: false, invalidReason: null, - modelRateLimits: acc.modelRateLimits || {} + modelRateLimits: acc.modelRateLimits || {}, + // New fields for subscription and quota tracking + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null } })); const settings = config.settings || {}; @@ -107,6 +111,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex) accounts: accounts.map(acc => ({ email: acc.email, source: acc.source, + enabled: acc.enabled !== false, // Persist enabled state dbPath: acc.dbPath || null, refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined, apiKey: acc.source === 'manual' ? acc.apiKey : undefined, @@ -115,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex) isInvalid: acc.isInvalid || false, invalidReason: acc.invalidReason || null, modelRateLimits: acc.modelRateLimits || {}, - lastUsed: acc.lastUsed + lastUsed: acc.lastUsed, + // Persist subscription and quota data + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null } })), settings: settings, activeIndex: activeIndex diff --git a/src/auth/oauth.js b/src/auth/oauth.js index 036292e..dabdc18 100644 --- a/src/auth/oauth.js +++ b/src/auth/oauth.js @@ -32,15 +32,16 @@ function generatePKCE() { * Generate authorization URL for Google OAuth * Returns the URL and the PKCE verifier (needed for token exchange) * + * @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI) * @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data */ -export function getAuthorizationUrl() { +export function getAuthorizationUrl(customRedirectUri = null) { const { verifier, challenge } = generatePKCE(); const state = crypto.randomBytes(16).toString('hex'); const params = new URLSearchParams({ client_id: OAUTH_CONFIG.clientId, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI, response_type: 'code', scope: OAUTH_CONFIG.scopes.join(' '), access_type: 'offline', diff --git a/src/cloudcode/index.js b/src/cloudcode/index.js index 57898fb..806f402 100644 --- a/src/cloudcode/index.js +++ b/src/cloudcode/index.js @@ -12,17 +12,18 @@ // Re-export public API export { sendMessage } from './message-handler.js'; export { sendMessageStream } from './streaming-handler.js'; -export { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js'; +export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js'; // Default export for backwards compatibility import { sendMessage } from './message-handler.js'; import { sendMessageStream } from './streaming-handler.js'; -import { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js'; +import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js'; export default { sendMessage, sendMessageStream, listModels, fetchAvailableModels, - getModelQuotas + getModelQuotas, + getSubscriptionTier }; diff --git a/src/cloudcode/model-api.js b/src/cloudcode/model-api.js index e8bd9e3..e87c240 100644 --- a/src/cloudcode/model-api.js +++ b/src/cloudcode/model-api.js @@ -110,3 +110,75 @@ export async function getModelQuotas(token) { return quotas; } + +/** + * Get subscription tier for an account + * Calls loadCodeAssist API to discover project ID and subscription tier + * + * @param {string} token - OAuth access token + * @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID + */ +export async function getSubscriptionTier(token) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; + + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = `${endpoint}/v1internal:loadCodeAssist`; + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }) + }); + + if (!response.ok) { + logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`); + continue; + } + + const data = await response.json(); + + // Extract project ID + let projectId = null; + if (typeof data.cloudaicompanionProject === 'string') { + projectId = data.cloudaicompanionProject; + } else if (data.cloudaicompanionProject?.id) { + projectId = data.cloudaicompanionProject.id; + } + + // Extract subscription tier (priority: paidTier > currentTier) + let tier = 'free'; + const tierId = data.paidTier?.id || data.currentTier?.id; + + if (tierId) { + const lowerTier = tierId.toLowerCase(); + if (lowerTier.includes('ultra')) { + tier = 'ultra'; + } else if (lowerTier.includes('pro')) { + tier = 'pro'; + } else { + tier = 'free'; + } + } + + logger.debug(`[CloudCode] Subscription detected: ${tier}, Project: ${projectId}`); + + return { tier, projectId }; + } catch (error) { + logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message); + } + } + + // Fallback: return default values if all endpoints fail + logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.'); + return { tier: 'free', projectId: null }; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..42ef525 --- /dev/null +++ b/src/config.js @@ -0,0 +1,86 @@ +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, + defaultCooldownMs: 60000, // 1 minute + maxWaitBeforeErrorMs: 120000, // 2 minutes + 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 5e0ce0a..a45af14 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,28 +60,35 @@ 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' ); +// Usage history persistence path +export const USAGE_HISTORY_PATH = join( + homedir(), + '.config/antigravity-proxy/usage-history.json' +); + // Antigravity app database path (for legacy single-account token extraction) // Uses platform-specific path detection export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath(); -export const DEFAULT_COOLDOWN_MS = 10 * 1000; // 10 second default cooldown -export const MAX_RETRIES = 5; // Max retry attempts across accounts -export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses -export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed +export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds +export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5 +export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses (from upstream) +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/modules/usage-stats.js b/src/modules/usage-stats.js new file mode 100644 index 0000000..66dc1ed --- /dev/null +++ b/src/modules/usage-stats.js @@ -0,0 +1,205 @@ +import fs from 'fs'; +import path from 'path'; + +import { USAGE_HISTORY_PATH } from '../constants.js'; + +// Persistence path +const HISTORY_FILE = USAGE_HISTORY_PATH; +const DATA_DIR = path.dirname(HISTORY_FILE); +const OLD_DATA_DIR = path.join(process.cwd(), 'data'); +const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json'); + +// In-memory storage +// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } } +let history = {}; +let isDirty = false; + +/** + * Extract model family from model ID + * @param {string} modelId - The model identifier (e.g., "claude-opus-4-5-thinking") + * @returns {string} The family name (claude, gemini, or other) + */ +function getFamily(modelId) { + const lower = (modelId || '').toLowerCase(); + if (lower.includes('claude')) return 'claude'; + if (lower.includes('gemini')) return 'gemini'; + return 'other'; +} + +/** + * Extract short model name (without family prefix) + * @param {string} modelId - The model identifier + * @param {string} family - The model family + * @returns {string} Short model name + */ +function getShortName(modelId, family) { + if (family === 'other') return modelId; + // Remove family prefix (e.g., "claude-opus-4-5" -> "opus-4-5") + return modelId.replace(new RegExp(`^${family}-`, 'i'), ''); +} + +/** + * Ensure data directory exists and load history. + * Includes migration from legacy local data directory. + */ +function load() { + try { + // Migration logic: if old file exists and new one doesn't + if (fs.existsSync(OLD_HISTORY_FILE) && !fs.existsSync(HISTORY_FILE)) { + console.log('[UsageStats] Migrating legacy usage data...'); + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + fs.copyFileSync(OLD_HISTORY_FILE, HISTORY_FILE); + // We keep the old file for safety initially, but could delete it + console.log(`[UsageStats] Migration complete: ${OLD_HISTORY_FILE} -> ${HISTORY_FILE}`); + } + + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + if (fs.existsSync(HISTORY_FILE)) { + const data = fs.readFileSync(HISTORY_FILE, 'utf8'); + history = JSON.parse(data); + } + } catch (err) { + console.error('[UsageStats] Failed to load history:', err); + history = {}; + } +} + +/** + * Save history to disk + */ +function save() { + if (!isDirty) return; + try { + fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2)); + isDirty = false; + } catch (err) { + console.error('[UsageStats] Failed to save history:', err); + } +} + +/** + * Prune old data (keep last 30 days) + */ +function prune() { + const now = new Date(); + const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + let pruned = false; + Object.keys(history).forEach(key => { + if (new Date(key) < cutoff) { + delete history[key]; + pruned = true; + } + }); + + if (pruned) isDirty = true; +} + +/** + * Track a request by model ID using hierarchical structure + * @param {string} modelId - The specific model identifier + */ +function track(modelId) { + const now = new Date(); + // Round down to nearest hour + now.setMinutes(0, 0, 0); + const key = now.toISOString(); + + if (!history[key]) { + history[key] = { _total: 0 }; + } + + const hourData = history[key]; + const family = getFamily(modelId); + const shortName = getShortName(modelId, family); + + // Initialize family object if needed + if (!hourData[family]) { + hourData[family] = { _subtotal: 0 }; + } + + // Increment model-specific count + hourData[family][shortName] = (hourData[family][shortName] || 0) + 1; + + // Increment family subtotal + hourData[family]._subtotal = (hourData[family]._subtotal || 0) + 1; + + // Increment global total + hourData._total = (hourData._total || 0) + 1; + + isDirty = true; +} + +/** + * Setup Express Middleware + * @param {import('express').Application} app + */ +function setupMiddleware(app) { + load(); + + // Auto-save every minute + setInterval(() => { + save(); + prune(); + }, 60 * 1000); + + // Save on exit + process.on('SIGINT', () => { save(); process.exit(); }); + process.on('SIGTERM', () => { save(); process.exit(); }); + + // Request interceptor + // Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints + const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions']; + + app.use((req, res, next) => { + if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) { + const model = req.body?.model; + if (model) { + track(model); + } + } + next(); + }); +} + +/** + * Setup API Routes + * @param {import('express').Application} app + */ +function setupRoutes(app) { + app.get('/api/stats/history', (req, res) => { + // Sort keys to ensure chronological order + const sortedKeys = Object.keys(history).sort(); + const sortedData = {}; + sortedKeys.forEach(key => { + sortedData[key] = history[key]; + }); + res.json(sortedData); + }); +} + +/** + * Get usage history data + * @returns {object} History data sorted by timestamp + */ +function getHistory() { + const sortedKeys = Object.keys(history).sort(); + const sortedData = {}; + sortedKeys.forEach(key => { + sortedData[key] = history[key]; + }); + return sortedData; +} + +export default { + setupMiddleware, + setupRoutes, + track, + getFamily, + getShortName, + getHistory +}; diff --git a/src/server.js b/src/server.js index 587b393..0178d7d 100644 --- a/src/server.js +++ b/src/server.js @@ -6,12 +6,20 @@ import express from 'express'; import cors from 'cors'; -import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js'; +import { mountWebUI } from './webui/index.js'; +import { config } from './config.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'; import { formatDuration } from './utils/helpers.js'; import { logger } from './utils/logger.js'; +import usageStats from './modules/usage-stats.js'; // Parse fallback flag directly from command line args to avoid circular dependency const args = process.argv.slice(2); @@ -57,6 +65,12 @@ async function ensureInitialized() { app.use(cors()); app.use(express.json({ limit: REQUEST_BODY_LIMIT })); +// Setup usage statistics middleware +usageStats.setupMiddleware(app); + +// 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 */ @@ -123,11 +137,11 @@ app.get('/health', async (req, res) => { try { await ensureInitialized(); const start = Date.now(); - + // Get high-level status first const status = accountManager.getStatus(); const allAccounts = accountManager.getAllAccounts(); - + // Fetch quotas for each account in parallel to get detailed model info const accountDetails = await Promise.allSettled( allAccounts.map(async (account) => { @@ -235,6 +249,7 @@ app.get('/account-limits', async (req, res) => { await ensureInitialized(); const allAccounts = accountManager.getAllAccounts(); const format = req.query.format || 'json'; + const includeHistory = req.query.includeHistory === 'true'; // Fetch quotas for each account in parallel const results = await Promise.allSettled( @@ -251,11 +266,33 @@ app.get('/account-limits', async (req, res) => { try { const token = await accountManager.getTokenForAccount(account); - const quotas = await getModelQuotas(token); + + // Fetch both quotas and subscription tier in parallel + const [quotas, subscription] = await Promise.all([ + getModelQuotas(token), + getSubscriptionTier(token) + ]); + + // Update account object with fresh data + account.subscription = { + tier: subscription.tier, + projectId: subscription.projectId, + detectedAt: Date.now() + }; + account.quota = { + models: quotas, + lastChecked: Date.now() + }; + + // Save updated account data to disk (async, don't wait) + accountManager.saveToDisk().catch(err => { + logger.error('[Server] Failed to save account data:', err); + }); return { email: account.email, status: 'ok', + subscription: account.subscription, models: quotas }; } catch (error) { @@ -263,6 +300,7 @@ app.get('/account-limits', async (req, res) => { email: account.email, status: 'error', error: error.message, + subscription: account.subscription || { tier: 'unknown', projectId: null }, models: {} }; } @@ -409,32 +447,61 @@ app.get('/account-limits', async (req, res) => { return res.send(lines.join('\n')); } - // Default: JSON format - res.json({ + // Get account metadata from AccountManager + const accountStatus = accountManager.getStatus(); + const accountMetadataMap = new Map( + accountStatus.accounts.map(a => [a.email, a]) + ); + + // Build response data + const responseData = { timestamp: new Date().toLocaleString(), totalAccounts: allAccounts.length, models: sortedModels, - accounts: accountLimits.map(acc => ({ - email: acc.email, - status: acc.status, - error: acc.error || null, - limits: Object.fromEntries( - sortedModels.map(modelId => { - const quota = acc.models?.[modelId]; - if (!quota) { - return [modelId, null]; - } - return [modelId, { - remaining: quota.remainingFraction !== null - ? `${Math.round(quota.remainingFraction * 100)}%` - : 'N/A', - remainingFraction: quota.remainingFraction, - resetTime: quota.resetTime || null - }]; - }) - ) - })) - }); + modelConfig: config.modelMapping || {}, + accounts: accountLimits.map(acc => { + // Merge quota data with account metadata + const metadata = accountMetadataMap.get(acc.email) || {}; + return { + email: acc.email, + status: acc.status, + error: acc.error || null, + // Include metadata from AccountManager (WebUI needs these) + source: metadata.source || 'unknown', + enabled: metadata.enabled !== false, + projectId: metadata.projectId || null, + isInvalid: metadata.isInvalid || false, + invalidReason: metadata.invalidReason || null, + lastUsed: metadata.lastUsed || null, + modelRateLimits: metadata.modelRateLimits || {}, + // Subscription data (new) + subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null }, + // Quota limits + limits: Object.fromEntries( + sortedModels.map(modelId => { + const quota = acc.models?.[modelId]; + if (!quota) { + return [modelId, null]; + } + return [modelId, { + remaining: quota.remainingFraction !== null + ? `${Math.round(quota.remainingFraction * 100)}%` + : 'N/A', + remainingFraction: quota.remainingFraction, + resetTime: quota.resetTime || null + }]; + }) + ) + }; + }) + }; + + // Optionally include usage history (for dashboard performance optimization) + if (includeHistory) { + responseData.history = usageStats.getHistory(); + } + + res.json(responseData); } catch (error) { res.status(500).json({ status: 'error', @@ -525,13 +592,12 @@ app.post('/v1/messages', async (req, res) => { // Ensure account manager is initialized await ensureInitialized(); - const { model, messages, - max_tokens, stream, system, + max_tokens, tools, tool_choice, thinking, @@ -540,9 +606,19 @@ app.post('/v1/messages', async (req, res) => { temperature } = req.body; + // Resolve model mapping if configured + let requestedModel = model || 'claude-3-5-sonnet-20241022'; + const modelMapping = config.modelMapping || {}; + if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) { + const targetModel = modelMapping[requestedModel].mapping; + logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`); + requestedModel = targetModel; + } + + const modelId = requestedModel; + // Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check. // If we have some available accounts, we try them first. - const modelId = model || 'claude-3-5-sonnet-20241022'; if (accountManager.isAllRateLimited(modelId)) { logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`); accountManager.resetAllRateLimits(); @@ -561,7 +637,7 @@ app.post('/v1/messages', async (req, res) => { // Build the request object const request = { - model: model || 'claude-3-5-sonnet-20241022', + model: modelId, messages, max_tokens: max_tokens || 4096, stream, @@ -667,6 +743,8 @@ app.post('/v1/messages', async (req, res) => { /** * Catch-all for unsupported endpoints */ +usageStats.setupRoutes(app); + app.use('*', (req, res) => { if (logger.isDebugEnabled) { logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`); 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..80e1099 --- /dev/null +++ b/src/webui/index.js @@ -0,0 +1,583 @@ +/** + * 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, ACCOUNT_CONFIG_PATH } from '../constants.js'; +import { readClaudeConfig, updateClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.js'; +import { logger } from '../utils/logger.js'; +import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js'; +import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; + +// OAuth state storage (state -> { server, verifier, state, timestamp }) +// Maps state ID to active OAuth flow data +const pendingOAuthFlows = new Map(); + +/** + * WebUI Helper Functions - Direct account manipulation + * These functions work around AccountManager's limited API by directly + * manipulating the accounts.json config file (non-invasive approach for PR) + */ + +/** + * Set account enabled/disabled state + */ +async function setAccountEnabled(email, enabled) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const account = accounts.find(a => a.email === email); + if (!account) { + throw new Error(`Account ${email} not found`); + } + account.enabled = enabled; + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); + logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`); +} + +/** + * Remove account from config + */ +async function removeAccount(email) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const index = accounts.findIndex(a => a.email === email); + if (index === -1) { + throw new Error(`Account ${email} not found`); + } + accounts.splice(index, 1); + // Adjust activeIndex if needed + const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex; + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex); + logger.info(`[WebUI] Account ${email} removed`); +} + +/** + * Add new account to config + */ +async function addAccount(accountData) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + + // Check if account already exists + const existingIndex = accounts.findIndex(a => a.email === accountData.email); + if (existingIndex !== -1) { + // Update existing account + accounts[existingIndex] = { + ...accounts[existingIndex], + ...accountData, + enabled: true, + isInvalid: false, + invalidReason: null, + addedAt: accounts[existingIndex].addedAt || new Date().toISOString() + }; + logger.info(`[WebUI] Account ${accountData.email} updated`); + } else { + // Add new account + accounts.push({ + ...accountData, + enabled: true, + isInvalid: false, + invalidReason: null, + modelRateLimits: {}, + lastUsed: null, + addedAt: new Date().toISOString() + }); + logger.info(`[WebUI] Account ${accountData.email} added`); + } + + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); +} + +/** + * 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' }); + } + + await setAccountEnabled(email, enabled); + + // Reload AccountManager to pick up changes + await accountManager.reload(); + + 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; + await removeAccount(email); + + // Reload AccountManager to pick up changes + await accountManager.reload(); + + 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 { + // Reload AccountManager from disk + await accountManager.reload(); + + 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, defaultCooldownMs, maxWaitBeforeErrorMs } = 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 (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) { + updates.defaultCooldownMs = defaultCooldownMs; + } + if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) { + updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs; + } + + 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 }); + } + }); + + /** + * POST /api/config/password - Change WebUI password + */ + app.post('/api/config/password', (req, res) => { + try { + const { oldPassword, newPassword } = req.body; + + // Validate input + if (!newPassword || typeof newPassword !== 'string') { + return res.status(400).json({ + status: 'error', + error: 'New password is required' + }); + } + + // If current password exists, verify old password + if (config.webuiPassword && config.webuiPassword !== oldPassword) { + return res.status(403).json({ + status: 'error', + error: 'Invalid current password' + }); + } + + // Save new password + const success = saveConfig({ webuiPassword: newPassword }); + + if (success) { + // Update in-memory config + config.webuiPassword = newPassword; + res.json({ + status: 'ok', + message: 'Password changed successfully' + }); + } else { + throw new Error('Failed to save password to config file'); + } + } catch (error) { + logger.error('[WebUI] Error changing password:', 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 + * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port + * to match Google OAuth Console's authorized redirect URIs + */ + app.get('/api/auth/url', async (req, res) => { + try { + // Clean up old flows (> 10 mins) + const now = Date.now(); + for (const [key, val] of pendingOAuthFlows.entries()) { + if (now - val.timestamp > 10 * 60 * 1000) { + pendingOAuthFlows.delete(key); + } + } + + // Generate OAuth URL using default redirect URI (localhost:51121) + const { url, verifier, state } = getAuthorizationUrl(); + + // Start callback server on port 51121 (same as CLI) + const serverPromise = startCallbackServer(state, 120000); // 2 min timeout + + // Store the flow data + pendingOAuthFlows.set(state, { + serverPromise, + verifier, + state, + timestamp: Date.now() + }); + + // Start async handler for the OAuth callback + serverPromise + .then(async (code) => { + try { + logger.info('[WebUI] Received OAuth callback, completing flow...'); + const accountData = await completeOAuthFlow(code, verifier); + + // Add or update the account + await addAccount({ + email: accountData.email, + refreshToken: accountData.refreshToken, + projectId: accountData.projectId, + source: 'oauth' + }); + + // Reload AccountManager to pick up the new account + await accountManager.reload(); + + logger.success(`[WebUI] Account ${accountData.email} added successfully`); + } catch (err) { + logger.error('[WebUI] OAuth flow completion error:', err); + } finally { + pendingOAuthFlows.delete(state); + } + }) + .catch((err) => { + logger.error('[WebUI] OAuth callback server error:', err); + pendingOAuthFlows.delete(state); + }); + + res.json({ status: 'ok', url }); + } catch (error) { + logger.error('[WebUI] Error generating auth URL:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * Note: /oauth/callback route removed + * OAuth callbacks are now handled by the temporary server on port 51121 + * (same as CLI) to match Google OAuth Console's authorized redirect URIs + */ + + 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..41e1be1 --- /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 = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +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..29b740f --- /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 = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +// 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..33e9922 --- /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 = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +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..30425cc --- /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 = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +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); +});