Merge pull request #47 from Wha1eChai/feature/webui

feat: Add Web UI for account and quota management
This commit is contained in:
Badri Narayanan S
2026-01-10 22:21:57 +05:30
committed by GitHub
55 changed files with 9027 additions and 138 deletions

4
.gitignore vendored
View File

@@ -16,6 +16,10 @@ log.txt
# Local config (may contain tokens) # Local config (may contain tokens)
.claude/ .claude/
.deepvcode/
# Runtime data
data/
# Test artifacts # Test artifacts
tests/utils/*.png tests/utils/*.png

View File

@@ -87,6 +87,9 @@ src/
│ ├── token-extractor.js # Legacy token extraction from DB │ ├── token-extractor.js # Legacy token extraction from DB
│ └── database.js # SQLite database access │ └── database.js # SQLite database access
├── webui/ # Web Management Interface
│ └── index.js # Express router and API endpoints
├── cli/ # CLI tools ├── cli/ # CLI tools
│ └── accounts.js # Account management CLI │ └── accounts.js # Account management CLI
@@ -105,10 +108,33 @@ src/
└── native-module-helper.js # Auto-rebuild for native modules └── 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:** **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 - **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/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/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 - **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 - Session ID derived from first user message hash for cache continuity
- Account state persisted to `~/.config/antigravity-proxy/accounts.json` - 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:** **Prompt Caching:**
- Cache is organization-scoped (requires same account + session ID) - Cache is organization-scoped (requires same account + session ID)
- Session ID is SHA256 hash of first user message content (stable across turns) - 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 - 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` - 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 ## Testing Notes
- Tests require the server to be running (`npm start` in separate terminal) - Tests require the server to be running (`npm start` in separate terminal)
@@ -186,6 +237,12 @@ src/
- `sleep(ms)` - Promise-based delay - `sleep(ms)` - Promise-based delay
- `isNetworkError(error)` - Check if error is a transient network error - `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:** Structured logging via `src/utils/logger.js`:
- `logger.info(msg)` - Standard info (blue) - `logger.info(msg)` - Standard info (blue)
- `logger.success(msg)` - Success messages (green) - `logger.success(msg)` - Success messages (green)
@@ -195,6 +252,17 @@ src/
- `logger.setDebug(true)` - Enable debug mode - `logger.setDebug(true)` - Enable debug mode
- `logger.isDebugEnabled` - Check if debug mode is on - `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 ## 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. 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.

184
README.md
View File

@@ -59,62 +59,7 @@ npm start
## Quick Start ## Quick Start
### 1. Add Account(s) ### 1. Start the Proxy Server
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
```bash ```bash
# If installed via npm # If installed via npm
@@ -129,6 +74,34 @@ npm start
The server runs on `http://localhost:8080` by default. 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: To use a custom port:
```bash ```bash
@@ -151,6 +124,18 @@ curl "http://localhost:8080/account-limits?format=table"
### Configure Claude Code ### 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: Create or edit the Claude Code settings file:
**macOS:** `~/.claude/settings.json` **macOS:** `~/.claude/settings.json`
@@ -267,18 +252,18 @@ Then run `claude` for official API or `claude-antigravity` for this proxy.
### Claude Models ### Claude Models
| Model ID | Description | | Model ID | Description |
|----------|-------------| | ---------------------------- | ---------------------------------------- |
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking | | `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-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking | | `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
### Gemini Models ### Gemini Models
| Model ID | Description | | Model ID | Description |
|----------|-------------| | ------------------- | ------------------------------- |
| `gemini-3-flash` | Gemini 3 Flash with thinking | | `gemini-3-flash` | Gemini 3 Flash with thinking |
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking | | `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
| `gemini-3-pro-high` | Gemini 3 Pro High 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. 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 - **Invalid account detection**: Accounts needing re-authentication are marked and skipped
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns - **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 ```bash
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
# CLI Table:
curl "http://localhost:8080/account-limits?format=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 ## API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| | ----------------- | ------ | --------------------------------------------------------------------- |
| `/health` | GET | Health check | | `/health` | GET | Health check |
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) | | `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
| `/v1/messages` | POST | Anthropic Messages API | | `/v1/messages` | POST | Anthropic Messages API |
| `/v1/models` | GET | List available models | | `/v1/models` | GET | List available models |
| `/refresh-token` | POST | Force token refresh | | `/refresh-token` | POST | Force token refresh |
--- ---
@@ -345,6 +381,7 @@ npm run test:caching # Prompt caching
### "Could not extract token from Antigravity" ### "Could not extract token from Antigravity"
If using single-account mode with Antigravity: If using single-account mode with Antigravity:
1. Make sure Antigravity app is installed and running 1. Make sure Antigravity app is installed and running
2. Ensure you're logged in to Antigravity 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 ### 401 Authentication Errors
The token might have expired. Try: The token might have expired. Try:
```bash ```bash
curl -X POST http://localhost:8080/refresh-token curl -X POST http://localhost:8080/refresh-token
``` ```
Or re-authenticate the account: Or re-authenticate the account:
```bash ```bash
antigravity-claude-proxy accounts antigravity-claude-proxy accounts
``` ```
@@ -369,6 +408,7 @@ With multiple accounts, the proxy automatically switches to the next available a
### Account Shows as "Invalid" ### Account Shows as "Invalid"
Re-authenticate the account: Re-authenticate the account:
```bash ```bash
antigravity-claude-proxy accounts antigravity-claude-proxy accounts
# Choose "Re-authenticate" for the invalid account # Choose "Re-authenticate" for the invalid account

52
config.example.json Normal file
View File

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

BIN
images/webui-dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

20
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "antigravity-claude-proxy", "name": "antigravity-claude-proxy",
"version": "1.0.2", "version": "1.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antigravity-claude-proxy", "name": "antigravity-claude-proxy",
"version": "1.0.2", "version": "1.2.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"async-mutex": "^0.5.0",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2" "express": "^4.18.2"
@@ -39,6 +40,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1304,6 +1314,12 @@
"node": ">=0.6" "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": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

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

198
public/app.js Normal file
View File

@@ -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 = `<div class="p-4 border border-red-500/50 bg-red-500/10 rounded-lg text-red-400 font-mono text-sm">
Error loading view: ${viewName}<br>
<span class="text-xs opacity-75">${err.message}</span>
</div>`;
});
});
// Main App Controller
Alpine.data('app', () => ({
get connectionStatus() {
return Alpine.store('data')?.connectionStatus || 'connecting';
},
get loading() {
return Alpine.store('data')?.loading || false;
},
init() {
console.log('App controller initialized');
// Theme setup
document.documentElement.setAttribute('data-theme', 'black');
document.documentElement.classList.add('dark');
// Chart Defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.color = 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');
}
}
}));
});

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

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

10
public/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="favicon_grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#A855F7" />
<stop offset="1" stop-color="#2563EB" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#favicon_grad)" />
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" fill="white" font-family="Inter, system-ui, sans-serif" font-weight="800" font-size="14">AG</text>
</svg>

After

Width:  |  Height:  |  Size: 566 B

379
public/index.html Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
};

View File

@@ -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();
};

View File

@@ -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;
};

View File

@@ -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 = [];
}
});

View File

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

View File

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

View File

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

View File

@@ -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
};

214
public/js/data-store.js Normal file
View File

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

View File

@@ -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();
}
}
});
});

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

@@ -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: "<strong>Disabled accounts</strong> 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: "<strong>已禁用的账号</strong>不会用于请求路由,但仍保留在配置中。仪表盘统计数据仅包含已启用的账号。",
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);
}
});
});

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

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

View File

@@ -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<any>} 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<any>} 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;
};

View File

@@ -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<void>}
*/
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');
};

View File

@@ -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;
};
};

260
public/views/accounts.html Normal file
View File

@@ -0,0 +1,260 @@
<div x-data="accountManager" class="view-container">
<!-- Compact Header -->
<div class="flex items-center justify-between gap-4 mb-6">
<!-- Title with inline subtitle -->
<div class="flex items-baseline gap-3">
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accountManagement')">
Account Management
</h1>
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('manageTokens')">
Manage Google Account tokens and authorization states
</span>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2">
<!-- Search -->
<div class="relative" x-show="$store.data.accounts.length > 0">
<input type="text"
x-model="searchQuery"
:placeholder="$store.global.t('searchAccounts')"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-48 pl-9 text-xs h-8"
@keydown.escape="searchQuery = ''">
<svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="reloadAccounts()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span x-text="$store.global.t('reload')">Reload</span>
</button>
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-xs gap-2 shadow-lg shadow-neon-purple/20 h-8"
onclick="document.getElementById('add_account_modal').showModal()">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('addAccount')">Add Account</span>
</button>
</div>
</div>
<!-- Table Card -->
<div class="view-card !p-0">
<table class="w-full">
<thead x-show="$store.data.accounts.length > 0">
<tr class="bg-space-900/50 border-b border-space-border/50">
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('accountEmail')">Account (Email)</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16">Tier</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32">Quota</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="$store.data.accounts.length === 0">
<tr>
<td colspan="6" class="py-16 text-center">
<div class="flex flex-col items-center gap-4 max-w-lg mx-auto">
<svg class="w-20 h-20 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h3 class="text-xl font-semibold text-gray-400" x-text="$store.global.t('noAccountsYet')">
No Accounts Yet
</h3>
<p class="text-sm text-gray-600 max-w-md leading-relaxed" x-text="$store.global.t('noAccountsDesc')">
Get started by adding a Google account via OAuth, or use the CLI command to import credentials.
</p>
<div class="flex items-center gap-4 mt-2">
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20"
onclick="document.getElementById('add_account_modal').showModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('addFirstAccount')">Add Your First Account</span>
</button>
<span class="text-xs text-gray-600" x-text="$store.global.t('or')">or</span>
<div class="text-xs font-mono bg-space-800 px-3 py-2 rounded border border-space-border text-gray-400">
npm run accounts:add
</div>
</div>
</div>
</td>
</tr>
</template>
<!-- Account Rows -->
<template x-for="acc in filteredAccounts" :key="acc.email">
<tr class="border-b border-space-border/30 last:border-0 hover:bg-white/5 transition-colors group">
<td class="pl-6 py-4">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
:checked="acc.enabled !== false"
@change="toggleAccount(acc.email, $el.checked)">
<div class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white"></div>
</label>
</td>
<td class="py-4">
<div class="tooltip tooltip-right" :data-tip="acc.email">
<span class="font-mono text-sm text-gray-300 truncate max-w-[320px] inline-block cursor-help group-hover:text-white transition-colors"
x-text="formatEmail(acc.email)">
</span>
</div>
</td>
<td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
:class="acc.source === 'oauth' ? 'bg-neon-purple/10 text-neon-purple border border-neon-purple/30' : 'bg-gray-500/10 text-gray-400 border border-gray-500/30'"
x-text="acc.source || 'oauth'">
</span>
</td>
<td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
:class="{
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra',
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro',
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
}"
x-text="(acc.subscription?.tier || 'free').toUpperCase()">
</span>
</td>
<td class="py-4">
<div x-data="{ quota: getMainModelQuota(acc) }">
<template x-if="quota.percent !== null">
<div class="flex items-center gap-2">
<div class="w-16 bg-gray-700 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full transition-all"
:class="{
'bg-neon-green': quota.percent > 50,
'bg-yellow-500': quota.percent > 20 && quota.percent <= 50,
'bg-red-500': quota.percent <= 20
}"
:style="`width: ${quota.percent}%`">
</div>
</div>
<span class="text-xs font-mono text-gray-400" x-text="quota.percent + '%'"></span>
</div>
</template>
<template x-if="quota.percent === null">
<span class="text-xs text-gray-600">-</span>
</template>
</div>
</td>
<td class="py-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full flex-shrink-0"
:class="acc.status === 'ok' ?
'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)] animate-pulse' :
'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'">
</div>
<span class="text-xs font-mono font-semibold"
:class="acc.status === 'ok' ? 'text-neon-green' : 'text-red-400'"
x-text="acc.status.toUpperCase()">
</span>
</div>
</td>
<td class="py-4 pr-6">
<div class="flex justify-end gap-2">
<!-- Fix Button -->
<button x-show="acc.status === 'invalid'"
class="px-3 py-1 text-[10px] font-bold font-mono uppercase tracking-wider rounded bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20 border border-yellow-500/30 hover:border-yellow-500/50 transition-all"
@click="fixAccount(acc.email)"
x-text="$store.global.t('fix')">
FIX
</button>
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
@click="refreshAccount(acc.email)" :title="$store.global.t('refreshData')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
class="p-2 rounded transition-colors"
:class="acc.source === 'database' ? 'text-gray-700 cursor-not-allowed' : 'hover:bg-red-500/10 text-gray-500 hover:text-red-400'"
:disabled="acc.source === 'database'"
@click="acc.source !== 'database' && confirmDeleteAccount(acc.email)"
:title="acc.source === 'database' ? $store.global.t('cannotDeleteDatabase') : $store.global.t('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
</template>
<!-- No Search Results -->
<template x-if="$store.data.accounts.length > 0 && filteredAccounts.length === 0">
<tr>
<td colspan="6" class="py-12 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p>
<button class="btn btn-xs btn-ghost text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Account Status Info -->
<div x-show="$store.data.accounts.length > 0" class="mt-4 px-6 py-3 bg-space-900/20 rounded-lg border border-space-border/20">
<p class="text-xs text-gray-600 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span x-html="$store.global.t('disabledAccountsNote')"></span>
</p>
</div>
<!-- Delete Confirmation Modal -->
<dialog id="delete_account_modal" class="modal">
<div class="modal-box bg-space-900 border-2 border-red-500/50">
<h3 class="font-bold text-lg text-red-400 flex items-center gap-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('dangerousOperation')">Dangerous Operation</span>
</h3>
<p class="py-4 text-gray-300">
<span x-text="$store.global.t('confirmDeletePrompt')">Are you sure you want to delete account</span>
<strong class="text-white font-mono" x-text="deleteTarget"></strong>?
</p>
<div class="bg-red-500/10 border border-red-500/30 rounded p-3 mb-4">
<p class="text-sm text-red-300 flex items-start gap-2">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('deleteWarning')">This action cannot be undone. All configuration and historical records will be permanently deleted.</span>
</p>
</div>
<div class="modal-action">
<button class="btn btn-ghost text-gray-400" onclick="document.getElementById('delete_account_modal').close()" x-text="$store.global.t('cancel')">
Cancel
</button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
@click="executeDelete()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span x-text="$store.global.t('confirmDelete')">Confirm Delete</span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>

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

@@ -0,0 +1,437 @@
<div x-data="dashboard" class="view-container">
<!-- Compact Header -->
<div class="flex items-center justify-between gap-4 mb-6">
<!-- Title with inline subtitle -->
<div class="flex items-baseline gap-3">
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('dashboard')">
Dashboard
</h1>
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('systemDesc')">
CLAUDE PROXY SYSTEM
</span>
</div>
<!-- Compact Status Indicator -->
<div class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-space-900/60 border border-space-border/40">
<div class="relative flex items-center justify-center">
<span class="absolute w-1.5 h-1.5 bg-neon-green rounded-full animate-ping opacity-75"></span>
<span class="relative w-1.5 h-1.5 bg-neon-green rounded-full"></span>
</div>
<span class="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Live</span>
<span class="text-gray-700"></span>
<span class="text-[10px] font-mono text-gray-400 tabular-nums"
x-text="new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'})">
</span>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'accounts'"
:title="$store.global.t('clickToViewAllAccounts')">
<!-- Icon 移到右上角,缩小并变灰 -->
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-cyan-400/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z">
</path>
</svg>
</div>
<!-- 数字放大为主角 -->
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.total"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('totalAccounts')"></div>
<div class="stat-desc text-cyan-400/60 text-[10px] truncate flex items-center gap-1">
<span x-text="$store.global.t('linkedAccounts')"></span>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<!-- Subscription Tier Distribution -->
<div class="flex items-center gap-2 mt-2 text-[10px] font-mono" x-show="stats.subscription">
<template x-if="stats.subscription?.ultra > 0">
<span class="px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/30">
<span x-text="stats.subscription.ultra"></span> Ultra
</span>
</template>
<template x-if="stats.subscription?.pro > 0">
<span class="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/30">
<span x-text="stats.subscription.pro"></span> Pro
</span>
</template>
<template x-if="stats.subscription?.free > 0">
<span class="px-1.5 py-0.5 rounded bg-gray-500/10 text-gray-400 border border-gray-500/30">
<span x-text="stats.subscription.free"></span> Free
</span>
</template>
</div>
</div>
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-green-500/30 hover:bg-green-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'models'"
:title="$store.global.t('clickToViewModels')">
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-green-400/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.active"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('active')"></div>
<div class="stat-desc text-green-400/60 text-[10px] truncate flex items-center gap-1">
<span x-text="$store.global.t('operational')"></span>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-red-500/30 hover:bg-red-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'accounts'"
:title="$store.global.t('clickToViewLimitedAccounts')">
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-red-500/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.limited"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('rateLimited')"></div>
<div class="stat-desc text-red-500/60 text-[10px] truncate flex items-center gap-1">
<span x-text="$store.global.t('cooldown')"></span>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Global Quota Chart -->
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 col-span-1 lg:col-start-4 lg:row-start-1 h-full flex items-center justify-between gap-3 overflow-hidden relative group hover:border-space-border/60 transition-colors">
<!-- Chart Container -->
<div class="h-14 w-14 lg:h-16 lg:w-16 relative flex-shrink-0">
<canvas id="quotaChart"></canvas>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div>
</div>
</div>
<!-- Legend / Info -->
<div class="flex flex-col justify-center gap-2 flex-grow min-w-0">
<div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono truncate"
x-text="$store.global.t('globalQuota')">Global Quota</span>
</div>
<!-- Custom Legend -->
<div class="space-y-1">
<div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-purple transition-colors group/legend"
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'claude'; $store.data.computeQuotaRows(); })"
:title="$store.global.t('clickToFilterClaude')">
<div class="flex items-center gap-1.5 truncate">
<div class="w-1.5 h-1.5 rounded-full bg-neon-purple flex-shrink-0"></div>
<span class="truncate" x-text="$store.global.t('familyClaude')">Claude</span>
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-green transition-colors group/legend"
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'gemini'; $store.data.computeQuotaRows(); })"
:title="$store.global.t('clickToFilterGemini')">
<div class="flex items-center gap-1.5 truncate">
<div class="w-1.5 h-1.5 rounded-full bg-neon-green flex-shrink-0"></div>
<span class="truncate" x-text="$store.global.t('familyGemini')">Gemini</span>
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Usage Trend Chart -->
<div class="view-card">
<!-- Header with Stats and Filter -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 mb-8">
<div class="flex flex-wrap items-center gap-5">
<div class="flex items-center gap-2.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
class="w-4 h-4 text-neon-purple">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
<h3 class="text-xs font-mono text-gray-400 uppercase tracking-widest whitespace-nowrap"
x-text="$store.global.t('requestVolume')">Request Volume</h3>
</div>
<!-- Usage Stats Pills -->
<div class="flex flex-wrap gap-2.5 text-[10px] font-mono">
<div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('totalColon')">Total:</span>
<span class="text-white ml-1 font-bold" x-text="usageStats.total"></span>
</div>
<div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('todayColon')">Today:</span>
<span class="text-neon-cyan ml-1 font-bold" x-text="usageStats.today"></span>
</div>
<div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('hour1Colon')">1H:</span>
<span class="text-neon-green ml-1 font-bold" x-text="usageStats.thisHour"></span>
</div>
</div>
</div>
<div class="flex items-center gap-3 w-full sm:w-auto justify-end flex-wrap">
<!-- Time Range Dropdown -->
<div class="relative">
<button @click="showTimeRangeDropdown = !showTimeRangeDropdown; showDisplayModeDropdown = false; showModelFilter = false"
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-cyan/50 transition-colors whitespace-nowrap">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span x-text="getTimeRangeLabel()"></span>
<svg class="w-3 h-3 transition-transform" :class="{'rotate-180': showTimeRangeDropdown}" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="showTimeRangeDropdown" @click.outside="showTimeRangeDropdown = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-36 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 overflow-hidden py-1"
style="display: none;">
<button @click="setTimeRange('1h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '1h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last1Hour')"></button>
<button @click="setTimeRange('6h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '6h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last6Hours')"></button>
<button @click="setTimeRange('24h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '24h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last24Hours')"></button>
<button @click="setTimeRange('7d')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '7d' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last7Days')"></button>
<button @click="setTimeRange('all')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === 'all' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('allTime')"></button>
</div>
</div>
<!-- Display Mode Dropdown -->
<div class="relative">
<button @click="showDisplayModeDropdown = !showDisplayModeDropdown; showTimeRangeDropdown = false; showModelFilter = false"
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-purple/50 transition-colors whitespace-nowrap">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span x-text="displayMode === 'family' ? $store.global.t('family') : $store.global.t('model')"></span>
<svg class="w-3 h-3 transition-transform" :class="{'rotate-180': showDisplayModeDropdown}" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="showDisplayModeDropdown" @click.outside="showDisplayModeDropdown = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-32 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 overflow-hidden py-1"
style="display: none;">
<button @click="setDisplayMode('family')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="displayMode === 'family' ? 'text-neon-purple' : 'text-gray-400'"
x-text="$store.global.t('family')"></button>
<button @click="setDisplayMode('model')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="displayMode === 'model' ? 'text-neon-purple' : 'text-gray-400'"
x-text="$store.global.t('model')"></button>
</div>
</div>
<!-- Filter Dropdown -->
<div class="relative">
<button @click="showModelFilter = !showModelFilter; showTimeRangeDropdown = false; showDisplayModeDropdown = false"
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-purple/50 transition-colors whitespace-nowrap">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<span x-text="$store.global.t('filter') + ' (' + getSelectedCount() + ')'">Filter (0/0)</span>
<svg class="w-3 h-3 transition-transform" :class="{'rotate-180': showModelFilter}" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown Menu -->
<div x-show="showModelFilter" @click.outside="showModelFilter = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-72 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 overflow-hidden"
style="display: none;">
<!-- Header -->
<div
class="flex items-center justify-between px-3 py-2 border-b border-space-border/50 bg-space-800/50">
<span class="text-[10px] font-mono text-gray-500 uppercase"
x-text="displayMode === 'family' ? $store.global.t('selectFamilies') : $store.global.t('selectModels')"></span>
<div class="flex gap-1">
<button @click="autoSelectTopN(5)" class="text-[10px] text-neon-purple hover:underline"
:title="$store.global.t('smartTitle')" x-text="$store.global.t('frequentModels')">
Smart
</button>
<span class="text-gray-600">|</span>
<button @click="selectAll()" class="text-[10px] text-neon-cyan hover:underline"
x-text="$store.global.t('all')">All</button>
<span class="text-gray-600">|</span>
<button @click="deselectAll()" class="text-[10px] text-gray-500 hover:underline"
x-text="$store.global.t('none')">None</button>
</div>
</div>
<!-- Hierarchical List -->
<div class="max-h-64 overflow-y-auto p-2 space-y-2">
<template x-for="family in families" :key="family">
<div class="space-y-1">
<!-- Family Header -->
<label
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer group"
x-show="displayMode === 'family'">
<input type="checkbox" :checked="isFamilySelected(family)"
@change="toggleFamily(family)"
class="checkbox checkbox-xs checkbox-primary">
<div class="w-2 h-2 rounded-full flex-shrink-0"
:style="'background-color:' + getFamilyColor(family)"></div>
<span class="text-xs text-gray-300 font-medium group-hover:text-white"
x-text="$store.global.t('family' + family.charAt(0).toUpperCase() + family.slice(1))"></span>
<span class="text-[10px] text-gray-600 ml-auto"
x-text="'(' + (modelTree[family] || []).length + ')'"></span>
</label>
<!-- Family Section Header (Model Mode) -->
<div class="flex items-center gap-2 px-2 py-1 text-[10px] text-gray-500 uppercase font-bold"
x-show="displayMode === 'model'">
<div class="w-1.5 h-1.5 rounded-full"
:style="'background-color:' + getFamilyColor(family)"></div>
<span
x-text="$store.global.t('family' + family.charAt(0).toUpperCase() + family.slice(1))"></span>
</div>
<!-- Models in Family -->
<template x-if="displayMode === 'model'">
<div class="ml-4 space-y-0.5">
<template x-for="(model, modelIndex) in (modelTree[family] || [])"
:key="family + ':' + model">
<label
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-white/5 cursor-pointer group">
<input type="checkbox" :checked="isModelSelected(family, model)"
@change="toggleModel(family, model)"
class="checkbox checkbox-xs checkbox-primary">
<div class="w-2 h-2 rounded-full flex-shrink-0"
:style="'background-color:' + getModelColor(family, modelIndex)">
</div>
<span class="text-xs text-gray-400 truncate group-hover:text-white"
x-text="model"></span>
</label>
</template>
</div>
</template>
</div>
</template>
<!-- Empty State -->
<div x-show="families.length === 0" class="text-center py-4 text-gray-600 text-xs"
x-text="$store.global.t('noDataTracked')">
No data tracked yet
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dynamic Legend -->
<div class="flex flex-wrap gap-3 mb-5"
x-show="displayMode === 'family' ? selectedFamilies.length > 0 : Object.values(selectedModels).flat().length > 0">
<!-- Family Mode Legend -->
<template x-if="displayMode === 'family'">
<template x-for="family in selectedFamilies" :key="family">
<div class="flex items-center gap-1.5 text-[10px] font-mono">
<div class="w-2 h-2 rounded-full" :style="'background-color:' + getFamilyColor(family)"></div>
<span class="text-gray-400"
x-text="$store.global.t('family' + family.charAt(0).toUpperCase() + family.slice(1))"></span>
</div>
</template>
</template>
<!-- Model Mode Legend -->
<template x-if="displayMode === 'model'">
<template x-for="family in families" :key="'legend-' + family">
<template x-for="(model, modelIndex) in (selectedModels[family] || [])" :key="family + ':' + model">
<div class="flex items-center gap-1.5 text-[10px] font-mono">
<div class="w-2 h-2 rounded-full"
:style="'background-color:' + getModelColor(family, modelIndex)"></div>
<span class="text-gray-400" x-text="model"></span>
</div>
</template>
</template>
</template>
</div>
<!-- Chart -->
<div class="h-48 w-full relative">
<canvas id="usageTrendChart"></canvas>
<!-- Overall Loading State -->
<div x-show="!stats.hasTrendData"
class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10"
style="display: none;">
<div class="text-xs font-mono text-gray-500 flex items-center gap-2">
<span class="loading loading-spinner loading-xs"></span>
<span x-text="$store.global.t('syncing')">SYNCING...</span>
</div>
</div>
<!-- Empty State (After Filtering) -->
<div x-show="stats.hasTrendData && !hasFilteredTrendData"
class="absolute inset-0 flex flex-col items-center justify-center bg-space-900/30 z-10"
style="display: none;">
<div class="flex flex-col items-center gap-4 animate-fade-in">
<div class="w-12 h-12 rounded-full bg-space-850 flex items-center justify-center text-gray-600 border border-space-border/50">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="text-xs font-mono text-gray-500 text-center">
<p x-text="$store.global.t('noDataTracked')">No data tracked yet</p>
<p class="text-[10px] opacity-60 mt-1" x-text="'[' + getTimeRangeLabel() + ']'"></p>
</div>
</div>
</div>
<!-- No Selection State -->
<div x-show="stats.hasTrendData && hasFilteredTrendData && (displayMode === 'family' ? selectedFamilies.length === 0 : Object.values(selectedModels).flat().length === 0)"
class="absolute inset-0 flex items-center justify-center bg-space-900/30 z-10"
style="display: none;">
<div class="text-xs font-mono text-gray-500"
x-text="displayMode === 'family' ? $store.global.t('selectFamilies') : $store.global.t('selectModels')">
</div>
</div>
</div>
</div>
</div>

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

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

251
public/views/models.html Normal file
View File

@@ -0,0 +1,251 @@
<div x-data="models" class="view-container">
<!-- Compact Header -->
<div class="flex items-center justify-between gap-4 mb-6">
<!-- Title with inline subtitle -->
<div class="flex items-baseline gap-3">
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('models')">
Models
</h1>
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('modelsPageDesc')">
Real-time quota and status for all available models.
</span>
</div>
<!-- Search Bar -->
<div class="relative w-72 h-9">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-xs placeholder-gray-600"
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
<button x-show="$store.data.filters.search"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-75"
x-transition:enter-end="opacity-100 scale-100"
@click="$store.data.filters.search = ''; $store.data.computeQuotaRows()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-white transition-colors">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Controls -->
<div class="view-card !p-4 flex flex-col lg:flex-row items-center justify-between gap-4">
<div class="flex flex-col md:flex-row items-center gap-4 w-full lg:w-auto flex-wrap">
<!-- Custom Select -->
<div class="relative w-full md:w-64 h-9">
<select
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-4 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all truncate text-xs cursor-pointer"
style="appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: none;"
x-model="$store.data.filters.account" @change="$store.data.computeQuotaRows()">
<option value="all" x-text="$store.global.t('allAccounts')">All Accounts</option>
<template x-for="acc in $store.data.accounts" :key="acc.email">
<option :value="acc.email" x-text="acc.email.split('@')[0]"></option>
</template>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Filter Buttons -->
<div class="join h-9 w-full md:w-auto overflow-x-auto">
<button
class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="$store.data.filters.family === 'all'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()"
x-text="$store.global.t('allCaps')">ALL</button>
<button
class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="$store.data.filters.family === 'claude'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()"
x-text="$store.global.t('claudeCaps')">CLAUDE</button>
<button
class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="$store.data.filters.family === 'gemini'
? 'bg-neon-green/20 text-neon-green border-neon-green/60 shadow-lg shadow-neon-green/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()"
x-text="$store.global.t('geminiCaps')">GEMINI</button>
</div>
</div>
</div>
<!-- Main Table Card -->
<div class="view-card !p-0">
<div class="overflow-x-auto min-h-[400px]">
<table class="standard-table"
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
<thead>
<tr>
<th class="w-14 py-3 pl-4 whitespace-nowrap" x-text="$store.global.t('stat')">Stat</th>
<th class="py-3 whitespace-nowrap" x-text="$store.global.t('modelIdentity')">Model Identity</th>
<th class="min-w-[12rem] py-3 whitespace-nowrap" x-text="$store.global.t('globalQuota')">Global
Quota</th>
<th class="min-w-[8rem] py-3 whitespace-nowrap" x-text="$store.global.t('nextReset')">Next Reset
</th>
<th class="py-3 whitespace-nowrap" x-text="$store.global.t('distribution')">Account
Distribution</th>
<th class="w-20 py-3 pr-4 text-right whitespace-nowrap" x-text="$store.global.t('actions')">Actions
</th>
</tr>
</thead>
<tbody class="text-sm">
<template x-for="row in $store.data.quotaRows" :key="row.modelId">
<tr class="group">
<td class="pl-4">
<div class="w-2 h-2 rounded-full transition-all duration-500"
:class="row.avgQuota > 0 ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'">
</div>
</td>
<td>
<div class="font-bold text-gray-200 group-hover:text-neon-purple transition-colors"
x-text="row.modelId"></div>
<div class="text-[10px] font-mono text-gray-500 uppercase"
x-text="$store.global.t('family' + row.family.charAt(0).toUpperCase() + row.family.slice(1))">
</div>
</td>
<td>
<div class="flex flex-col gap-1 pr-4">
<div class="flex justify-between text-xs font-mono">
<span x-text="row.avgQuota + '%'"
:class="row.avgQuota > 0 ? 'text-white' : 'text-red-500'"></span>
<!-- Available/Total Accounts Indicator -->
<span class="text-gray-500 text-[10px]"
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
</div>
<progress class="progress w-full h-1 bg-space-800"
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
:value="row.avgQuota" max="100"></progress>
</div>
</td>
<td class="font-mono text-xs">
<div class="tooltip tooltip-left"
:data-tip="row.quotaInfo && row.quotaInfo.length > 0 ? row.quotaInfo.filter(q => q.resetTime).map(q => q.email + ': ' + q.resetTime).join('\n') : 'No reset data'">
<span x-text="row.resetIn"
:class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
</div>
</td>
<td>
<div class="flex items-center justify-end gap-3">
<div
class="text-[10px] font-mono text-gray-500 hidden xl:block text-right leading-tight opacity-70">
<div
x-text="$store.global.t('activeCount', {count: row.quotaInfo?.filter(q => q.pct > 0).length || 0})">
</div>
</div>
<!-- Account Status Indicators -->
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]" x-data="{ maxVisible: 12 }">
<template x-if="!row.quotaInfo || row.quotaInfo.length === 0">
<div class="text-[10px] text-gray-600 italic">No data</div>
</template>
<template x-if="row.quotaInfo && row.quotaInfo.length > 0">
<div class="flex flex-wrap gap-1 justify-end">
<!-- Visible accounts (limited to maxVisible) -->
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
<div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div>
</div>
</template>
<!-- Overflow indicator -->
<template x-if="row.quotaInfo.length > maxVisible">
<div class="tooltip tooltip-left"
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)`).join('\n')">
<div class="w-3 h-3 rounded-[2px] bg-gray-700/50 border border-gray-600 flex items-center justify-center cursor-help hover:bg-gray-600/70 transition-colors">
<span class="text-[8px] text-gray-400 font-bold leading-none" x-text="`+${row.quotaInfo.length - maxVisible}`"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</td>
<td class="text-right pr-4">
<div
class="flex items-center justify-end gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<!-- Pin Toggle -->
<button class="btn btn-xs btn-circle transition-colors"
:class="row.pinned ? 'bg-neon-purple/20 text-neon-purple border-neon-purple/50 hover:bg-neon-purple/30' : 'btn-ghost text-gray-600 hover:text-gray-300'"
@click="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
@keydown.enter="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
@keydown.space.prevent="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
:title="$store.global.t('pinToTop')"
:aria-label="row.pinned ? 'Unpin model ' + row.modelId : 'Pin model ' + row.modelId + ' to top'"
:aria-pressed="row.pinned ? 'true' : 'false'"
tabindex="0">
<svg x-show="row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
</svg>
<svg x-show="!row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5"
fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
<!-- Hide Toggle -->
<button class="btn btn-xs btn-circle transition-colors"
:class="row.hidden ? 'bg-red-500/20 text-red-400 border-red-500/50 hover:bg-red-500/30' : 'btn-ghost text-gray-400 hover:text-white'"
@click="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
@keydown.enter="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
@keydown.space.prevent="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
:title="$store.global.t('toggleVisibility')"
:aria-label="row.hidden ? 'Show model ' + row.modelId : 'Hide model ' + row.modelId"
:aria-pressed="row.hidden ? 'true' : 'false'"
tabindex="0">
<svg x-show="!row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg x-show="row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</td>
</tr>
</template>
<!-- Loading -->
<tr x-show="$store.data.loading && !$store.data.quotaRows.length">
<td colspan="6" class="h-64 text-center">
<div class="flex flex-col items-center justify-center gap-3">
<span class="loading loading-bars loading-md text-neon-purple"></span>
<span class="text-xs font-mono text-gray-600 animate-pulse"
x-text="$store.global.t('establishingUplink')">ESTABLISHING
UPLINK...</span>
</div>
</td>
</tr>
<!-- Empty -->
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0">
<td colspan="6" class="h-64 text-center text-gray-600 font-mono text-xs"
x-text="$store.global.t('noSignal')">
NO SIGNAL DETECTED
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

1021
public/views/settings.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,16 @@ export class AccountManager {
this.#initialized = true; 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 * Get the number of accounts
* @returns {number} Number of configured accounts * @returns {number} Number of configured accounts
@@ -278,6 +288,8 @@ export class AccountManager {
accounts: this.#accounts.map(a => ({ accounts: this.#accounts.map(a => ({
email: a.email, email: a.email,
source: a.source, source: a.source,
enabled: a.enabled !== false, // Default to true if undefined
projectId: a.projectId || null,
modelRateLimits: a.modelRateLimits || {}, modelRateLimits: a.modelRateLimits || {},
isInvalid: a.isInvalid || false, isInvalid: a.isInvalid || false,
invalidReason: a.invalidReason || null, invalidReason: a.invalidReason || null,

View File

@@ -39,6 +39,9 @@ export function getAvailableAccounts(accounts, modelId = null) {
return accounts.filter(acc => { return accounts.filter(acc => {
if (acc.isInvalid) return false; if (acc.isInvalid) return false;
// WebUI: Skip disabled accounts
if (acc.enabled === false) return false;
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) { if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
const limit = acc.modelRateLimits[modelId]; const limit = acc.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) { if (limit.isRateLimited && limit.resetTime > Date.now()) {

View File

@@ -19,6 +19,9 @@ import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
function isAccountUsable(account, modelId) { function isAccountUsable(account, modelId) {
if (!account || account.isInvalid) return false; if (!account || account.isInvalid) return false;
// WebUI: Skip disabled accounts
if (account.enabled === false) return false;
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) { if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
const limit = account.modelRateLimits[modelId]; const limit = account.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) { if (limit.isRateLimited && limit.resetTime > Date.now()) {

View File

@@ -27,10 +27,14 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
const accounts = (config.accounts || []).map(acc => ({ const accounts = (config.accounts || []).map(acc => ({
...acc, ...acc,
lastUsed: acc.lastUsed || null, 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 // Reset invalid flag on startup - give accounts a fresh chance to refresh
isInvalid: false, isInvalid: false,
invalidReason: null, 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 || {}; const settings = config.settings || {};
@@ -107,6 +111,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
accounts: accounts.map(acc => ({ accounts: accounts.map(acc => ({
email: acc.email, email: acc.email,
source: acc.source, source: acc.source,
enabled: acc.enabled !== false, // Persist enabled state
dbPath: acc.dbPath || null, dbPath: acc.dbPath || null,
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined, refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
apiKey: acc.source === 'manual' ? acc.apiKey : undefined, apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
@@ -115,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
isInvalid: acc.isInvalid || false, isInvalid: acc.isInvalid || false,
invalidReason: acc.invalidReason || null, invalidReason: acc.invalidReason || null,
modelRateLimits: acc.modelRateLimits || {}, 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, settings: settings,
activeIndex: activeIndex activeIndex: activeIndex

View File

@@ -32,15 +32,16 @@ function generatePKCE() {
* Generate authorization URL for Google OAuth * Generate authorization URL for Google OAuth
* Returns the URL and the PKCE verifier (needed for token exchange) * 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 * @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 { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex'); const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: OAUTH_CONFIG.clientId, client_id: OAUTH_CONFIG.clientId,
redirect_uri: OAUTH_REDIRECT_URI, redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
response_type: 'code', response_type: 'code',
scope: OAUTH_CONFIG.scopes.join(' '), scope: OAUTH_CONFIG.scopes.join(' '),
access_type: 'offline', access_type: 'offline',

View File

@@ -12,17 +12,18 @@
// Re-export public API // Re-export public API
export { sendMessage } from './message-handler.js'; export { sendMessage } from './message-handler.js';
export { sendMessageStream } from './streaming-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 // Default export for backwards compatibility
import { sendMessage } from './message-handler.js'; import { sendMessage } from './message-handler.js';
import { sendMessageStream } from './streaming-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 { export default {
sendMessage, sendMessage,
sendMessageStream, sendMessageStream,
listModels, listModels,
fetchAvailableModels, fetchAvailableModels,
getModelQuotas getModelQuotas,
getSubscriptionTier
}; };

View File

@@ -110,3 +110,75 @@ export async function getModelQuotas(token) {
return quotas; 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 };
}

86
src/config.js Normal file
View File

@@ -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 };

View File

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

205
src/modules/usage-stats.js Normal file
View File

@@ -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
};

View File

@@ -6,12 +6,20 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; 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 { forceRefresh } from './auth/token-extractor.js';
import { REQUEST_BODY_LIMIT } from './constants.js'; import { REQUEST_BODY_LIMIT } from './constants.js';
import { AccountManager } from './account-manager/index.js'; import { AccountManager } from './account-manager/index.js';
import { formatDuration } from './utils/helpers.js'; import { formatDuration } from './utils/helpers.js';
import { logger } from './utils/logger.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 // Parse fallback flag directly from command line args to avoid circular dependency
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -57,6 +65,12 @@ async function ensureInitialized() {
app.use(cors()); app.use(cors());
app.use(express.json({ limit: REQUEST_BODY_LIMIT })); app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
// 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 * Parse error message to extract error type, status code, and user-friendly message
*/ */
@@ -235,6 +249,7 @@ app.get('/account-limits', async (req, res) => {
await ensureInitialized(); await ensureInitialized();
const allAccounts = accountManager.getAllAccounts(); const allAccounts = accountManager.getAllAccounts();
const format = req.query.format || 'json'; const format = req.query.format || 'json';
const includeHistory = req.query.includeHistory === 'true';
// Fetch quotas for each account in parallel // Fetch quotas for each account in parallel
const results = await Promise.allSettled( const results = await Promise.allSettled(
@@ -251,11 +266,33 @@ app.get('/account-limits', async (req, res) => {
try { try {
const token = await accountManager.getTokenForAccount(account); 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 { return {
email: account.email, email: account.email,
status: 'ok', status: 'ok',
subscription: account.subscription,
models: quotas models: quotas
}; };
} catch (error) { } catch (error) {
@@ -263,6 +300,7 @@ app.get('/account-limits', async (req, res) => {
email: account.email, email: account.email,
status: 'error', status: 'error',
error: error.message, error: error.message,
subscription: account.subscription || { tier: 'unknown', projectId: null },
models: {} models: {}
}; };
} }
@@ -409,32 +447,61 @@ app.get('/account-limits', async (req, res) => {
return res.send(lines.join('\n')); return res.send(lines.join('\n'));
} }
// Default: JSON format // Get account metadata from AccountManager
res.json({ const accountStatus = accountManager.getStatus();
const accountMetadataMap = new Map(
accountStatus.accounts.map(a => [a.email, a])
);
// Build response data
const responseData = {
timestamp: new Date().toLocaleString(), timestamp: new Date().toLocaleString(),
totalAccounts: allAccounts.length, totalAccounts: allAccounts.length,
models: sortedModels, models: sortedModels,
accounts: accountLimits.map(acc => ({ modelConfig: config.modelMapping || {},
email: acc.email, accounts: accountLimits.map(acc => {
status: acc.status, // Merge quota data with account metadata
error: acc.error || null, const metadata = accountMetadataMap.get(acc.email) || {};
limits: Object.fromEntries( return {
sortedModels.map(modelId => { email: acc.email,
const quota = acc.models?.[modelId]; status: acc.status,
if (!quota) { error: acc.error || null,
return [modelId, null]; // Include metadata from AccountManager (WebUI needs these)
} source: metadata.source || 'unknown',
return [modelId, { enabled: metadata.enabled !== false,
remaining: quota.remainingFraction !== null projectId: metadata.projectId || null,
? `${Math.round(quota.remainingFraction * 100)}%` isInvalid: metadata.isInvalid || false,
: 'N/A', invalidReason: metadata.invalidReason || null,
remainingFraction: quota.remainingFraction, lastUsed: metadata.lastUsed || null,
resetTime: quota.resetTime || 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) { } catch (error) {
res.status(500).json({ res.status(500).json({
status: 'error', status: 'error',
@@ -525,13 +592,12 @@ app.post('/v1/messages', async (req, res) => {
// Ensure account manager is initialized // Ensure account manager is initialized
await ensureInitialized(); await ensureInitialized();
const { const {
model, model,
messages, messages,
max_tokens,
stream, stream,
system, system,
max_tokens,
tools, tools,
tool_choice, tool_choice,
thinking, thinking,
@@ -540,9 +606,19 @@ app.post('/v1/messages', async (req, res) => {
temperature temperature
} = req.body; } = 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. // 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. // If we have some available accounts, we try them first.
const modelId = model || 'claude-3-5-sonnet-20241022';
if (accountManager.isAllRateLimited(modelId)) { if (accountManager.isAllRateLimited(modelId)) {
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`); logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
accountManager.resetAllRateLimits(); accountManager.resetAllRateLimits();
@@ -561,7 +637,7 @@ app.post('/v1/messages', async (req, res) => {
// Build the request object // Build the request object
const request = { const request = {
model: model || 'claude-3-5-sonnet-20241022', model: modelId,
messages, messages,
max_tokens: max_tokens || 4096, max_tokens: max_tokens || 4096,
stream, stream,
@@ -667,6 +743,8 @@ app.post('/v1/messages', async (req, res) => {
/** /**
* Catch-all for unsupported endpoints * Catch-all for unsupported endpoints
*/ */
usageStats.setupRoutes(app);
app.use('*', (req, res) => { app.use('*', (req, res) => {
if (logger.isDebugEnabled) { if (logger.isDebugEnabled) {
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`); logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);

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

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

View File

@@ -5,6 +5,9 @@
* Simple ANSI codes used to avoid dependencies. * Simple ANSI codes used to avoid dependencies.
*/ */
import { EventEmitter } from 'events';
import util from 'util';
const COLORS = { const COLORS = {
RESET: '\x1b[0m', RESET: '\x1b[0m',
BRIGHT: '\x1b[1m', BRIGHT: '\x1b[1m',
@@ -20,9 +23,12 @@ const COLORS = {
GRAY: '\x1b[90m' GRAY: '\x1b[90m'
}; };
class Logger { class Logger extends EventEmitter {
constructor() { constructor() {
super();
this.isDebugEnabled = false; this.isDebugEnabled = false;
this.history = [];
this.maxHistory = 1000;
} }
/** /**
@@ -40,6 +46,13 @@ class Logger {
return new Date().toISOString(); return new Date().toISOString();
} }
/**
* Get log history
*/
getHistory() {
return this.history;
}
/** /**
* Format and print a log message * Format and print a log message
* @param {string} level * @param {string} level
@@ -49,10 +62,28 @@ class Logger {
*/ */
print(level, color, message, ...args) { print(level, color, message, ...args) {
// Format: [TIMESTAMP] [LEVEL] Message // Format: [TIMESTAMP] [LEVEL] Message
const timestamp = `${COLORS.GRAY}[${this.getTimestamp()}]${COLORS.RESET}`; const timestampStr = this.getTimestamp();
const timestamp = `${COLORS.GRAY}[${timestampStr}]${COLORS.RESET}`;
const levelTag = `${color}[${level}]${COLORS.RESET}`; const levelTag = `${color}[${level}]${COLORS.RESET}`;
console.log(`${timestamp} ${levelTag} ${message}`, ...args); // Format the message with args similar to console.log
const formattedMessage = util.format(message, ...args);
console.log(`${timestamp} ${levelTag} ${formattedMessage}`);
// Store structured log
const logEntry = {
timestamp: timestampStr,
level,
message: formattedMessage
};
this.history.push(logEntry);
if (this.history.length > this.maxHistory) {
this.history.shift();
}
this.emit('log', logEntry);
} }
/** /**

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

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

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

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

View File

@@ -0,0 +1,217 @@
/**
* Frontend Test Suite - Accounts Page
* Tests the account manager component functionality
*
* Run: node tests/test-frontend-accounts.cjs
*/
const http = require('http');
const BASE_URL = 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);
});

View File

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

View File

@@ -0,0 +1,160 @@
/**
* Frontend Test Suite - Dashboard Page
* Tests the dashboard component functionality
*
* Run: node tests/test-frontend-dashboard.cjs
*/
const http = require('http');
const BASE_URL = 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);
});

View File

@@ -0,0 +1,163 @@
/**
* Frontend Test Suite - Logs Page
* Tests the logs viewer component functionality
*
* Run: node tests/test-frontend-logs.cjs
*/
const http = require('http');
const BASE_URL = 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);
});

View File

@@ -0,0 +1,348 @@
/**
* Frontend Test Suite - Settings Page
* Tests the settings and Claude configuration components
*
* Run: node tests/test-frontend-settings.cjs
*/
const http = require('http');
const BASE_URL = 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);
});