Merge pull request #47 from Wha1eChai/feature/webui
feat: Add Web UI for account and quota management
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,10 @@ log.txt
|
|||||||
|
|
||||||
# Local config (may contain tokens)
|
# Local config (may contain tokens)
|
||||||
.claude/
|
.claude/
|
||||||
|
.deepvcode/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
tests/utils/*.png
|
tests/utils/*.png
|
||||||
|
|||||||
70
CLAUDE.md
70
CLAUDE.md
@@ -87,6 +87,9 @@ src/
|
|||||||
│ ├── token-extractor.js # Legacy token extraction from DB
|
│ ├── token-extractor.js # Legacy token extraction from DB
|
||||||
│ └── database.js # SQLite database access
|
│ └── database.js # SQLite database access
|
||||||
│
|
│
|
||||||
|
├── webui/ # Web Management Interface
|
||||||
|
│ └── index.js # Express router and API endpoints
|
||||||
|
│
|
||||||
├── cli/ # CLI tools
|
├── cli/ # CLI tools
|
||||||
│ └── accounts.js # Account management CLI
|
│ └── accounts.js # Account management CLI
|
||||||
│
|
│
|
||||||
@@ -105,10 +108,33 @@ src/
|
|||||||
└── native-module-helper.js # Auto-rebuild for native modules
|
└── native-module-helper.js # Auto-rebuild for native modules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Frontend Structure (public/):**
|
||||||
|
|
||||||
|
```
|
||||||
|
public/
|
||||||
|
├── index.html # Main entry point
|
||||||
|
├── js/
|
||||||
|
│ ├── app.js # Main application logic (Alpine.js)
|
||||||
|
│ ├── store.js # Global state management
|
||||||
|
│ ├── components/ # UI Components
|
||||||
|
│ │ ├── dashboard.js # Real-time stats & charts
|
||||||
|
│ │ ├── account-manager.js # Account list & OAuth handling
|
||||||
|
│ │ ├── logs-viewer.js # Live log streaming
|
||||||
|
│ │ └── claude-config.js # CLI settings editor
|
||||||
|
│ └── utils/ # Frontend utilities
|
||||||
|
└── views/ # HTML partials (loaded dynamically)
|
||||||
|
├── dashboard.html
|
||||||
|
├── accounts.html
|
||||||
|
├── settings.html
|
||||||
|
└── logs.html
|
||||||
|
```
|
||||||
|
|
||||||
**Key Modules:**
|
**Key Modules:**
|
||||||
|
|
||||||
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
|
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI
|
||||||
|
- **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs
|
||||||
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
||||||
|
- `model-api.js`: Model listing, quota retrieval (`getModelQuotas()`), and subscription tier detection (`getSubscriptionTier()`)
|
||||||
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
||||||
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
|
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
|
||||||
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
||||||
@@ -123,6 +149,17 @@ src/
|
|||||||
- Session ID derived from first user message hash for cache continuity
|
- Session ID derived from first user message hash for cache continuity
|
||||||
- Account state persisted to `~/.config/antigravity-proxy/accounts.json`
|
- Account state persisted to `~/.config/antigravity-proxy/accounts.json`
|
||||||
|
|
||||||
|
**Account Data Model:**
|
||||||
|
Each account object in `accounts.json` contains:
|
||||||
|
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed`
|
||||||
|
- **Credentials**: `refreshToken` (OAuth) or `apiKey` (manual)
|
||||||
|
- **Subscription**: `{ tier, projectId, detectedAt }` - automatically detected via `loadCodeAssist` API
|
||||||
|
- `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
|
||||||
|
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
|
||||||
|
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API
|
||||||
|
- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
|
||||||
|
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication
|
||||||
|
|
||||||
**Prompt Caching:**
|
**Prompt Caching:**
|
||||||
- Cache is organization-scoped (requires same account + session ID)
|
- Cache is organization-scoped (requires same account + session ID)
|
||||||
- Session ID is SHA256 hash of first user message content (stable across turns)
|
- Session ID is SHA256 hash of first user message content (stable across turns)
|
||||||
@@ -152,6 +189,20 @@ src/
|
|||||||
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
|
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
|
||||||
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
|
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
|
||||||
|
|
||||||
|
**Web Management UI:**
|
||||||
|
|
||||||
|
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN)
|
||||||
|
- **Architecture**: Single Page Application (SPA) with dynamic view loading
|
||||||
|
- **State Management**: Alpine.store for global state (accounts, settings, logs)
|
||||||
|
- **Features**:
|
||||||
|
- Real-time dashboard with Chart.js visualization and subscription tier distribution
|
||||||
|
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars
|
||||||
|
- OAuth flow handling via popup window
|
||||||
|
- Live log streaming via Server-Sent Events (SSE)
|
||||||
|
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
|
||||||
|
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
|
||||||
|
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
- Tests require the server to be running (`npm start` in separate terminal)
|
- Tests require the server to be running (`npm start` in separate terminal)
|
||||||
@@ -186,6 +237,12 @@ src/
|
|||||||
- `sleep(ms)` - Promise-based delay
|
- `sleep(ms)` - Promise-based delay
|
||||||
- `isNetworkError(error)` - Check if error is a transient network error
|
- `isNetworkError(error)` - Check if error is a transient network error
|
||||||
|
|
||||||
|
**Data Persistence:**
|
||||||
|
- Subscription and quota data are automatically fetched when `/account-limits` is called
|
||||||
|
- Updated data is saved to `accounts.json` asynchronously (non-blocking)
|
||||||
|
- On server restart, accounts load with last known subscription/quota state
|
||||||
|
- Quota is refreshed on each WebUI poll (default: 30s with jitter)
|
||||||
|
|
||||||
**Logger:** Structured logging via `src/utils/logger.js`:
|
**Logger:** Structured logging via `src/utils/logger.js`:
|
||||||
- `logger.info(msg)` - Standard info (blue)
|
- `logger.info(msg)` - Standard info (blue)
|
||||||
- `logger.success(msg)` - Success messages (green)
|
- `logger.success(msg)` - Success messages (green)
|
||||||
@@ -195,6 +252,17 @@ src/
|
|||||||
- `logger.setDebug(true)` - Enable debug mode
|
- `logger.setDebug(true)` - Enable debug mode
|
||||||
- `logger.isDebugEnabled` - Check if debug mode is on
|
- `logger.isDebugEnabled` - Check if debug mode is on
|
||||||
|
|
||||||
|
**WebUI APIs:**
|
||||||
|
|
||||||
|
- `/api/accounts/*` - Account management (list, add, remove, refresh)
|
||||||
|
- `/api/config/*` - Server configuration (read/write)
|
||||||
|
- `/api/claude/config` - Claude CLI settings
|
||||||
|
- `/api/logs/stream` - SSE endpoint for real-time logs
|
||||||
|
- `/api/auth/url` - Generate Google OAuth URL
|
||||||
|
- `/account-limits` - Fetch account quotas and subscription data
|
||||||
|
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
|
||||||
|
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.
|
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.
|
||||||
|
|||||||
186
README.md
186
README.md
@@ -59,62 +59,7 @@ npm start
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Add Account(s)
|
### 1. Start the Proxy Server
|
||||||
|
|
||||||
You have two options:
|
|
||||||
|
|
||||||
**Option A: Use Antigravity (Single Account)**
|
|
||||||
|
|
||||||
If you have Antigravity installed and logged in, the proxy will automatically extract your token. No additional setup needed.
|
|
||||||
|
|
||||||
**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)**
|
|
||||||
|
|
||||||
Add one or more Google accounts for load balancing.
|
|
||||||
|
|
||||||
#### Desktop/Laptop (with browser)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If installed via npm
|
|
||||||
antigravity-claude-proxy accounts add
|
|
||||||
|
|
||||||
# If using npx
|
|
||||||
npx antigravity-claude-proxy@latest accounts add
|
|
||||||
|
|
||||||
# If cloned locally
|
|
||||||
npm run accounts:add
|
|
||||||
```
|
|
||||||
|
|
||||||
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts.
|
|
||||||
|
|
||||||
#### Headless Server (Docker, SSH, no desktop)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If installed via npm
|
|
||||||
antigravity-claude-proxy accounts add --no-browser
|
|
||||||
|
|
||||||
# If using npx
|
|
||||||
npx antigravity-claude-proxy@latest accounts add -- --no-browser
|
|
||||||
|
|
||||||
# If cloned locally
|
|
||||||
npm run accounts:add -- --no-browser
|
|
||||||
```
|
|
||||||
|
|
||||||
This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal.
|
|
||||||
|
|
||||||
#### Manage accounts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all accounts
|
|
||||||
antigravity-claude-proxy accounts list
|
|
||||||
|
|
||||||
# Verify accounts are working
|
|
||||||
antigravity-claude-proxy accounts verify
|
|
||||||
|
|
||||||
# Interactive account management
|
|
||||||
antigravity-claude-proxy accounts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start the Proxy Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# If installed via npm
|
# If installed via npm
|
||||||
@@ -129,6 +74,34 @@ npm start
|
|||||||
|
|
||||||
The server runs on `http://localhost:8080` by default.
|
The server runs on `http://localhost:8080` by default.
|
||||||
|
|
||||||
|
### 2. Link Account(s)
|
||||||
|
|
||||||
|
Choose one of the following methods to authorize the proxy:
|
||||||
|
|
||||||
|
#### **Method A: Web Dashboard (Recommended)**
|
||||||
|
|
||||||
|
1. With the proxy running, open `http://localhost:8080` in your browser.
|
||||||
|
2. Navigate to the **Accounts** tab and click **Add Account**.
|
||||||
|
3. Complete the Google OAuth authorization in the popup window.
|
||||||
|
|
||||||
|
#### **Method B: CLI (Desktop or Headless)**
|
||||||
|
|
||||||
|
If you prefer the terminal or are on a remote server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desktop (opens browser)
|
||||||
|
antigravity-claude-proxy accounts add
|
||||||
|
|
||||||
|
# Headless (Docker/SSH)
|
||||||
|
antigravity-claude-proxy accounts add --no-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
> For full CLI account management options, run `antigravity-claude-proxy accounts --help`.
|
||||||
|
|
||||||
|
#### **Method C: Automatic (Antigravity Users)**
|
||||||
|
|
||||||
|
If you have the **Antigravity** app installed and logged in, the proxy will automatically detect your local session. No additional setup is required.
|
||||||
|
|
||||||
To use a custom port:
|
To use a custom port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -151,6 +124,18 @@ curl "http://localhost:8080/account-limits?format=table"
|
|||||||
|
|
||||||
### Configure Claude Code
|
### Configure Claude Code
|
||||||
|
|
||||||
|
You can configure these settings in two ways:
|
||||||
|
|
||||||
|
#### **Via Web Console (Recommended)**
|
||||||
|
|
||||||
|
1. Open the WebUI at `http://localhost:8080`.
|
||||||
|
2. Go to **Settings** → **Claude CLI**.
|
||||||
|
3. Select your preferred models and click **Apply to Claude CLI**.
|
||||||
|
|
||||||
|
> [!TIP] > **Configuration Precedence**: System environment variables (set in shell profile like `.zshrc`) take precedence over the `settings.json` file. If you use the Web Console to manage settings, ensure you haven't manually exported conflicting variables in your terminal.
|
||||||
|
|
||||||
|
#### **Manual Configuration**
|
||||||
|
|
||||||
Create or edit the Claude Code settings file:
|
Create or edit the Claude Code settings file:
|
||||||
|
|
||||||
**macOS:** `~/.claude/settings.json`
|
**macOS:** `~/.claude/settings.json`
|
||||||
@@ -267,18 +252,18 @@ Then run `claude` for official API or `claude-antigravity` for this proxy.
|
|||||||
|
|
||||||
### Claude Models
|
### Claude Models
|
||||||
|
|
||||||
| Model ID | Description |
|
| Model ID | Description |
|
||||||
|----------|-------------|
|
| ---------------------------- | ---------------------------------------- |
|
||||||
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking |
|
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking |
|
||||||
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
|
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
|
||||||
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
|
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
|
||||||
|
|
||||||
### Gemini Models
|
### Gemini Models
|
||||||
|
|
||||||
| Model ID | Description |
|
| Model ID | Description |
|
||||||
|----------|-------------|
|
| ------------------- | ------------------------------- |
|
||||||
| `gemini-3-flash` | Gemini 3 Flash with thinking |
|
| `gemini-3-flash` | Gemini 3 Flash with thinking |
|
||||||
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
|
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
|
||||||
| `gemini-3-pro-high` | Gemini 3 Pro High with thinking |
|
| `gemini-3-pro-high` | Gemini 3 Pro High with thinking |
|
||||||
|
|
||||||
Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
|
Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
|
||||||
@@ -295,23 +280,74 @@ When you add multiple accounts, the proxy automatically:
|
|||||||
- **Invalid account detection**: Accounts needing re-authentication are marked and skipped
|
- **Invalid account detection**: Accounts needing re-authentication are marked and skipped
|
||||||
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns
|
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns
|
||||||
|
|
||||||
Check account status anytime:
|
Check account status, subscription tiers, and quota anytime:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
|
||||||
|
# CLI Table:
|
||||||
curl "http://localhost:8080/account-limits?format=table"
|
curl "http://localhost:8080/account-limits?format=table"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### CLI Management Reference
|
||||||
|
|
||||||
|
If you prefer using the terminal for management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all accounts
|
||||||
|
antigravity-claude-proxy accounts list
|
||||||
|
|
||||||
|
# Verify account health
|
||||||
|
antigravity-claude-proxy accounts verify
|
||||||
|
|
||||||
|
# Interactive CLI menu
|
||||||
|
antigravity-claude-proxy accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Management Console
|
||||||
|
|
||||||
|
The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
|
||||||
|
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
|
||||||
|
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance.
|
||||||
|
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
|
||||||
|
- **Live Logs**: Stream server logs with level-based filtering and search.
|
||||||
|
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
||||||
|
- **Bilingual Interface**: Full support for English and Chinese (switch via Settings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file.
|
||||||
|
|
||||||
|
### Configurable Options
|
||||||
|
|
||||||
|
- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
|
||||||
|
- **Custom Port**: Change the default `8080` port.
|
||||||
|
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
|
||||||
|
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
||||||
|
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
|
||||||
|
|
||||||
|
Refer to `config.example.json` for a complete list of fields and documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
| ----------------- | ------ | --------------------------------------------------------------------- |
|
||||||
| `/health` | GET | Health check |
|
| `/health` | GET | Health check |
|
||||||
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
|
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
|
||||||
| `/v1/messages` | POST | Anthropic Messages API |
|
| `/v1/messages` | POST | Anthropic Messages API |
|
||||||
| `/v1/models` | GET | List available models |
|
| `/v1/models` | GET | List available models |
|
||||||
| `/refresh-token` | POST | Force token refresh |
|
| `/refresh-token` | POST | Force token refresh |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,6 +381,7 @@ npm run test:caching # Prompt caching
|
|||||||
### "Could not extract token from Antigravity"
|
### "Could not extract token from Antigravity"
|
||||||
|
|
||||||
If using single-account mode with Antigravity:
|
If using single-account mode with Antigravity:
|
||||||
|
|
||||||
1. Make sure Antigravity app is installed and running
|
1. Make sure Antigravity app is installed and running
|
||||||
2. Ensure you're logged in to Antigravity
|
2. Ensure you're logged in to Antigravity
|
||||||
|
|
||||||
@@ -353,11 +390,13 @@ Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add`
|
|||||||
### 401 Authentication Errors
|
### 401 Authentication Errors
|
||||||
|
|
||||||
The token might have expired. Try:
|
The token might have expired. Try:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/refresh-token
|
curl -X POST http://localhost:8080/refresh-token
|
||||||
```
|
```
|
||||||
|
|
||||||
Or re-authenticate the account:
|
Or re-authenticate the account:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
antigravity-claude-proxy accounts
|
antigravity-claude-proxy accounts
|
||||||
```
|
```
|
||||||
@@ -369,6 +408,7 @@ With multiple accounts, the proxy automatically switches to the next available a
|
|||||||
### Account Shows as "Invalid"
|
### Account Shows as "Invalid"
|
||||||
|
|
||||||
Re-authenticate the account:
|
Re-authenticate the account:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
antigravity-claude-proxy accounts
|
antigravity-claude-proxy accounts
|
||||||
# Choose "Re-authenticate" for the invalid account
|
# Choose "Re-authenticate" for the invalid account
|
||||||
@@ -435,4 +475,4 @@ MIT
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)
|
[](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)
|
||||||
|
|||||||
52
config.example.json
Normal file
52
config.example.json
Normal 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
BIN
images/webui-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-claude-proxy",
|
"name": "antigravity-claude-proxy",
|
||||||
"version": "1.0.2",
|
"version": "1.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "antigravity-claude-proxy",
|
"name": "antigravity-claude-proxy",
|
||||||
"version": "1.0.2",
|
"version": "1.2.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2"
|
||||||
@@ -39,6 +40,15 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/async-mutex": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -1304,6 +1314,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-claude-proxy",
|
"name": "antigravity-claude-proxy",
|
||||||
"version": "1.0.2",
|
"version": "1.2.6",
|
||||||
"description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
|
"description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"bin"
|
"bin",
|
||||||
|
"public"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
@@ -52,8 +53,9 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
198
public/app.js
Normal file
198
public/app.js
Normal 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
372
public/css/style.css
Normal 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
10
public/favicon.svg
Normal 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
379
public/index.html
Normal 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
137
public/js/app-init.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
202
public/js/components/account-manager.js
Normal file
202
public/js/components/account-manager.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
143
public/js/components/claude-config.js
Normal file
143
public/js/components/claude-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
220
public/js/components/dashboard.js
Normal file
220
public/js/components/dashboard.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
520
public/js/components/dashboard/charts.js
Normal file
520
public/js/components/dashboard/charts.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
347
public/js/components/dashboard/filters.js
Normal file
347
public/js/components/dashboard/filters.js
Normal 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();
|
||||||
|
};
|
||||||
57
public/js/components/dashboard/stats.js
Normal file
57
public/js/components/dashboard/stats.js
Normal 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;
|
||||||
|
};
|
||||||
100
public/js/components/logs-viewer.js
Normal file
100
public/js/components/logs-viewer.js
Normal 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 = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
47
public/js/components/model-manager.js
Normal file
47
public/js/components/model-manager.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
36
public/js/components/models.js
Normal file
36
public/js/components/models.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
252
public/js/components/server-config.js
Normal file
252
public/js/components/server-config.js
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
82
public/js/config/constants.js
Normal file
82
public/js/config/constants.js
Normal 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
214
public/js/data-store.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
58
public/js/settings-store.js
Normal file
58
public/js/settings-store.js
Normal 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
545
public/js/store.js
Normal 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
69
public/js/utils.js
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
107
public/js/utils/error-handler.js
Normal file
107
public/js/utils/error-handler.js
Normal 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;
|
||||||
|
};
|
||||||
42
public/js/utils/model-config.js
Normal file
42
public/js/utils/model-config.js
Normal 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');
|
||||||
|
};
|
||||||
168
public/js/utils/validators.js
Normal file
168
public/js/utils/validators.js
Normal 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
260
public/views/accounts.html
Normal 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
437
public/views/dashboard.html
Normal 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
97
public/views/logs.html
Normal 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
251
public/views/models.html
Normal 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
1021
public/views/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,16 @@ export class AccountManager {
|
|||||||
this.#initialized = true;
|
this.#initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload accounts from disk (force re-initialization)
|
||||||
|
* Useful when accounts.json is modified externally (e.g., by WebUI)
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
this.#initialized = false;
|
||||||
|
await this.initialize();
|
||||||
|
logger.info('[AccountManager] Accounts reloaded from disk');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the number of accounts
|
* Get the number of accounts
|
||||||
* @returns {number} Number of configured accounts
|
* @returns {number} Number of configured accounts
|
||||||
@@ -278,6 +288,8 @@ export class AccountManager {
|
|||||||
accounts: this.#accounts.map(a => ({
|
accounts: this.#accounts.map(a => ({
|
||||||
email: a.email,
|
email: a.email,
|
||||||
source: a.source,
|
source: a.source,
|
||||||
|
enabled: a.enabled !== false, // Default to true if undefined
|
||||||
|
projectId: a.projectId || null,
|
||||||
modelRateLimits: a.modelRateLimits || {},
|
modelRateLimits: a.modelRateLimits || {},
|
||||||
isInvalid: a.isInvalid || false,
|
isInvalid: a.isInvalid || false,
|
||||||
invalidReason: a.invalidReason || null,
|
invalidReason: a.invalidReason || null,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export function getAvailableAccounts(accounts, modelId = null) {
|
|||||||
return accounts.filter(acc => {
|
return accounts.filter(acc => {
|
||||||
if (acc.isInvalid) return false;
|
if (acc.isInvalid) return false;
|
||||||
|
|
||||||
|
// WebUI: Skip disabled accounts
|
||||||
|
if (acc.enabled === false) return false;
|
||||||
|
|
||||||
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
|
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
|
||||||
const limit = acc.modelRateLimits[modelId];
|
const limit = acc.modelRateLimits[modelId];
|
||||||
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
|
|||||||
function isAccountUsable(account, modelId) {
|
function isAccountUsable(account, modelId) {
|
||||||
if (!account || account.isInvalid) return false;
|
if (!account || account.isInvalid) return false;
|
||||||
|
|
||||||
|
// WebUI: Skip disabled accounts
|
||||||
|
if (account.enabled === false) return false;
|
||||||
|
|
||||||
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
|
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
|
||||||
const limit = account.modelRateLimits[modelId];
|
const limit = account.modelRateLimits[modelId];
|
||||||
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
|
|||||||
const accounts = (config.accounts || []).map(acc => ({
|
const accounts = (config.accounts || []).map(acc => ({
|
||||||
...acc,
|
...acc,
|
||||||
lastUsed: acc.lastUsed || null,
|
lastUsed: acc.lastUsed || null,
|
||||||
|
enabled: acc.enabled !== false, // Default to true if not specified
|
||||||
// Reset invalid flag on startup - give accounts a fresh chance to refresh
|
// Reset invalid flag on startup - give accounts a fresh chance to refresh
|
||||||
isInvalid: false,
|
isInvalid: false,
|
||||||
invalidReason: null,
|
invalidReason: null,
|
||||||
modelRateLimits: acc.modelRateLimits || {}
|
modelRateLimits: acc.modelRateLimits || {},
|
||||||
|
// New fields for subscription and quota tracking
|
||||||
|
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||||
|
quota: acc.quota || { models: {}, lastChecked: null }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const settings = config.settings || {};
|
const settings = config.settings || {};
|
||||||
@@ -107,6 +111,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
|||||||
accounts: accounts.map(acc => ({
|
accounts: accounts.map(acc => ({
|
||||||
email: acc.email,
|
email: acc.email,
|
||||||
source: acc.source,
|
source: acc.source,
|
||||||
|
enabled: acc.enabled !== false, // Persist enabled state
|
||||||
dbPath: acc.dbPath || null,
|
dbPath: acc.dbPath || null,
|
||||||
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
|
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
|
||||||
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
|
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
|
||||||
@@ -115,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
|||||||
isInvalid: acc.isInvalid || false,
|
isInvalid: acc.isInvalid || false,
|
||||||
invalidReason: acc.invalidReason || null,
|
invalidReason: acc.invalidReason || null,
|
||||||
modelRateLimits: acc.modelRateLimits || {},
|
modelRateLimits: acc.modelRateLimits || {},
|
||||||
lastUsed: acc.lastUsed
|
lastUsed: acc.lastUsed,
|
||||||
|
// Persist subscription and quota data
|
||||||
|
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||||
|
quota: acc.quota || { models: {}, lastChecked: null }
|
||||||
})),
|
})),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
activeIndex: activeIndex
|
activeIndex: activeIndex
|
||||||
|
|||||||
@@ -32,15 +32,16 @@ function generatePKCE() {
|
|||||||
* Generate authorization URL for Google OAuth
|
* Generate authorization URL for Google OAuth
|
||||||
* Returns the URL and the PKCE verifier (needed for token exchange)
|
* Returns the URL and the PKCE verifier (needed for token exchange)
|
||||||
*
|
*
|
||||||
|
* @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI)
|
||||||
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
|
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
|
||||||
*/
|
*/
|
||||||
export function getAuthorizationUrl() {
|
export function getAuthorizationUrl(customRedirectUri = null) {
|
||||||
const { verifier, challenge } = generatePKCE();
|
const { verifier, challenge } = generatePKCE();
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: OAUTH_CONFIG.clientId,
|
client_id: OAUTH_CONFIG.clientId,
|
||||||
redirect_uri: OAUTH_REDIRECT_URI,
|
redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: OAUTH_CONFIG.scopes.join(' '),
|
scope: OAUTH_CONFIG.scopes.join(' '),
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
// Re-export public API
|
// Re-export public API
|
||||||
export { sendMessage } from './message-handler.js';
|
export { sendMessage } from './message-handler.js';
|
||||||
export { sendMessageStream } from './streaming-handler.js';
|
export { sendMessageStream } from './streaming-handler.js';
|
||||||
export { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js';
|
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
|
||||||
|
|
||||||
// Default export for backwards compatibility
|
// Default export for backwards compatibility
|
||||||
import { sendMessage } from './message-handler.js';
|
import { sendMessage } from './message-handler.js';
|
||||||
import { sendMessageStream } from './streaming-handler.js';
|
import { sendMessageStream } from './streaming-handler.js';
|
||||||
import { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js';
|
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendMessageStream,
|
sendMessageStream,
|
||||||
listModels,
|
listModels,
|
||||||
fetchAvailableModels,
|
fetchAvailableModels,
|
||||||
getModelQuotas
|
getModelQuotas,
|
||||||
|
getSubscriptionTier
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,3 +110,75 @@ export async function getModelQuotas(token) {
|
|||||||
|
|
||||||
return quotas;
|
return quotas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription tier for an account
|
||||||
|
* Calls loadCodeAssist API to discover project ID and subscription tier
|
||||||
|
*
|
||||||
|
* @param {string} token - OAuth access token
|
||||||
|
* @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID
|
||||||
|
*/
|
||||||
|
export async function getSubscriptionTier(token) {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...ANTIGRAVITY_HEADERS
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
||||||
|
try {
|
||||||
|
const url = `${endpoint}/v1internal:loadCodeAssist`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
ideType: 'IDE_UNSPECIFIED',
|
||||||
|
platform: 'PLATFORM_UNSPECIFIED',
|
||||||
|
pluginType: 'GEMINI'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Extract project ID
|
||||||
|
let projectId = null;
|
||||||
|
if (typeof data.cloudaicompanionProject === 'string') {
|
||||||
|
projectId = data.cloudaicompanionProject;
|
||||||
|
} else if (data.cloudaicompanionProject?.id) {
|
||||||
|
projectId = data.cloudaicompanionProject.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract subscription tier (priority: paidTier > currentTier)
|
||||||
|
let tier = 'free';
|
||||||
|
const tierId = data.paidTier?.id || data.currentTier?.id;
|
||||||
|
|
||||||
|
if (tierId) {
|
||||||
|
const lowerTier = tierId.toLowerCase();
|
||||||
|
if (lowerTier.includes('ultra')) {
|
||||||
|
tier = 'ultra';
|
||||||
|
} else if (lowerTier.includes('pro')) {
|
||||||
|
tier = 'pro';
|
||||||
|
} else {
|
||||||
|
tier = 'free';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[CloudCode] Subscription detected: ${tier}, Project: ${projectId}`);
|
||||||
|
|
||||||
|
return { tier, projectId };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return default values if all endpoints fail
|
||||||
|
logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
|
||||||
|
return { tier: 'free', projectId: null };
|
||||||
|
}
|
||||||
|
|||||||
86
src/config.js
Normal file
86
src/config.js
Normal 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 };
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { homedir, platform, arch } from 'os';
|
import { homedir, platform, arch } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Antigravity database path based on the current platform.
|
* Get the Antigravity database path based on the current platform.
|
||||||
@@ -59,28 +60,35 @@ export const ANTIGRAVITY_HEADERS = {
|
|||||||
// Default project ID if none can be discovered
|
// Default project ID if none can be discovered
|
||||||
export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc';
|
export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc';
|
||||||
|
|
||||||
export const TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
// Configurable constants - values from config.json take precedence
|
||||||
export const REQUEST_BODY_LIMIT = '50mb';
|
export const TOKEN_REFRESH_INTERVAL_MS = config?.tokenCacheTtlMs || (5 * 60 * 1000); // From config or 5 minutes
|
||||||
|
export const REQUEST_BODY_LIMIT = config?.requestBodyLimit || '50mb';
|
||||||
export const ANTIGRAVITY_AUTH_PORT = 9092;
|
export const ANTIGRAVITY_AUTH_PORT = 9092;
|
||||||
export const DEFAULT_PORT = 8080;
|
export const DEFAULT_PORT = config?.port || 8080;
|
||||||
|
|
||||||
// Multi-account configuration
|
// Multi-account configuration
|
||||||
export const ACCOUNT_CONFIG_PATH = join(
|
export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
|
||||||
homedir(),
|
homedir(),
|
||||||
'.config/antigravity-proxy/accounts.json'
|
'.config/antigravity-proxy/accounts.json'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Usage history persistence path
|
||||||
|
export const USAGE_HISTORY_PATH = join(
|
||||||
|
homedir(),
|
||||||
|
'.config/antigravity-proxy/usage-history.json'
|
||||||
|
);
|
||||||
|
|
||||||
// Antigravity app database path (for legacy single-account token extraction)
|
// Antigravity app database path (for legacy single-account token extraction)
|
||||||
// Uses platform-specific path detection
|
// Uses platform-specific path detection
|
||||||
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
|
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
|
||||||
|
|
||||||
export const DEFAULT_COOLDOWN_MS = 10 * 1000; // 10 second default cooldown
|
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds
|
||||||
export const MAX_RETRIES = 5; // Max retry attempts across accounts
|
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
|
||||||
export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses
|
export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses (from upstream)
|
||||||
export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed
|
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10
|
||||||
|
|
||||||
// Rate limit wait thresholds
|
// Rate limit wait thresholds
|
||||||
export const MAX_WAIT_BEFORE_ERROR_MS = 120000; // 2 minutes - throw error if wait exceeds this
|
export const MAX_WAIT_BEFORE_ERROR_MS = config?.maxWaitBeforeErrorMs || 120000; // From config or 2 minutes
|
||||||
|
|
||||||
// Thinking model constants
|
// Thinking model constants
|
||||||
export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length
|
export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length
|
||||||
|
|||||||
205
src/modules/usage-stats.js
Normal file
205
src/modules/usage-stats.js
Normal 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
|
||||||
|
};
|
||||||
140
src/server.js
140
src/server.js
@@ -6,12 +6,20 @@
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
|
||||||
|
import { mountWebUI } from './webui/index.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
import { forceRefresh } from './auth/token-extractor.js';
|
import { forceRefresh } from './auth/token-extractor.js';
|
||||||
import { REQUEST_BODY_LIMIT } from './constants.js';
|
import { REQUEST_BODY_LIMIT } from './constants.js';
|
||||||
import { AccountManager } from './account-manager/index.js';
|
import { AccountManager } from './account-manager/index.js';
|
||||||
import { formatDuration } from './utils/helpers.js';
|
import { formatDuration } from './utils/helpers.js';
|
||||||
import { logger } from './utils/logger.js';
|
import { logger } from './utils/logger.js';
|
||||||
|
import usageStats from './modules/usage-stats.js';
|
||||||
|
|
||||||
// Parse fallback flag directly from command line args to avoid circular dependency
|
// Parse fallback flag directly from command line args to avoid circular dependency
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -57,6 +65,12 @@ async function ensureInitialized() {
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
||||||
|
|
||||||
|
// Setup usage statistics middleware
|
||||||
|
usageStats.setupMiddleware(app);
|
||||||
|
|
||||||
|
// Mount WebUI (optional web interface for account management)
|
||||||
|
mountWebUI(app, __dirname, accountManager);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse error message to extract error type, status code, and user-friendly message
|
* Parse error message to extract error type, status code, and user-friendly message
|
||||||
*/
|
*/
|
||||||
@@ -123,11 +137,11 @@ app.get('/health', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
// Get high-level status first
|
// Get high-level status first
|
||||||
const status = accountManager.getStatus();
|
const status = accountManager.getStatus();
|
||||||
const allAccounts = accountManager.getAllAccounts();
|
const allAccounts = accountManager.getAllAccounts();
|
||||||
|
|
||||||
// Fetch quotas for each account in parallel to get detailed model info
|
// Fetch quotas for each account in parallel to get detailed model info
|
||||||
const accountDetails = await Promise.allSettled(
|
const accountDetails = await Promise.allSettled(
|
||||||
allAccounts.map(async (account) => {
|
allAccounts.map(async (account) => {
|
||||||
@@ -235,6 +249,7 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
const allAccounts = accountManager.getAllAccounts();
|
const allAccounts = accountManager.getAllAccounts();
|
||||||
const format = req.query.format || 'json';
|
const format = req.query.format || 'json';
|
||||||
|
const includeHistory = req.query.includeHistory === 'true';
|
||||||
|
|
||||||
// Fetch quotas for each account in parallel
|
// Fetch quotas for each account in parallel
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
@@ -251,11 +266,33 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await accountManager.getTokenForAccount(account);
|
const token = await accountManager.getTokenForAccount(account);
|
||||||
const quotas = await getModelQuotas(token);
|
|
||||||
|
// Fetch both quotas and subscription tier in parallel
|
||||||
|
const [quotas, subscription] = await Promise.all([
|
||||||
|
getModelQuotas(token),
|
||||||
|
getSubscriptionTier(token)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update account object with fresh data
|
||||||
|
account.subscription = {
|
||||||
|
tier: subscription.tier,
|
||||||
|
projectId: subscription.projectId,
|
||||||
|
detectedAt: Date.now()
|
||||||
|
};
|
||||||
|
account.quota = {
|
||||||
|
models: quotas,
|
||||||
|
lastChecked: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save updated account data to disk (async, don't wait)
|
||||||
|
accountManager.saveToDisk().catch(err => {
|
||||||
|
logger.error('[Server] Failed to save account data:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: account.email,
|
email: account.email,
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
subscription: account.subscription,
|
||||||
models: quotas
|
models: quotas
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -263,6 +300,7 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
subscription: account.subscription || { tier: 'unknown', projectId: null },
|
||||||
models: {}
|
models: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -409,32 +447,61 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
return res.send(lines.join('\n'));
|
return res.send(lines.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: JSON format
|
// Get account metadata from AccountManager
|
||||||
res.json({
|
const accountStatus = accountManager.getStatus();
|
||||||
|
const accountMetadataMap = new Map(
|
||||||
|
accountStatus.accounts.map(a => [a.email, a])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build response data
|
||||||
|
const responseData = {
|
||||||
timestamp: new Date().toLocaleString(),
|
timestamp: new Date().toLocaleString(),
|
||||||
totalAccounts: allAccounts.length,
|
totalAccounts: allAccounts.length,
|
||||||
models: sortedModels,
|
models: sortedModels,
|
||||||
accounts: accountLimits.map(acc => ({
|
modelConfig: config.modelMapping || {},
|
||||||
email: acc.email,
|
accounts: accountLimits.map(acc => {
|
||||||
status: acc.status,
|
// Merge quota data with account metadata
|
||||||
error: acc.error || null,
|
const metadata = accountMetadataMap.get(acc.email) || {};
|
||||||
limits: Object.fromEntries(
|
return {
|
||||||
sortedModels.map(modelId => {
|
email: acc.email,
|
||||||
const quota = acc.models?.[modelId];
|
status: acc.status,
|
||||||
if (!quota) {
|
error: acc.error || null,
|
||||||
return [modelId, null];
|
// Include metadata from AccountManager (WebUI needs these)
|
||||||
}
|
source: metadata.source || 'unknown',
|
||||||
return [modelId, {
|
enabled: metadata.enabled !== false,
|
||||||
remaining: quota.remainingFraction !== null
|
projectId: metadata.projectId || null,
|
||||||
? `${Math.round(quota.remainingFraction * 100)}%`
|
isInvalid: metadata.isInvalid || false,
|
||||||
: 'N/A',
|
invalidReason: metadata.invalidReason || null,
|
||||||
remainingFraction: quota.remainingFraction,
|
lastUsed: metadata.lastUsed || null,
|
||||||
resetTime: quota.resetTime || null
|
modelRateLimits: metadata.modelRateLimits || {},
|
||||||
}];
|
// Subscription data (new)
|
||||||
})
|
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
|
||||||
)
|
// Quota limits
|
||||||
}))
|
limits: Object.fromEntries(
|
||||||
});
|
sortedModels.map(modelId => {
|
||||||
|
const quota = acc.models?.[modelId];
|
||||||
|
if (!quota) {
|
||||||
|
return [modelId, null];
|
||||||
|
}
|
||||||
|
return [modelId, {
|
||||||
|
remaining: quota.remainingFraction !== null
|
||||||
|
? `${Math.round(quota.remainingFraction * 100)}%`
|
||||||
|
: 'N/A',
|
||||||
|
remainingFraction: quota.remainingFraction,
|
||||||
|
resetTime: quota.resetTime || null
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optionally include usage history (for dashboard performance optimization)
|
||||||
|
if (includeHistory) {
|
||||||
|
responseData.history = usageStats.getHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(responseData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -525,13 +592,12 @@ app.post('/v1/messages', async (req, res) => {
|
|||||||
// Ensure account manager is initialized
|
// Ensure account manager is initialized
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
max_tokens,
|
|
||||||
stream,
|
stream,
|
||||||
system,
|
system,
|
||||||
|
max_tokens,
|
||||||
tools,
|
tools,
|
||||||
tool_choice,
|
tool_choice,
|
||||||
thinking,
|
thinking,
|
||||||
@@ -540,9 +606,19 @@ app.post('/v1/messages', async (req, res) => {
|
|||||||
temperature
|
temperature
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Resolve model mapping if configured
|
||||||
|
let requestedModel = model || 'claude-3-5-sonnet-20241022';
|
||||||
|
const modelMapping = config.modelMapping || {};
|
||||||
|
if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
|
||||||
|
const targetModel = modelMapping[requestedModel].mapping;
|
||||||
|
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
|
||||||
|
requestedModel = targetModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = requestedModel;
|
||||||
|
|
||||||
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
|
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
|
||||||
// If we have some available accounts, we try them first.
|
// If we have some available accounts, we try them first.
|
||||||
const modelId = model || 'claude-3-5-sonnet-20241022';
|
|
||||||
if (accountManager.isAllRateLimited(modelId)) {
|
if (accountManager.isAllRateLimited(modelId)) {
|
||||||
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
|
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
|
||||||
accountManager.resetAllRateLimits();
|
accountManager.resetAllRateLimits();
|
||||||
@@ -561,7 +637,7 @@ app.post('/v1/messages', async (req, res) => {
|
|||||||
|
|
||||||
// Build the request object
|
// Build the request object
|
||||||
const request = {
|
const request = {
|
||||||
model: model || 'claude-3-5-sonnet-20241022',
|
model: modelId,
|
||||||
messages,
|
messages,
|
||||||
max_tokens: max_tokens || 4096,
|
max_tokens: max_tokens || 4096,
|
||||||
stream,
|
stream,
|
||||||
@@ -667,6 +743,8 @@ app.post('/v1/messages', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Catch-all for unsupported endpoints
|
* Catch-all for unsupported endpoints
|
||||||
*/
|
*/
|
||||||
|
usageStats.setupRoutes(app);
|
||||||
|
|
||||||
app.use('*', (req, res) => {
|
app.use('*', (req, res) => {
|
||||||
if (logger.isDebugEnabled) {
|
if (logger.isDebugEnabled) {
|
||||||
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
|
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
|
||||||
|
|||||||
111
src/utils/claude-config.js
Normal file
111
src/utils/claude-config.js
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Logger Utility
|
* Logger Utility
|
||||||
*
|
*
|
||||||
* Provides structured logging with colors and debug support.
|
* Provides structured logging with colors and debug support.
|
||||||
* Simple ANSI codes used to avoid dependencies.
|
* Simple ANSI codes used to avoid dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import util from 'util';
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
RESET: '\x1b[0m',
|
RESET: '\x1b[0m',
|
||||||
BRIGHT: '\x1b[1m',
|
BRIGHT: '\x1b[1m',
|
||||||
DIM: '\x1b[2m',
|
DIM: '\x1b[2m',
|
||||||
|
|
||||||
RED: '\x1b[31m',
|
RED: '\x1b[31m',
|
||||||
GREEN: '\x1b[32m',
|
GREEN: '\x1b[32m',
|
||||||
YELLOW: '\x1b[33m',
|
YELLOW: '\x1b[33m',
|
||||||
@@ -20,14 +23,17 @@ const COLORS = {
|
|||||||
GRAY: '\x1b[90m'
|
GRAY: '\x1b[90m'
|
||||||
};
|
};
|
||||||
|
|
||||||
class Logger {
|
class Logger extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
super();
|
||||||
this.isDebugEnabled = false;
|
this.isDebugEnabled = false;
|
||||||
|
this.history = [];
|
||||||
|
this.maxHistory = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set debug mode
|
* Set debug mode
|
||||||
* @param {boolean} enabled
|
* @param {boolean} enabled
|
||||||
*/
|
*/
|
||||||
setDebug(enabled) {
|
setDebug(enabled) {
|
||||||
this.isDebugEnabled = !!enabled;
|
this.isDebugEnabled = !!enabled;
|
||||||
@@ -40,19 +46,44 @@ class Logger {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get log history
|
||||||
|
*/
|
||||||
|
getHistory() {
|
||||||
|
return this.history;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format and print a log message
|
* Format and print a log message
|
||||||
* @param {string} level
|
* @param {string} level
|
||||||
* @param {string} color
|
* @param {string} color
|
||||||
* @param {string} message
|
* @param {string} message
|
||||||
* @param {...any} args
|
* @param {...any} args
|
||||||
*/
|
*/
|
||||||
print(level, color, message, ...args) {
|
print(level, color, message, ...args) {
|
||||||
// Format: [TIMESTAMP] [LEVEL] Message
|
// Format: [TIMESTAMP] [LEVEL] Message
|
||||||
const timestamp = `${COLORS.GRAY}[${this.getTimestamp()}]${COLORS.RESET}`;
|
const timestampStr = this.getTimestamp();
|
||||||
|
const timestamp = `${COLORS.GRAY}[${timestampStr}]${COLORS.RESET}`;
|
||||||
const levelTag = `${color}[${level}]${COLORS.RESET}`;
|
const levelTag = `${color}[${level}]${COLORS.RESET}`;
|
||||||
|
|
||||||
console.log(`${timestamp} ${levelTag} ${message}`, ...args);
|
// Format the message with args similar to console.log
|
||||||
|
const formattedMessage = util.format(message, ...args);
|
||||||
|
|
||||||
|
console.log(`${timestamp} ${levelTag} ${formattedMessage}`);
|
||||||
|
|
||||||
|
// Store structured log
|
||||||
|
const logEntry = {
|
||||||
|
timestamp: timestampStr,
|
||||||
|
level,
|
||||||
|
message: formattedMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
this.history.push(logEntry);
|
||||||
|
if (this.history.length > this.maxHistory) {
|
||||||
|
this.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('log', logEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +129,7 @@ class Logger {
|
|||||||
log(message, ...args) {
|
log(message, ...args) {
|
||||||
console.log(message, ...args);
|
console.log(message, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print a section header
|
* Print a section header
|
||||||
*/
|
*/
|
||||||
|
|||||||
161
src/utils/retry.js
Normal file
161
src/utils/retry.js
Normal 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
583
src/webui/index.js
Normal 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 /');
|
||||||
|
}
|
||||||
217
tests/frontend/test-frontend-accounts.cjs
Normal file
217
tests/frontend/test-frontend-accounts.cjs
Normal 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);
|
||||||
|
});
|
||||||
85
tests/frontend/test-frontend-all.cjs
Normal file
85
tests/frontend/test-frontend-all.cjs
Normal 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);
|
||||||
|
});
|
||||||
160
tests/frontend/test-frontend-dashboard.cjs
Normal file
160
tests/frontend/test-frontend-dashboard.cjs
Normal 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);
|
||||||
|
});
|
||||||
163
tests/frontend/test-frontend-logs.cjs
Normal file
163
tests/frontend/test-frontend-logs.cjs
Normal 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);
|
||||||
|
});
|
||||||
348
tests/frontend/test-frontend-settings.cjs
Normal file
348
tests/frontend/test-frontend-settings.cjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user