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)
.claude/
.deepvcode/
# Runtime data
data/
# Test artifacts
tests/utils/*.png

View File

@@ -87,6 +87,9 @@ src/
│ ├── token-extractor.js # Legacy token extraction from DB
│ └── database.js # SQLite database access
├── webui/ # Web Management Interface
│ └── index.js # Express router and API endpoints
├── cli/ # CLI tools
│ └── accounts.js # Account management CLI
@@ -105,10 +108,33 @@ src/
└── native-module-helper.js # Auto-rebuild for native modules
```
**Frontend Structure (public/):**
```
public/
├── index.html # Main entry point
├── js/
│ ├── app.js # Main application logic (Alpine.js)
│ ├── store.js # Global state management
│ ├── components/ # UI Components
│ │ ├── dashboard.js # Real-time stats & charts
│ │ ├── account-manager.js # Account list & OAuth handling
│ │ ├── logs-viewer.js # Live log streaming
│ │ └── claude-config.js # CLI settings editor
│ └── utils/ # Frontend utilities
└── views/ # HTML partials (loaded dynamically)
├── dashboard.html
├── accounts.html
├── settings.html
└── logs.html
```
**Key Modules:**
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI
- **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
- `model-api.js`: Model listing, quota retrieval (`getModelQuotas()`), and subscription tier detection (`getSubscriptionTier()`)
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
@@ -123,6 +149,17 @@ src/
- Session ID derived from first user message hash for cache continuity
- Account state persisted to `~/.config/antigravity-proxy/accounts.json`
**Account Data Model:**
Each account object in `accounts.json` contains:
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed`
- **Credentials**: `refreshToken` (OAuth) or `apiKey` (manual)
- **Subscription**: `{ tier, projectId, detectedAt }` - automatically detected via `loadCodeAssist` API
- `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API
- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication
**Prompt Caching:**
- Cache is organization-scoped (requires same account + session ID)
- Session ID is SHA256 hash of first user message content (stable across turns)
@@ -152,6 +189,20 @@ src/
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
**Web Management UI:**
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN)
- **Architecture**: Single Page Application (SPA) with dynamic view loading
- **State Management**: Alpine.store for global state (accounts, settings, logs)
- **Features**:
- Real-time dashboard with Chart.js visualization and subscription tier distribution
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars
- OAuth flow handling via popup window
- Live log streaming via Server-Sent Events (SSE)
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
## Testing Notes
- Tests require the server to be running (`npm start` in separate terminal)
@@ -186,6 +237,12 @@ src/
- `sleep(ms)` - Promise-based delay
- `isNetworkError(error)` - Check if error is a transient network error
**Data Persistence:**
- Subscription and quota data are automatically fetched when `/account-limits` is called
- Updated data is saved to `accounts.json` asynchronously (non-blocking)
- On server restart, accounts load with last known subscription/quota state
- Quota is refreshed on each WebUI poll (default: 30s with jitter)
**Logger:** Structured logging via `src/utils/logger.js`:
- `logger.info(msg)` - Standard info (blue)
- `logger.success(msg)` - Success messages (green)
@@ -195,6 +252,17 @@ src/
- `logger.setDebug(true)` - Enable debug mode
- `logger.isDebugEnabled` - Check if debug mode is on
**WebUI APIs:**
- `/api/accounts/*` - Account management (list, add, remove, refresh)
- `/api/config/*` - Server configuration (read/write)
- `/api/claude/config` - Claude CLI settings
- `/api/logs/stream` - SSE endpoint for real-time logs
- `/api/auth/url` - Generate Google OAuth URL
- `/account-limits` - Fetch account quotas and subscription data
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
## Maintenance
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.

186
README.md
View File

@@ -59,62 +59,7 @@ npm start
## Quick Start
### 1. Add Account(s)
You have two options:
**Option A: Use Antigravity (Single Account)**
If you have Antigravity installed and logged in, the proxy will automatically extract your token. No additional setup needed.
**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)**
Add one or more Google accounts for load balancing.
#### Desktop/Laptop (with browser)
```bash
# If installed via npm
antigravity-claude-proxy accounts add
# If using npx
npx antigravity-claude-proxy@latest accounts add
# If cloned locally
npm run accounts:add
```
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts.
#### Headless Server (Docker, SSH, no desktop)
```bash
# If installed via npm
antigravity-claude-proxy accounts add --no-browser
# If using npx
npx antigravity-claude-proxy@latest accounts add -- --no-browser
# If cloned locally
npm run accounts:add -- --no-browser
```
This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal.
#### Manage accounts
```bash
# List all accounts
antigravity-claude-proxy accounts list
# Verify accounts are working
antigravity-claude-proxy accounts verify
# Interactive account management
antigravity-claude-proxy accounts
```
### 2. Start the Proxy Server
### 1. Start the Proxy Server
```bash
# If installed via npm
@@ -129,6 +74,34 @@ npm start
The server runs on `http://localhost:8080` by default.
### 2. Link Account(s)
Choose one of the following methods to authorize the proxy:
#### **Method A: Web Dashboard (Recommended)**
1. With the proxy running, open `http://localhost:8080` in your browser.
2. Navigate to the **Accounts** tab and click **Add Account**.
3. Complete the Google OAuth authorization in the popup window.
#### **Method B: CLI (Desktop or Headless)**
If you prefer the terminal or are on a remote server:
```bash
# Desktop (opens browser)
antigravity-claude-proxy accounts add
# Headless (Docker/SSH)
antigravity-claude-proxy accounts add --no-browser
```
> For full CLI account management options, run `antigravity-claude-proxy accounts --help`.
#### **Method C: Automatic (Antigravity Users)**
If you have the **Antigravity** app installed and logged in, the proxy will automatically detect your local session. No additional setup is required.
To use a custom port:
```bash
@@ -151,6 +124,18 @@ curl "http://localhost:8080/account-limits?format=table"
### Configure Claude Code
You can configure these settings in two ways:
#### **Via Web Console (Recommended)**
1. Open the WebUI at `http://localhost:8080`.
2. Go to **Settings****Claude CLI**.
3. Select your preferred models and click **Apply to Claude CLI**.
> [!TIP] > **Configuration Precedence**: System environment variables (set in shell profile like `.zshrc`) take precedence over the `settings.json` file. If you use the Web Console to manage settings, ensure you haven't manually exported conflicting variables in your terminal.
#### **Manual Configuration**
Create or edit the Claude Code settings file:
**macOS:** `~/.claude/settings.json`
@@ -267,18 +252,18 @@ Then run `claude` for official API or `claude-antigravity` for this proxy.
### Claude Models
| Model ID | Description |
|----------|-------------|
| Model ID | Description |
| ---------------------------- | ---------------------------------------- |
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking |
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
### Gemini Models
| Model ID | Description |
|----------|-------------|
| `gemini-3-flash` | Gemini 3 Flash with thinking |
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
| Model ID | Description |
| ------------------- | ------------------------------- |
| `gemini-3-flash` | Gemini 3 Flash with thinking |
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
| `gemini-3-pro-high` | Gemini 3 Pro High with thinking |
Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
@@ -295,23 +280,74 @@ When you add multiple accounts, the proxy automatically:
- **Invalid account detection**: Accounts needing re-authentication are marked and skipped
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns
Check account status anytime:
Check account status, subscription tiers, and quota anytime:
```bash
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
# CLI Table:
curl "http://localhost:8080/account-limits?format=table"
```
#### CLI Management Reference
If you prefer using the terminal for management:
```bash
# List all accounts
antigravity-claude-proxy accounts list
# Verify account health
antigravity-claude-proxy accounts verify
# Interactive CLI menu
antigravity-claude-proxy accounts
```
---
## Web Management Console
The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port).
![Antigravity Console](images/webui-dashboard.png)
### Key Features
- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance.
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
- **Live Logs**: Stream server logs with level-based filtering and search.
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
- **Bilingual Interface**: Full support for English and Chinese (switch via Settings).
---
## Advanced Configuration
While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file.
### Configurable Options
- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
- **Custom Port**: Change the default `8080` port.
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
Refer to `config.example.json` for a complete list of fields and documentation.
---
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
| `/v1/messages` | POST | Anthropic Messages API |
| `/v1/models` | GET | List available models |
| `/refresh-token` | POST | Force token refresh |
| Endpoint | Method | Description |
| ----------------- | ------ | --------------------------------------------------------------------- |
| `/health` | GET | Health check |
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
| `/v1/messages` | POST | Anthropic Messages API |
| `/v1/models` | GET | List available models |
| `/refresh-token` | POST | Force token refresh |
---
@@ -345,6 +381,7 @@ npm run test:caching # Prompt caching
### "Could not extract token from Antigravity"
If using single-account mode with Antigravity:
1. Make sure Antigravity app is installed and running
2. Ensure you're logged in to Antigravity
@@ -353,11 +390,13 @@ Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add`
### 401 Authentication Errors
The token might have expired. Try:
```bash
curl -X POST http://localhost:8080/refresh-token
```
Or re-authenticate the account:
```bash
antigravity-claude-proxy accounts
```
@@ -369,6 +408,7 @@ With multiple accounts, the proxy automatically switches to the next available a
### Account Shows as "Invalid"
Re-authenticate the account:
```bash
antigravity-claude-proxy accounts
# Choose "Re-authenticate" for the invalid account
@@ -435,4 +475,4 @@ MIT
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)

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

View File

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

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;
}
/**
* Reload accounts from disk (force re-initialization)
* Useful when accounts.json is modified externally (e.g., by WebUI)
*/
async reload() {
this.#initialized = false;
await this.initialize();
logger.info('[AccountManager] Accounts reloaded from disk');
}
/**
* Get the number of accounts
* @returns {number} Number of configured accounts
@@ -278,6 +288,8 @@ export class AccountManager {
accounts: this.#accounts.map(a => ({
email: a.email,
source: a.source,
enabled: a.enabled !== false, // Default to true if undefined
projectId: a.projectId || null,
modelRateLimits: a.modelRateLimits || {},
isInvalid: a.isInvalid || false,
invalidReason: a.invalidReason || null,

View File

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

View File

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

View File

@@ -27,10 +27,14 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
const accounts = (config.accounts || []).map(acc => ({
...acc,
lastUsed: acc.lastUsed || null,
enabled: acc.enabled !== false, // Default to true if not specified
// Reset invalid flag on startup - give accounts a fresh chance to refresh
isInvalid: false,
invalidReason: null,
modelRateLimits: acc.modelRateLimits || {}
modelRateLimits: acc.modelRateLimits || {},
// New fields for subscription and quota tracking
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
quota: acc.quota || { models: {}, lastChecked: null }
}));
const settings = config.settings || {};
@@ -107,6 +111,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
accounts: accounts.map(acc => ({
email: acc.email,
source: acc.source,
enabled: acc.enabled !== false, // Persist enabled state
dbPath: acc.dbPath || null,
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
@@ -115,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
isInvalid: acc.isInvalid || false,
invalidReason: acc.invalidReason || null,
modelRateLimits: acc.modelRateLimits || {},
lastUsed: acc.lastUsed
lastUsed: acc.lastUsed,
// Persist subscription and quota data
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
quota: acc.quota || { models: {}, lastChecked: null }
})),
settings: settings,
activeIndex: activeIndex

View File

@@ -32,15 +32,16 @@ function generatePKCE() {
* Generate authorization URL for Google OAuth
* Returns the URL and the PKCE verifier (needed for token exchange)
*
* @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI)
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
*/
export function getAuthorizationUrl() {
export function getAuthorizationUrl(customRedirectUri = null) {
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: OAUTH_CONFIG.clientId,
redirect_uri: OAUTH_REDIRECT_URI,
redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
response_type: 'code',
scope: OAUTH_CONFIG.scopes.join(' '),
access_type: 'offline',

View File

@@ -12,17 +12,18 @@
// Re-export public API
export { sendMessage } from './message-handler.js';
export { sendMessageStream } from './streaming-handler.js';
export { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js';
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
// Default export for backwards compatibility
import { sendMessage } from './message-handler.js';
import { sendMessageStream } from './streaming-handler.js';
import { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js';
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
export default {
sendMessage,
sendMessageStream,
listModels,
fetchAvailableModels,
getModelQuotas
getModelQuotas,
getSubscriptionTier
};

View File

@@ -110,3 +110,75 @@ export async function getModelQuotas(token) {
return quotas;
}
/**
* Get subscription tier for an account
* Calls loadCodeAssist API to discover project ID and subscription tier
*
* @param {string} token - OAuth access token
* @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID
*/
export async function getSubscriptionTier(token) {
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...ANTIGRAVITY_HEADERS
};
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
try {
const url = `${endpoint}/v1internal:loadCodeAssist`;
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI'
}
})
});
if (!response.ok) {
logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`);
continue;
}
const data = await response.json();
// Extract project ID
let projectId = null;
if (typeof data.cloudaicompanionProject === 'string') {
projectId = data.cloudaicompanionProject;
} else if (data.cloudaicompanionProject?.id) {
projectId = data.cloudaicompanionProject.id;
}
// Extract subscription tier (priority: paidTier > currentTier)
let tier = 'free';
const tierId = data.paidTier?.id || data.currentTier?.id;
if (tierId) {
const lowerTier = tierId.toLowerCase();
if (lowerTier.includes('ultra')) {
tier = 'ultra';
} else if (lowerTier.includes('pro')) {
tier = 'pro';
} else {
tier = 'free';
}
}
logger.debug(`[CloudCode] Subscription detected: ${tier}, Project: ${projectId}`);
return { tier, projectId };
} catch (error) {
logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message);
}
}
// Fallback: return default values if all endpoints fail
logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
return { tier: 'free', projectId: null };
}

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

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 cors from 'cors';
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
import path from 'path';
import { fileURLToPath } from 'url';
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
import { mountWebUI } from './webui/index.js';
import { config } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { forceRefresh } from './auth/token-extractor.js';
import { REQUEST_BODY_LIMIT } from './constants.js';
import { AccountManager } from './account-manager/index.js';
import { formatDuration } from './utils/helpers.js';
import { logger } from './utils/logger.js';
import usageStats from './modules/usage-stats.js';
// Parse fallback flag directly from command line args to avoid circular dependency
const args = process.argv.slice(2);
@@ -57,6 +65,12 @@ async function ensureInitialized() {
app.use(cors());
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
// Setup usage statistics middleware
usageStats.setupMiddleware(app);
// Mount WebUI (optional web interface for account management)
mountWebUI(app, __dirname, accountManager);
/**
* Parse error message to extract error type, status code, and user-friendly message
*/
@@ -123,11 +137,11 @@ app.get('/health', async (req, res) => {
try {
await ensureInitialized();
const start = Date.now();
// Get high-level status first
const status = accountManager.getStatus();
const allAccounts = accountManager.getAllAccounts();
// Fetch quotas for each account in parallel to get detailed model info
const accountDetails = await Promise.allSettled(
allAccounts.map(async (account) => {
@@ -235,6 +249,7 @@ app.get('/account-limits', async (req, res) => {
await ensureInitialized();
const allAccounts = accountManager.getAllAccounts();
const format = req.query.format || 'json';
const includeHistory = req.query.includeHistory === 'true';
// Fetch quotas for each account in parallel
const results = await Promise.allSettled(
@@ -251,11 +266,33 @@ app.get('/account-limits', async (req, res) => {
try {
const token = await accountManager.getTokenForAccount(account);
const quotas = await getModelQuotas(token);
// Fetch both quotas and subscription tier in parallel
const [quotas, subscription] = await Promise.all([
getModelQuotas(token),
getSubscriptionTier(token)
]);
// Update account object with fresh data
account.subscription = {
tier: subscription.tier,
projectId: subscription.projectId,
detectedAt: Date.now()
};
account.quota = {
models: quotas,
lastChecked: Date.now()
};
// Save updated account data to disk (async, don't wait)
accountManager.saveToDisk().catch(err => {
logger.error('[Server] Failed to save account data:', err);
});
return {
email: account.email,
status: 'ok',
subscription: account.subscription,
models: quotas
};
} catch (error) {
@@ -263,6 +300,7 @@ app.get('/account-limits', async (req, res) => {
email: account.email,
status: 'error',
error: error.message,
subscription: account.subscription || { tier: 'unknown', projectId: null },
models: {}
};
}
@@ -409,32 +447,61 @@ app.get('/account-limits', async (req, res) => {
return res.send(lines.join('\n'));
}
// Default: JSON format
res.json({
// Get account metadata from AccountManager
const accountStatus = accountManager.getStatus();
const accountMetadataMap = new Map(
accountStatus.accounts.map(a => [a.email, a])
);
// Build response data
const responseData = {
timestamp: new Date().toLocaleString(),
totalAccounts: allAccounts.length,
models: sortedModels,
accounts: accountLimits.map(acc => ({
email: acc.email,
status: acc.status,
error: acc.error || null,
limits: Object.fromEntries(
sortedModels.map(modelId => {
const quota = acc.models?.[modelId];
if (!quota) {
return [modelId, null];
}
return [modelId, {
remaining: quota.remainingFraction !== null
? `${Math.round(quota.remainingFraction * 100)}%`
: 'N/A',
remainingFraction: quota.remainingFraction,
resetTime: quota.resetTime || null
}];
})
)
}))
});
modelConfig: config.modelMapping || {},
accounts: accountLimits.map(acc => {
// Merge quota data with account metadata
const metadata = accountMetadataMap.get(acc.email) || {};
return {
email: acc.email,
status: acc.status,
error: acc.error || null,
// Include metadata from AccountManager (WebUI needs these)
source: metadata.source || 'unknown',
enabled: metadata.enabled !== false,
projectId: metadata.projectId || null,
isInvalid: metadata.isInvalid || false,
invalidReason: metadata.invalidReason || null,
lastUsed: metadata.lastUsed || null,
modelRateLimits: metadata.modelRateLimits || {},
// Subscription data (new)
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
// Quota limits
limits: Object.fromEntries(
sortedModels.map(modelId => {
const quota = acc.models?.[modelId];
if (!quota) {
return [modelId, null];
}
return [modelId, {
remaining: quota.remainingFraction !== null
? `${Math.round(quota.remainingFraction * 100)}%`
: 'N/A',
remainingFraction: quota.remainingFraction,
resetTime: quota.resetTime || null
}];
})
)
};
})
};
// Optionally include usage history (for dashboard performance optimization)
if (includeHistory) {
responseData.history = usageStats.getHistory();
}
res.json(responseData);
} catch (error) {
res.status(500).json({
status: 'error',
@@ -525,13 +592,12 @@ app.post('/v1/messages', async (req, res) => {
// Ensure account manager is initialized
await ensureInitialized();
const {
model,
messages,
max_tokens,
stream,
system,
max_tokens,
tools,
tool_choice,
thinking,
@@ -540,9 +606,19 @@ app.post('/v1/messages', async (req, res) => {
temperature
} = req.body;
// Resolve model mapping if configured
let requestedModel = model || 'claude-3-5-sonnet-20241022';
const modelMapping = config.modelMapping || {};
if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
const targetModel = modelMapping[requestedModel].mapping;
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
requestedModel = targetModel;
}
const modelId = requestedModel;
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
// If we have some available accounts, we try them first.
const modelId = model || 'claude-3-5-sonnet-20241022';
if (accountManager.isAllRateLimited(modelId)) {
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
accountManager.resetAllRateLimits();
@@ -561,7 +637,7 @@ app.post('/v1/messages', async (req, res) => {
// Build the request object
const request = {
model: model || 'claude-3-5-sonnet-20241022',
model: modelId,
messages,
max_tokens: max_tokens || 4096,
stream,
@@ -667,6 +743,8 @@ app.post('/v1/messages', async (req, res) => {
/**
* Catch-all for unsupported endpoints
*/
usageStats.setupRoutes(app);
app.use('*', (req, res) => {
if (logger.isDebugEnabled) {
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);

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

@@ -1,15 +1,18 @@
/**
* Logger Utility
*
*
* Provides structured logging with colors and debug support.
* Simple ANSI codes used to avoid dependencies.
*/
import { EventEmitter } from 'events';
import util from 'util';
const COLORS = {
RESET: '\x1b[0m',
BRIGHT: '\x1b[1m',
DIM: '\x1b[2m',
RED: '\x1b[31m',
GREEN: '\x1b[32m',
YELLOW: '\x1b[33m',
@@ -20,14 +23,17 @@ const COLORS = {
GRAY: '\x1b[90m'
};
class Logger {
class Logger extends EventEmitter {
constructor() {
super();
this.isDebugEnabled = false;
this.history = [];
this.maxHistory = 1000;
}
/**
* Set debug mode
* @param {boolean} enabled
* @param {boolean} enabled
*/
setDebug(enabled) {
this.isDebugEnabled = !!enabled;
@@ -40,19 +46,44 @@ class Logger {
return new Date().toISOString();
}
/**
* Get log history
*/
getHistory() {
return this.history;
}
/**
* Format and print a log message
* @param {string} level
* @param {string} color
* @param {string} message
* @param {...any} args
* @param {string} level
* @param {string} color
* @param {string} message
* @param {...any} args
*/
print(level, color, message, ...args) {
// Format: [TIMESTAMP] [LEVEL] Message
const timestamp = `${COLORS.GRAY}[${this.getTimestamp()}]${COLORS.RESET}`;
const timestampStr = this.getTimestamp();
const timestamp = `${COLORS.GRAY}[${timestampStr}]${COLORS.RESET}`;
const levelTag = `${color}[${level}]${COLORS.RESET}`;
console.log(`${timestamp} ${levelTag} ${message}`, ...args);
// Format the message with args similar to console.log
const formattedMessage = util.format(message, ...args);
console.log(`${timestamp} ${levelTag} ${formattedMessage}`);
// Store structured log
const logEntry = {
timestamp: timestampStr,
level,
message: formattedMessage
};
this.history.push(logEntry);
if (this.history.length > this.maxHistory) {
this.history.shift();
}
this.emit('log', logEntry);
}
/**
@@ -98,7 +129,7 @@ class Logger {
log(message, ...args) {
console.log(message, ...args);
}
/**
* Print a section header
*/

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