feat(webui): add subscription tier and quota visualization

Backend:
This commit is contained in:
Wha1eChai
2026-01-10 06:04:51 +08:00
parent 369a66e8cf
commit ee6d222e4d
11 changed files with 277 additions and 18 deletions

View File

@@ -134,6 +134,7 @@ public/
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI
- **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
- `model-api.js`: Model listing, quota retrieval (`getModelQuotas()`), and subscription tier detection (`getSubscriptionTier()`)
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
@@ -148,6 +149,17 @@ public/
- Session ID derived from first user message hash for cache continuity
- Account state persisted to `~/.config/antigravity-proxy/accounts.json`
**Account Data Model:**
Each account object in `accounts.json` contains:
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed`
- **Credentials**: `refreshToken` (OAuth) or `apiKey` (manual)
- **Subscription**: `{ tier, projectId, detectedAt }` - automatically detected via `loadCodeAssist` API
- `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API
- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication
**Prompt Caching:**
- Cache is organization-scoped (requires same account + session ID)
- Session ID is SHA256 hash of first user message content (stable across turns)
@@ -183,11 +195,13 @@ public/
- **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
- 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
@@ -223,6 +237,12 @@ public/
- `sleep(ms)` - Promise-based delay
- `isNetworkError(error)` - Check if error is a transient network error
**Data Persistence:**
- Subscription and quota data are automatically fetched when `/account-limits` is called
- Updated data is saved to `accounts.json` asynchronously (non-blocking)
- On server restart, accounts load with last known subscription/quota state
- Quota is refreshed on each WebUI poll (default: 30s with jitter)
**Logger:** Structured logging via `src/utils/logger.js`:
- `logger.info(msg)` - Standard info (blue)
- `logger.success(msg)` - Success messages (green)
@@ -239,6 +259,9 @@ public/
- `/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

View File

@@ -280,10 +280,10 @@ When you add multiple accounts, the proxy automatically:
- **Invalid account detection**: Accounts needing re-authentication are marked and skipped
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns
Check account status anytime:
Check account status, subscription tiers, and quota anytime:
```bash
# Web UI: http://localhost:8080/ (Accounts tab)
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
# CLI Table:
curl "http://localhost:8080/account-limits?format=table"
```
@@ -313,9 +313,9 @@ The proxy includes a built-in, modern web interface for real-time monitoring and
### Key Features
- **Real-time Dashboard**: Monitor request volume, active accounts, and model health.
- **Visual Model Quota**: Track per-model usage and next reset times.
- **Account Management**: Add/remove Google accounts via OAuth without using the CLI.
- **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.

View File

@@ -39,6 +39,7 @@ document.addEventListener('alpine:init', () => {
},
refreshTimer: null,
isTabVisible: true,
fetchData() {
Alpine.store('data').fetchData();
@@ -46,9 +47,39 @@ document.addEventListener('alpine:init', () => {
startAutoRefresh() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
const interval = parseInt(Alpine.store('settings').refreshInterval);
if (interval > 0) {
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
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();
}
},

View File

@@ -163,5 +163,40 @@ window.Components.accountManager = () => ({
} catch (e) {
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
}
},
/**
* Get main model quota for display
* Prioritizes flagship models (Opus > Sonnet > Flash)
* @param {Object} account - Account object with limits
* @returns {Object} { percent: number|null, model: string }
*/
getMainModelQuota(account) {
const limits = account.limits || {};
const modelIds = Object.keys(limits);
if (modelIds.length === 0) {
return { percent: null, model: '-' };
}
// Priority: opus > sonnet > flash > others
const priorityModels = [
modelIds.find(m => m.toLowerCase().includes('opus')),
modelIds.find(m => m.toLowerCase().includes('sonnet')),
modelIds.find(m => m.toLowerCase().includes('flash')),
modelIds[0] // Fallback to first model
];
const selectedModel = priorityModels.find(m => m) || modelIds[0];
const quota = limits[selectedModel];
if (!quota || quota.remainingFraction === null) {
return { percent: null, model: selectedModel };
}
return {
percent: Math.round(quota.remainingFraction * 100),
model: selectedModel
};
}
});

View File

@@ -40,4 +40,18 @@ window.DashboardStats.updateStats = function(component) {
component.stats.total = enabledAccounts.length;
component.stats.active = active;
component.stats.limited = limited;
// Calculate subscription tier distribution
const subscription = { ultra: 0, pro: 0, free: 0 };
enabledAccounts.forEach(acc => {
const tier = acc.subscription?.tier || 'free';
if (tier === 'ultra') {
subscription.ultra++;
} else if (tier === 'pro') {
subscription.pro++;
} else {
subscription.free++;
}
});
component.stats.subscription = subscription;
};

View File

@@ -50,6 +50,8 @@
<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>
@@ -112,6 +114,38 @@
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"

View File

@@ -50,6 +50,24 @@
<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

View File

@@ -31,7 +31,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
// Reset invalid flag on startup - give accounts a fresh chance to refresh
isInvalid: false,
invalidReason: null,
modelRateLimits: acc.modelRateLimits || {}
modelRateLimits: acc.modelRateLimits || {},
// New fields for subscription and quota tracking
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
quota: acc.quota || { models: {}, lastChecked: null }
}));
const settings = config.settings || {};
@@ -117,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
isInvalid: acc.isInvalid || false,
invalidReason: acc.invalidReason || null,
modelRateLimits: acc.modelRateLimits || {},
lastUsed: acc.lastUsed
lastUsed: acc.lastUsed,
// Persist subscription and quota data
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
quota: acc.quota || { models: {}, lastChecked: null }
})),
settings: settings,
activeIndex: activeIndex

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
import { mountWebUI } from './webui/index.js';
import { config } from './config.js';
@@ -266,11 +266,33 @@ app.get('/account-limits', async (req, res) => {
try {
const token = await accountManager.getTokenForAccount(account);
const quotas = await getModelQuotas(token);
// Fetch both quotas and subscription tier in parallel
const [quotas, subscription] = await Promise.all([
getModelQuotas(token),
getSubscriptionTier(token)
]);
// Update account object with fresh data
account.subscription = {
tier: subscription.tier,
projectId: subscription.projectId,
detectedAt: Date.now()
};
account.quota = {
models: quotas,
lastChecked: Date.now()
};
// Save updated account data to disk (async, don't wait)
accountManager.saveToDisk().catch(err => {
logger.error('[Server] Failed to save account data:', err);
});
return {
email: account.email,
status: 'ok',
subscription: account.subscription,
models: quotas
};
} catch (error) {
@@ -278,6 +300,7 @@ app.get('/account-limits', async (req, res) => {
email: account.email,
status: 'error',
error: error.message,
subscription: account.subscription || { tier: 'unknown', projectId: null },
models: {}
};
}
@@ -451,6 +474,8 @@ app.get('/account-limits', async (req, res) => {
invalidReason: metadata.invalidReason || null,
lastUsed: metadata.lastUsed || null,
modelRateLimits: metadata.modelRateLimits || {},
// Subscription data (new)
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
// Quota limits
limits: Object.fromEntries(
sortedModels.map(modelId => {