Merge pull request #101 from jgor20/overall-enhancements-to-web-ui
feat(webui): Comprehensive UI enhancements, responsive design, and routing improvements
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -95,6 +95,9 @@ src/
|
|||||||
├── webui/ # Web Management Interface
|
├── webui/ # Web Management Interface
|
||||||
│ └── index.js # Express router and API endpoints
|
│ └── index.js # Express router and API endpoints
|
||||||
│
|
│
|
||||||
|
├── modules/ # Feature modules
|
||||||
|
│ └── usage-stats.js # Request tracking and history persistence
|
||||||
|
│
|
||||||
├── cli/ # CLI tools
|
├── cli/ # CLI tools
|
||||||
│ └── accounts.js # Account management CLI
|
│ └── accounts.js # Account management CLI
|
||||||
│
|
│
|
||||||
@@ -124,6 +127,8 @@ public/
|
|||||||
│ └── input.css # Tailwind source with @apply directives
|
│ └── input.css # Tailwind source with @apply directives
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── app.js # Main application logic (Alpine.js)
|
│ ├── app.js # Main application logic (Alpine.js)
|
||||||
|
│ ├── config/ # Application configuration
|
||||||
|
│ │ └── constants.js # Centralized UI constants and limits
|
||||||
│ ├── store.js # Global state management
|
│ ├── store.js # Global state management
|
||||||
│ ├── data-store.js # Shared data store (accounts, models, quotas)
|
│ ├── data-store.js # Shared data store (accounts, models, quotas)
|
||||||
│ ├── settings-store.js # Settings management store
|
│ ├── settings-store.js # Settings management store
|
||||||
@@ -161,6 +166,7 @@ public/
|
|||||||
- **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
|
||||||
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
||||||
|
- **src/modules/usage-stats.js**: Tracks request volume by model/family, persists 30-day history to JSON, and auto-prunes old data.
|
||||||
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
||||||
- **src/errors.js**: Custom error classes (`RateLimitError`, `AuthError`, `ApiError`, etc.)
|
- **src/errors.js**: Custom error classes (`RateLimitError`, `AuthError`, `ApiError`, etc.)
|
||||||
|
|
||||||
@@ -294,6 +300,7 @@ Each account object in `accounts.json` contains:
|
|||||||
- `/api/config/*` - Server configuration (read/write)
|
- `/api/config/*` - Server configuration (read/write)
|
||||||
- `/api/claude/config` - Claude CLI settings
|
- `/api/claude/config` - Claude CLI settings
|
||||||
- `/api/logs/stream` - SSE endpoint for real-time logs
|
- `/api/logs/stream` - SSE endpoint for real-time logs
|
||||||
|
- `/api/stats/history` - Retrieve 30-day request history (sorted chronologically)
|
||||||
- `/api/auth/url` - Generate Google OAuth URL
|
- `/api/auth/url` - Generate Google OAuth URL
|
||||||
- `/account-limits` - Fetch account quotas and subscription data
|
- `/account-limits` - Fetch account quotas and subscription data
|
||||||
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
|
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
|
||||||
@@ -339,6 +346,14 @@ async myOperation() {
|
|||||||
- Shows error toast on failure
|
- Shows error toast on failure
|
||||||
- Always resets loading state in `finally` block
|
- Always resets loading state in `finally` block
|
||||||
|
|
||||||
|
### Frontend Configuration
|
||||||
|
|
||||||
|
**Constants**:
|
||||||
|
All frontend magic numbers and configuration values are centralized in `public/js/config/constants.js`. Use `window.AppConstants` to access:
|
||||||
|
- `INTERVALS`: Refresh rates and timeouts
|
||||||
|
- `LIMITS`: Data quotas and display limits
|
||||||
|
- `UI`: Animation durations and delay settings
|
||||||
|
|
||||||
### Account Operations Service Layer
|
### Account Operations Service Layer
|
||||||
|
|
||||||
Use `window.AccountActions` for account operations instead of direct API calls:
|
Use `window.AccountActions` for account operations instead of direct API calls:
|
||||||
@@ -384,7 +399,8 @@ Dashboard is split into three modules for maintainability:
|
|||||||
- `getInitialState()` - Default filter values
|
- `getInitialState()` - Default filter values
|
||||||
- `loadPreferences(component)` - Load from localStorage
|
- `loadPreferences(component)` - Load from localStorage
|
||||||
- `savePreferences(component)` - Save to localStorage
|
- `savePreferences(component)` - Save to localStorage
|
||||||
- Filter types: time range, display mode, family/model selection
|
- `autoSelectTopN(component)` - Smart select top 5 active models
|
||||||
|
- Filter types: time range (1h/6h/24h/7d/all), display mode, family/model selection
|
||||||
|
|
||||||
Each module is well-documented with JSDoc comments.
|
Each module is well-documented with JSDoc comments.
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ The proxy includes a built-in, modern web interface for real-time monitoring and
|
|||||||
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
|
- **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.
|
- **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.
|
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
|
||||||
|
- **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
|
||||||
|
- **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
|
||||||
|
- **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
|
||||||
- **Live Logs**: Stream server logs with level-based filtering and search.
|
- **Live Logs**: Stream server logs with level-based filtering and search.
|
||||||
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
||||||
- **Bilingual Interface**: Full support for English and Chinese (switch via Settings).
|
- **Bilingual Interface**: Full support for English and Chinese (switch via Settings).
|
||||||
|
|||||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -395,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1415,6 +1416,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -1791,6 +1793,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2608,6 +2611,7 @@
|
|||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -2732,6 +2736,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,9 +57,39 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return Alpine.store('data')?.loading || false;
|
return Alpine.store('data')?.loading || false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sidebarOpen: window.innerWidth >= 1024,
|
||||||
|
toggleSidebar() {
|
||||||
|
this.sidebarOpen = !this.sidebarOpen;
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
console.log('App controller initialized');
|
console.log('App controller initialized');
|
||||||
|
|
||||||
|
// Handle responsive sidebar transitions
|
||||||
|
let lastWidth = window.innerWidth;
|
||||||
|
let resizeTimeout = null;
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||||
|
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
|
const currentWidth = window.innerWidth;
|
||||||
|
const lgBreakpoint = 1024;
|
||||||
|
|
||||||
|
// Desktop -> Mobile: Auto-close sidebar to prevent overlay blocking screen
|
||||||
|
if (lastWidth >= lgBreakpoint && currentWidth < lgBreakpoint) {
|
||||||
|
this.sidebarOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile -> Desktop: Auto-open sidebar (restore standard desktop layout)
|
||||||
|
if (lastWidth < lgBreakpoint && currentWidth >= lgBreakpoint) {
|
||||||
|
this.sidebarOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWidth = currentWidth;
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
// Theme setup
|
// Theme setup
|
||||||
document.documentElement.setAttribute('data-theme', 'black');
|
document.documentElement.setAttribute('data-theme', 'black');
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
|||||||
@@ -366,6 +366,25 @@
|
|||||||
/* Refactored UI Components (Phase 1.2) */
|
/* Refactored UI Components (Phase 1.2) */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Phase 1.2 additions ... */
|
||||||
|
|
||||||
|
/* Filter Controls */
|
||||||
|
.filter-control {
|
||||||
|
@apply flex items-center justify-center gap-2 px-3 py-1.5 lg:px-4 lg:py-2
|
||||||
|
text-[10px] lg:text-xs font-mono font-medium text-gray-400
|
||||||
|
bg-space-800 lg:bg-transparent border border-space-border/50 lg:border-transparent
|
||||||
|
rounded lg:rounded-md hover:text-white lg:hover:bg-space-800
|
||||||
|
transition-all duration-200 whitespace-nowrap w-full sm:w-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control:hover {
|
||||||
|
@apply border-neon-cyan/50 lg:border-neon-cyan/30 lg:shadow-lg lg:shadow-neon-cyan/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control-item {
|
||||||
|
@apply block w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
/* Action Buttons */
|
/* Action Buttons */
|
||||||
.btn-action-ghost {
|
.btn-action-ghost {
|
||||||
@apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors;
|
@apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors;
|
||||||
@@ -489,3 +508,12 @@
|
|||||||
.skeleton-table-row {
|
.skeleton-table-row {
|
||||||
@apply skeleton h-12 w-full mb-2;
|
@apply skeleton h-12 w-full mb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop Sidebar Collapsed State */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body .sidebar-collapsed {
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
public/css/style.css
generated
2
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -62,8 +62,15 @@
|
|||||||
|
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<div
|
<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">
|
class="h-14 border-b border-space-border flex items-center px-4 lg:px-6 justify-between bg-space-900/50 backdrop-blur-md z-50 relative">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Mobile Menu Button -->
|
||||||
|
<button @click="toggleSidebar()" class="text-gray-400 hover:text-white focus:outline-none p-1 transition-colors">
|
||||||
|
<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="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<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)]">
|
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>
|
AG</div>
|
||||||
@@ -82,10 +89,10 @@
|
|||||||
? 'bg-neon-green/10 border-neon-green/20 text-neon-green'
|
? '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')">
|
: (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"
|
<div class="w-1.5 h-1.5 rounded-full"
|
||||||
:class="$store.data.connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : ($store.data.connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
|
: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>
|
</div>
|
||||||
<span
|
<span
|
||||||
x-text="$store.data.connectionStatus === 'connected' ? $store.global.t('online') : ($store.data.connectionStatus === 'connecting' ? $store.global.t('connecting') : $store.global.t('offline'))"></span>
|
x-text="connectionStatus === 'connected' ? $store.global.t('online') : (connectionStatus === 'disconnected' ? $store.global.t('offline') : $store.global.t('connecting'))"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-4 w-px bg-space-border"></div>
|
<div class="h-4 w-px bg-space-border"></div>
|
||||||
@@ -103,77 +110,113 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layout -->
|
<!-- Layout -->
|
||||||
<div class="flex h-[calc(100vh-56px)]">
|
<div class="flex h-[calc(100vh-56px)] relative">
|
||||||
|
|
||||||
|
<!-- Mobile Sidebar Overlay -->
|
||||||
|
<div x-show="sidebarOpen"
|
||||||
|
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
style="display: none;"></div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="w-64 bg-space-900 border-r border-space-border flex flex-col pt-6 pb-4">
|
<div class="fixed top-14 bottom-0 left-0 z-40 bg-space-900 border-r border-space-border transition-all duration-300 shadow-2xl overflow-hidden lg:static lg:h-auto lg:shadow-none lg:flex-shrink-0"
|
||||||
<div class="px-4 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest"
|
:class="{
|
||||||
x-text="$store.global.t('main')">Main</div>
|
'translate-x-0': sidebarOpen,
|
||||||
<nav class="flex flex-col gap-1">
|
'-translate-x-full': !sidebarOpen,
|
||||||
<button
|
'w-64': sidebarOpen,
|
||||||
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"
|
'lg:translate-x-0': sidebarOpen,
|
||||||
:class="{'active': $store.global.activeTab === 'dashboard'}"
|
'lg:w-64': sidebarOpen,
|
||||||
@click="$store.global.activeTab = 'dashboard'">
|
'sidebar-collapsed': !sidebarOpen
|
||||||
<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" />
|
<!-- Inner Sidebar Content (Fixed Width to prevent squashing) -->
|
||||||
</svg>
|
<div class="w-64 flex flex-col h-full pt-6 pb-4 flex-shrink-0">
|
||||||
<span x-text="$store.global.t('dashboard')">Dashboard</span>
|
<!-- Mobile Menu Header -->
|
||||||
</button>
|
<div class="flex items-center justify-between px-4 mb-6 lg:hidden">
|
||||||
<button
|
<span class="text-sm font-bold text-white">Menu</span>
|
||||||
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"
|
<button @click="sidebarOpen = false" class="text-gray-400 hover:text-white">
|
||||||
:class="{'active': $store.global.activeTab === 'models'}"
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@click="$store.global.activeTab = 'models'">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
</button>
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
</div>
|
||||||
</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"
|
<!-- Desktop Header (Main) -->
|
||||||
x-text="$store.global.t('system')">System</div>
|
<div class="px-4 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest hidden lg:block"
|
||||||
<nav class="flex flex-col gap-1">
|
x-text="$store.global.t('main')">Main</div>
|
||||||
<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"
|
<nav class="flex flex-col gap-1">
|
||||||
:class="{'active': $store.global.activeTab === 'logs'}" @click="$store.global.activeTab = 'logs'">
|
<button
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
:class="{'active': $store.global.activeTab === 'dashboard'}"
|
||||||
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" />
|
@click="$store.global.activeTab = 'dashboard'">
|
||||||
</svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span x-text="$store.global.t('logs')">Logs</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</button>
|
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" />
|
||||||
<button
|
</svg>
|
||||||
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"
|
<span x-text="$store.global.t('dashboard')">Dashboard</span>
|
||||||
:class="{'active': $store.global.activeTab === 'settings'}"
|
</button>
|
||||||
@click="$store.global.activeTab = 'settings'">
|
<button
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
:class="{'active': $store.global.activeTab === 'models'}"
|
||||||
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" />
|
@click="$store.global.activeTab = 'models'">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</svg>
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
<span x-text="$store.global.t('settings')">Settings</span>
|
</svg>
|
||||||
</button>
|
<span x-text="$store.global.t('models')">Models</span>
|
||||||
</nav>
|
</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>
|
||||||
|
|
||||||
<!-- Footer Info -->
|
<div class="px-4 mt-8 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest"
|
||||||
<div class="mt-auto px-6 text-[10px] text-gray-700 font-mono">
|
x-text="$store.global.t('system')">System</div>
|
||||||
<div class="flex justify-between">
|
<nav class="flex flex-col gap-1">
|
||||||
<span x-text="'V ' + $store.global.version">V 1.0.0</span>
|
<button
|
||||||
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" rel="noopener noreferrer"
|
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="hover:text-neon-purple transition-colors">GitHub</a>
|
: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 x-text="'V ' + $store.global.version">V 1.0.0</span>
|
||||||
|
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="hover:text-neon-purple transition-colors">GitHub</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,30 +182,56 @@ window.Components.accountManager = () => ({
|
|||||||
*/
|
*/
|
||||||
getMainModelQuota(account) {
|
getMainModelQuota(account) {
|
||||||
const limits = account.limits || {};
|
const limits = account.limits || {};
|
||||||
const modelIds = Object.keys(limits);
|
|
||||||
|
const getQuotaVal = (id) => {
|
||||||
|
const l = limits[id];
|
||||||
|
if (!l) return -1;
|
||||||
|
if (l.remainingFraction !== null) return l.remainingFraction;
|
||||||
|
if (l.resetTime) return 0; // Rate limited
|
||||||
|
return -1; // Unknown
|
||||||
|
};
|
||||||
|
|
||||||
if (modelIds.length === 0) {
|
const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0);
|
||||||
return { percent: null, model: '-' };
|
|
||||||
}
|
if (validIds.length === 0) return { percent: null, model: '-' };
|
||||||
|
|
||||||
// Priority: opus > sonnet > flash > others
|
const DEAD_THRESHOLD = 0.01;
|
||||||
const priorityModels = [
|
|
||||||
modelIds.find(m => m.toLowerCase().includes('opus')),
|
const MODEL_TIERS = [
|
||||||
modelIds.find(m => m.toLowerCase().includes('sonnet')),
|
{ pattern: /\bopus\b/, aliveScore: 100, deadScore: 60 },
|
||||||
modelIds.find(m => m.toLowerCase().includes('flash')),
|
{ pattern: /\bsonnet\b/, aliveScore: 90, deadScore: 55 },
|
||||||
modelIds[0] // Fallback to first model
|
// Gemini 3 Pro / Ultra
|
||||||
|
{ pattern: /\bgemini-3\b/, extraCheck: (l) => /\bpro\b/.test(l) || /\bultra\b/.test(l), aliveScore: 80, deadScore: 50 },
|
||||||
|
{ pattern: /\bpro\b/, aliveScore: 75, deadScore: 45 },
|
||||||
|
// Mid/Low Tier
|
||||||
|
{ pattern: /\bhaiku\b/, aliveScore: 30, deadScore: 15 },
|
||||||
|
{ pattern: /\bflash\b/, aliveScore: 20, deadScore: 10 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedModel = priorityModels.find(m => m) || modelIds[0];
|
const getPriority = (id) => {
|
||||||
const quota = limits[selectedModel];
|
const lower = id.toLowerCase();
|
||||||
|
const val = getQuotaVal(id);
|
||||||
|
const isAlive = val > DEAD_THRESHOLD;
|
||||||
|
|
||||||
|
for (const tier of MODEL_TIERS) {
|
||||||
|
if (tier.pattern.test(lower)) {
|
||||||
|
if (tier.extraCheck && !tier.extraCheck(lower)) continue;
|
||||||
|
return isAlive ? tier.aliveScore : tier.deadScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAlive ? 5 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
if (!quota || quota.remainingFraction === null) {
|
// Sort by priority desc
|
||||||
return { percent: null, model: selectedModel };
|
validIds.sort((a, b) => getPriority(b) - getPriority(a));
|
||||||
}
|
|
||||||
|
|
||||||
|
const bestModel = validIds[0];
|
||||||
|
const val = getQuotaVal(bestModel);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
percent: Math.round(quota.remainingFraction * 100),
|
percent: Math.round(val * 100),
|
||||||
model: selectedModel
|
model: bestModel
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,13 +45,24 @@ window.Components.dashboard = () => ({
|
|||||||
this.$watch('$store.data.accounts', () => {
|
this.$watch('$store.data.accounts', () => {
|
||||||
if (this.$store.global.activeTab === 'dashboard') {
|
if (this.$store.global.activeTab === 'dashboard') {
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
this.$nextTick(() => this.updateCharts());
|
// Debounce chart updates to prevent rapid flickering
|
||||||
|
if (this._debouncedUpdateCharts) {
|
||||||
|
this._debouncedUpdateCharts();
|
||||||
|
} else {
|
||||||
|
this._debouncedUpdateCharts = window.utils.debounce(() => this.updateCharts(), 100);
|
||||||
|
this._debouncedUpdateCharts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for history updates from data-store (automatically loaded with account data)
|
// Watch for history updates from data-store (automatically loaded with account data)
|
||||||
this.$watch('$store.data.usageHistory', (newHistory) => {
|
this.$watch('$store.data.usageHistory', (newHistory) => {
|
||||||
if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) {
|
if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) {
|
||||||
|
// Optimization: Skip if data hasn't changed (prevents double render on load)
|
||||||
|
if (this.historyData && JSON.stringify(newHistory) === JSON.stringify(this.historyData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.historyData = newHistory;
|
this.historyData = newHistory;
|
||||||
this.processHistory(newHistory);
|
this.processHistory(newHistory);
|
||||||
this.stats.hasTrendData = true;
|
this.stats.hasTrendData = true;
|
||||||
@@ -59,17 +70,22 @@ window.Components.dashboard = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initial update if already on dashboard
|
// Initial update if already on dashboard
|
||||||
|
// Note: Alpine.store('data') may already have data from cache if initialized before this component
|
||||||
if (this.$store.global.activeTab === 'dashboard') {
|
if (this.$store.global.activeTab === 'dashboard') {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
this.updateCharts();
|
this.updateCharts();
|
||||||
|
|
||||||
// Load history if already in store
|
// Optimization: Only process history if it hasn't been processed yet
|
||||||
|
// The usageHistory watcher above will handle updates if data changes
|
||||||
const history = Alpine.store('data').usageHistory;
|
const history = Alpine.store('data').usageHistory;
|
||||||
if (history && Object.keys(history).length > 0) {
|
if (history && Object.keys(history).length > 0) {
|
||||||
this.historyData = history;
|
// Check if we already have this data to avoid redundant chart update
|
||||||
this.processHistory(history);
|
if (!this.historyData || JSON.stringify(history) !== JSON.stringify(this.historyData)) {
|
||||||
this.stats.hasTrendData = true;
|
this.historyData = history;
|
||||||
|
this.processHistory(history);
|
||||||
|
this.stats.hasTrendData = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,16 +135,6 @@ window.DashboardCharts.createDataset = function (label, data, color, canvas) {
|
|||||||
* @param {object} component - Dashboard component instance
|
* @param {object} component - Dashboard component instance
|
||||||
*/
|
*/
|
||||||
window.DashboardCharts.updateCharts = function (component) {
|
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");
|
const canvas = document.getElementById("quotaChart");
|
||||||
|
|
||||||
// Safety checks
|
// Safety checks
|
||||||
@@ -152,6 +142,33 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
console.debug("quotaChart canvas not found");
|
console.debug("quotaChart canvas not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FORCE DESTROY: Check for existing chart on the canvas element property
|
||||||
|
// This handles cases where Component state is lost but DOM persists
|
||||||
|
if (canvas._chartInstance) {
|
||||||
|
console.debug("Destroying existing quota chart from canvas property");
|
||||||
|
try {
|
||||||
|
canvas._chartInstance.destroy();
|
||||||
|
} catch(e) { console.warn(e); }
|
||||||
|
canvas._chartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check component state as backup
|
||||||
|
if (component.charts.quotaDistribution) {
|
||||||
|
try {
|
||||||
|
component.charts.quotaDistribution.destroy();
|
||||||
|
} catch(e) { }
|
||||||
|
component.charts.quotaDistribution = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try Chart.js registry
|
||||||
|
if (typeof Chart !== "undefined" && Chart.getChart) {
|
||||||
|
const regChart = Chart.getChart(canvas);
|
||||||
|
if (regChart) {
|
||||||
|
try { regChart.destroy(); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof Chart === "undefined") {
|
if (typeof Chart === "undefined") {
|
||||||
console.warn("Chart.js not loaded");
|
console.warn("Chart.js not loaded");
|
||||||
return;
|
return;
|
||||||
@@ -178,13 +195,17 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
// Calculate average health from quotaInfo (each entry has { pct })
|
// Calculate average health from quotaInfo (each entry has { pct })
|
||||||
// Health = average of all account quotas for this model
|
// Health = average of all account quotas for this model
|
||||||
const quotaInfo = row.quotaInfo || [];
|
const quotaInfo = row.quotaInfo || [];
|
||||||
|
let avgHealth = 0;
|
||||||
|
|
||||||
if (quotaInfo.length > 0) {
|
if (quotaInfo.length > 0) {
|
||||||
const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
||||||
healthByFamily[family].total++;
|
|
||||||
healthByFamily[family].weighted += avgHealth;
|
|
||||||
totalHealthSum += avgHealth;
|
|
||||||
totalModelCount++;
|
|
||||||
}
|
}
|
||||||
|
// If quotaInfo is empty, avgHealth remains 0 (depleted/unknown)
|
||||||
|
|
||||||
|
healthByFamily[family].total++;
|
||||||
|
healthByFamily[family].weighted += avgHealth;
|
||||||
|
totalHealthSum += avgHealth;
|
||||||
|
totalModelCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update overall health for dashboard display
|
// Update overall health for dashboard display
|
||||||
@@ -193,9 +214,9 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const familyColors = {
|
const familyColors = {
|
||||||
claude: getThemeColor("--color-neon-purple"),
|
claude: getThemeColor("--color-neon-purple") || "#a855f7",
|
||||||
gemini: getThemeColor("--color-neon-green"),
|
gemini: getThemeColor("--color-neon-green") || "#22c55e",
|
||||||
unknown: getThemeColor("--color-neon-cyan"),
|
unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -240,43 +261,52 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
|
|
||||||
// Inactive segment
|
// Inactive segment
|
||||||
data.push(inactiveVal);
|
data.push(inactiveVal);
|
||||||
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.1));
|
// Use higher opacity (0.6) to ensure the ring color matches the legend more closely
|
||||||
|
// while still differentiating "depleted" from "active" (1.0 opacity)
|
||||||
|
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6));
|
||||||
labels.push(depletedLabel);
|
labels.push(depletedLabel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create Chart
|
||||||
try {
|
try {
|
||||||
component.charts.quotaDistribution = new Chart(canvas, {
|
const newChart = new Chart(canvas, {
|
||||||
type: "doughnut",
|
// ... config
|
||||||
data: {
|
type: "doughnut",
|
||||||
labels: labels,
|
data: {
|
||||||
datasets: [
|
labels: labels,
|
||||||
{
|
datasets: [
|
||||||
data: data,
|
{
|
||||||
backgroundColor: colors,
|
data: data,
|
||||||
borderColor: getThemeColor("--color-space-950"),
|
backgroundColor: colors,
|
||||||
borderWidth: 2,
|
borderColor: getThemeColor("--color-space-950"),
|
||||||
hoverOffset: 0,
|
borderWidth: 0,
|
||||||
borderRadius: 0,
|
hoverOffset: 0,
|
||||||
},
|
borderRadius: 0,
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
options: {
|
},
|
||||||
responsive: true,
|
options: {
|
||||||
maintainAspectRatio: false,
|
responsive: true,
|
||||||
cutout: "85%",
|
maintainAspectRatio: false,
|
||||||
rotation: -90,
|
cutout: "85%",
|
||||||
circumference: 360,
|
rotation: -90,
|
||||||
plugins: {
|
circumference: 360,
|
||||||
legend: { display: false },
|
plugins: {
|
||||||
tooltip: { enabled: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
tooltip: { enabled: false },
|
||||||
},
|
title: { display: false },
|
||||||
animation: {
|
},
|
||||||
animateScale: true,
|
animation: {
|
||||||
animateRotate: true,
|
// Disable animation for quota chart to prevent "double refresh" visual glitch
|
||||||
},
|
duration: 0
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SAVE INSTANCE TO CANVAS AND COMPONENT
|
||||||
|
canvas._chartInstance = newChart;
|
||||||
|
component.charts.quotaDistribution = newChart;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to create quota chart:", e);
|
console.error("Failed to create quota chart:", e);
|
||||||
}
|
}
|
||||||
@@ -296,28 +326,46 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|||||||
|
|
||||||
console.log("[updateTrendChart] Starting update...");
|
console.log("[updateTrendChart] Starting update...");
|
||||||
|
|
||||||
// Safely destroy existing chart instance FIRST
|
const canvas = document.getElementById("usageTrendChart");
|
||||||
if (component.charts.usageTrend) {
|
|
||||||
console.log("[updateTrendChart] Destroying existing chart");
|
// FORCE DESTROY: Check for existing chart on the canvas element property
|
||||||
try {
|
if (canvas) {
|
||||||
// Stop all animations before destroying to prevent null context errors
|
if (canvas._chartInstance) {
|
||||||
component.charts.usageTrend.stop();
|
console.debug("Destroying existing trend chart from canvas property");
|
||||||
component.charts.usageTrend.destroy();
|
try {
|
||||||
} catch (e) {
|
canvas._chartInstance.stop();
|
||||||
console.error("[updateTrendChart] Failed to destroy chart:", e);
|
canvas._chartInstance.destroy();
|
||||||
}
|
} catch(e) { console.warn(e); }
|
||||||
component.charts.usageTrend = null;
|
canvas._chartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try Chart.js registry
|
||||||
|
if (typeof Chart !== "undefined" && Chart.getChart) {
|
||||||
|
const regChart = Chart.getChart(canvas);
|
||||||
|
if (regChart) {
|
||||||
|
try { regChart.stop(); regChart.destroy(); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById("usageTrendChart");
|
// Also check component state
|
||||||
|
if (component.charts.usageTrend) {
|
||||||
|
try {
|
||||||
|
component.charts.usageTrend.stop();
|
||||||
|
component.charts.usageTrend.destroy();
|
||||||
|
} catch (e) { }
|
||||||
|
component.charts.usageTrend = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Safety checks
|
// Safety checks
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.error("[updateTrendChart] Canvas not found in DOM!");
|
console.error("[updateTrendChart] Canvas not found in DOM!");
|
||||||
|
_trendChartUpdateLock = false; // Release lock!
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof Chart === "undefined") {
|
if (typeof Chart === "undefined") {
|
||||||
console.error("[updateTrendChart] Chart.js not loaded");
|
console.error("[updateTrendChart] Chart.js not loaded");
|
||||||
|
_trendChartUpdateLock = false; // Release lock!
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +518,7 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
component.charts.usageTrend = new Chart(canvas, {
|
const newChart = new Chart(canvas, {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: { labels, datasets },
|
data: { labels, datasets },
|
||||||
options: {
|
options: {
|
||||||
@@ -527,6 +575,11 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SAVE INSTANCE
|
||||||
|
canvas._chartInstance = newChart;
|
||||||
|
component.charts.usageTrend = newChart;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to create trend chart:", e);
|
console.error("Failed to create trend chart:", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ window.DashboardStats = window.DashboardStats || {};
|
|||||||
*
|
*
|
||||||
* 统计逻辑:
|
* 统计逻辑:
|
||||||
* 1. 仅统计启用的账号(enabled !== false)
|
* 1. 仅统计启用的账号(enabled !== false)
|
||||||
* 2. 优先统计核心模型(Sonnet/Opus/Pro/Flash)的配额
|
* 2. 检查账号下所有追踪模型的配额
|
||||||
* 3. 配额 > 5% 视为 active,否则为 limited
|
* 3. 如果任一追踪模型配额 <= 5%,则标记为 limited (Rate Limited Cooldown)
|
||||||
* 4. 状态非 'ok' 的账号归为 limited
|
* 4. 如果所有追踪模型配额 > 5%,则标记为 active
|
||||||
|
* 5. 状态非 'ok' 的账号归为 limited
|
||||||
*
|
*
|
||||||
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
||||||
* @param {object} component.stats - 统计数据对象(会被修改)
|
* @param {object} component.stats - 统计数据对象(会被修改)
|
||||||
@@ -37,24 +38,32 @@ window.DashboardStats.updateStats = function(component) {
|
|||||||
const accounts = Alpine.store('data').accounts;
|
const accounts = Alpine.store('data').accounts;
|
||||||
let active = 0, limited = 0;
|
let active = 0, limited = 0;
|
||||||
|
|
||||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
|
||||||
|
|
||||||
// Only count enabled accounts in statistics
|
// Only count enabled accounts in statistics
|
||||||
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
||||||
|
|
||||||
enabledAccounts.forEach(acc => {
|
enabledAccounts.forEach(acc => {
|
||||||
if (acc.status === 'ok') {
|
if (acc.status === 'ok') {
|
||||||
const limits = Object.entries(acc.limits || {});
|
const limits = Object.entries(acc.limits || {});
|
||||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
|
||||||
|
|
||||||
if (!hasActiveCore) {
|
if (limits.length === 0) {
|
||||||
const hasAnyCore = limits.some(([id]) => isCore(id));
|
// No limit data available, consider limited to be safe
|
||||||
if (!hasAnyCore) {
|
limited++;
|
||||||
hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasActiveCore) active++; else limited++;
|
// Check if ANY tracked model is rate limited (<= 5%)
|
||||||
|
// We consider all models in the limits object as "tracked"
|
||||||
|
const hasRateLimitedModel = limits.some(([_, l]) => {
|
||||||
|
// Treat null/undefined fraction as 0 (limited)
|
||||||
|
if (!l || l.remainingFraction === null || l.remainingFraction === undefined) return true;
|
||||||
|
return l.remainingFraction <= 0.05;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRateLimitedModel) {
|
||||||
|
limited++;
|
||||||
|
} else {
|
||||||
|
active++;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
limited++;
|
limited++;
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,25 @@ window.DashboardStats.updateStats = function(component) {
|
|||||||
component.stats.active = active;
|
component.stats.active = active;
|
||||||
component.stats.limited = limited;
|
component.stats.limited = limited;
|
||||||
|
|
||||||
|
// Calculate model usage for rate limit details
|
||||||
|
let totalLimitedModels = 0;
|
||||||
|
let totalTrackedModels = 0;
|
||||||
|
|
||||||
|
enabledAccounts.forEach(acc => {
|
||||||
|
const limits = Object.entries(acc.limits || {});
|
||||||
|
limits.forEach(([id, l]) => {
|
||||||
|
totalTrackedModels++;
|
||||||
|
if (!l || l.remainingFraction == null || l.remainingFraction <= 0.05) {
|
||||||
|
totalLimitedModels++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
component.stats.modelUsage = {
|
||||||
|
limited: totalLimitedModels,
|
||||||
|
total: totalTrackedModels
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate subscription tier distribution
|
// Calculate subscription tier distribution
|
||||||
const subscription = { ultra: 0, pro: 0, free: 0 };
|
const subscription = { ultra: 0, pro: 0, free: 0 };
|
||||||
enabledAccounts.forEach(acc => {
|
enabledAccounts.forEach(acc => {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
filters: {
|
filters: {
|
||||||
account: 'all',
|
account: 'all',
|
||||||
family: 'all',
|
family: 'all',
|
||||||
search: ''
|
search: '',
|
||||||
|
sortCol: 'avgQuota',
|
||||||
|
sortAsc: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Settings for calculation
|
// Settings for calculation
|
||||||
@@ -32,12 +34,66 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// For simplicity, let's keep relevant filters here.
|
// For simplicity, let's keep relevant filters here.
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Restore from cache first for instant render
|
||||||
|
this.loadFromCache();
|
||||||
|
|
||||||
|
// Watch filters to recompute
|
||||||
|
// Alpine stores don't have $watch automatically unless inside a component?
|
||||||
|
// We can manually call compute when filters change.
|
||||||
|
|
||||||
// Start health check monitoring
|
// Start health check monitoring
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadFromCache() {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem('ag_data_cache');
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached);
|
||||||
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// Check TTL
|
||||||
|
if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
|
||||||
|
console.log('Cache expired, skipping restoration');
|
||||||
|
localStorage.removeItem('ag_data_cache');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validity check
|
||||||
|
if (data.accounts && data.models) {
|
||||||
|
this.accounts = data.accounts;
|
||||||
|
this.models = data.models;
|
||||||
|
this.modelConfig = data.modelConfig || {};
|
||||||
|
this.usageHistory = data.usageHistory || {};
|
||||||
|
|
||||||
|
// Don't show loading on initial load if we have cache
|
||||||
|
this.initialLoad = false;
|
||||||
|
this.computeQuotaRows();
|
||||||
|
console.log('Restored data from cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load cache', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveToCache() {
|
||||||
|
try {
|
||||||
|
const cacheData = {
|
||||||
|
accounts: this.accounts,
|
||||||
|
models: this.models,
|
||||||
|
modelConfig: this.modelConfig,
|
||||||
|
usageHistory: this.usageHistory,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save cache', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchData() {
|
async fetchData() {
|
||||||
// Only show skeleton on initial load, not on refresh
|
// Only show skeleton on initial load if we didn't restore from cache
|
||||||
if (this.initialLoad) {
|
if (this.initialLoad) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
}
|
}
|
||||||
@@ -65,6 +121,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.usageHistory = data.history;
|
this.usageHistory = data.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.saveToCache(); // Save fresh data
|
||||||
this.computeQuotaRows();
|
this.computeQuotaRows();
|
||||||
|
|
||||||
this.lastUpdated = new Date().toLocaleTimeString();
|
this.lastUpdated = new Date().toLocaleTimeString();
|
||||||
@@ -236,20 +293,52 @@ document.addEventListener('alpine:init', () => {
|
|||||||
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
||||||
quotaInfo,
|
quotaInfo,
|
||||||
pinned: !!config.pinned,
|
pinned: !!config.pinned,
|
||||||
hidden: !!isHidden // Use computed visibility
|
hidden: !!isHidden, // Use computed visibility
|
||||||
|
activeCount: quotaInfo.filter(q => q.pct > 0).length
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: Pinned first, then by avgQuota (descending)
|
// Sort: Pinned first, then by selected column
|
||||||
|
const sortCol = this.filters.sortCol;
|
||||||
|
const sortAsc = this.filters.sortAsc;
|
||||||
|
|
||||||
this.quotaRows = rows.sort((a, b) => {
|
this.quotaRows = rows.sort((a, b) => {
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
return b.avgQuota - a.avgQuota;
|
|
||||||
|
let valA = a[sortCol];
|
||||||
|
let valB = b[sortCol];
|
||||||
|
|
||||||
|
// Handle nulls (always push to bottom)
|
||||||
|
if (valA === valB) return 0;
|
||||||
|
if (valA === null || valA === undefined) return 1;
|
||||||
|
if (valB === null || valB === undefined) return -1;
|
||||||
|
|
||||||
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||||
|
return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortAsc ? valA - valB : valB - valA;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger Dashboard Update if active
|
// Trigger Dashboard Update if active
|
||||||
// Ideally dashboard watches this store.
|
// Ideally dashboard watches this store.
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSort(col) {
|
||||||
|
if (this.filters.sortCol === col) {
|
||||||
|
this.filters.sortAsc = !this.filters.sortAsc;
|
||||||
|
} else {
|
||||||
|
this.filters.sortCol = col;
|
||||||
|
// Default sort direction: Descending for numbers/stats, Ascending for text/time
|
||||||
|
if (['avgQuota', 'activeCount'].includes(col)) {
|
||||||
|
this.filters.sortAsc = false;
|
||||||
|
} else {
|
||||||
|
this.filters.sortAsc = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.computeQuotaRows();
|
||||||
|
},
|
||||||
|
|
||||||
getModelFamily(modelId) {
|
getModelFamily(modelId) {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
if (lower.includes('claude')) return 'claude';
|
if (lower.includes('claude')) return 'claude';
|
||||||
@@ -286,8 +375,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
quotaInfo.push({ pct });
|
quotaInfo.push({ pct });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (quotaInfo.length === 0) return;
|
// treat missing quotaInfo as 0%/unknown; still include row
|
||||||
|
|
||||||
rows.push({ modelId, family, quotaInfo });
|
rows.push({ modelId, family, quotaInfo });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,33 @@
|
|||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
Alpine.store('global', {
|
Alpine.store('global', {
|
||||||
|
init() {
|
||||||
|
// Hash-based routing
|
||||||
|
const validTabs = ['dashboard', 'models', 'accounts', 'logs', 'settings'];
|
||||||
|
const getHash = () => window.location.hash.substring(1);
|
||||||
|
|
||||||
|
// 1. Initial load from hash
|
||||||
|
const initialHash = getHash();
|
||||||
|
if (validTabs.includes(initialHash)) {
|
||||||
|
this.activeTab = initialHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync State -> URL
|
||||||
|
Alpine.effect(() => {
|
||||||
|
if (validTabs.includes(this.activeTab) && getHash() !== this.activeTab) {
|
||||||
|
window.location.hash = this.activeTab;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Sync URL -> State (Back/Forward buttons)
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const hash = getHash();
|
||||||
|
if (validTabs.includes(hash) && this.activeTab !== hash) {
|
||||||
|
this.activeTab = hash;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// App State
|
// App State
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
activeTab: 'dashboard',
|
activeTab: 'dashboard',
|
||||||
@@ -25,6 +52,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
active: "ACTIVE",
|
active: "ACTIVE",
|
||||||
operational: "Operational",
|
operational: "Operational",
|
||||||
rateLimited: "RATE LIMITED",
|
rateLimited: "RATE LIMITED",
|
||||||
|
quotasDepleted: "{count}/{total} Quotas Depleted",
|
||||||
|
quotasDepletedTitle: "QUOTAS DEPLETED",
|
||||||
|
outOfTracked: "Out of {total} Tracked",
|
||||||
cooldown: "Cooldown",
|
cooldown: "Cooldown",
|
||||||
searchPlaceholder: "Search models...",
|
searchPlaceholder: "Search models...",
|
||||||
allAccounts: "All Accounts",
|
allAccounts: "All Accounts",
|
||||||
@@ -279,6 +309,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
active: "活跃状态",
|
active: "活跃状态",
|
||||||
operational: "运行中",
|
operational: "运行中",
|
||||||
rateLimited: "受限状态",
|
rateLimited: "受限状态",
|
||||||
|
quotasDepleted: "{count}/{total} 配额耗尽",
|
||||||
|
quotasDepletedTitle: "配额耗尽数",
|
||||||
|
outOfTracked: "共追踪 {total} 个",
|
||||||
cooldown: "冷却中",
|
cooldown: "冷却中",
|
||||||
searchPlaceholder: "搜索模型...",
|
searchPlaceholder: "搜索模型...",
|
||||||
allAccounts: "所有账号",
|
allAccounts: "所有账号",
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
<!-- Compact Header -->
|
<!-- Compact Header -->
|
||||||
<div class="flex items-center justify-between gap-4 mb-6">
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
<!-- Title with inline subtitle -->
|
<!-- Title with inline subtitle -->
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accountManagement')">
|
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accountManagement')">
|
||||||
Account Management
|
Account Management
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
|
<div class="flex items-center h-6 px-3 rounded-full bg-space-800/80 border border-space-border/50 shadow-sm backdrop-blur-sm">
|
||||||
x-text="$store.global.t('manageTokens')">
|
<span class="text-[10px] font-mono text-gray-400 uppercase tracking-wider"
|
||||||
Manage Google Account tokens and authorization states
|
x-text="$store.global.t('manageTokens')">
|
||||||
</span>
|
Manage Google Account tokens and authorization states
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
|
|||||||
@@ -2,18 +2,20 @@
|
|||||||
<!-- Compact Header -->
|
<!-- Compact Header -->
|
||||||
<div class="flex items-center justify-between gap-4 mb-6">
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
<!-- Title with inline subtitle -->
|
<!-- Title with inline subtitle -->
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('dashboard')">
|
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('dashboard')">
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
|
<div class="flex items-center h-6 px-3 rounded-full bg-space-800/80 border border-space-border/50 shadow-sm backdrop-blur-sm">
|
||||||
x-text="$store.global.t('systemDesc')">
|
<span class="text-[10px] font-mono text-gray-400 uppercase tracking-wider"
|
||||||
CLAUDE PROXY SYSTEM
|
x-text="$store.global.t('systemDesc')">
|
||||||
</span>
|
CLAUDE PROXY SYSTEM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compact Status Indicator -->
|
<!-- 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="flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-space-900/60 border border-space-border/40 whitespace-nowrap flex-shrink-0">
|
||||||
<div class="relative flex items-center justify-center">
|
<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="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>
|
<span class="relative w-1.5 h-1.5 bg-neon-green rounded-full"></span>
|
||||||
@@ -29,11 +31,12 @@
|
|||||||
<!-- Skeleton Loading (仅在首次加载时显示) -->
|
<!-- Skeleton Loading (仅在首次加载时显示) -->
|
||||||
<div x-show="$store.data.initialLoad" class="space-y-6">
|
<div x-show="$store.data.initialLoad" class="space-y-6">
|
||||||
<!-- Skeleton Stats Grid -->
|
<!-- Skeleton Stats Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
|
<div class="skeleton-stat-card col-span-2 sm:col-span-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skeleton Charts -->
|
<!-- Skeleton Charts -->
|
||||||
@@ -46,31 +49,33 @@
|
|||||||
<!-- Actual Content (首次加载完成后显示) -->
|
<!-- Actual Content (首次加载完成后显示) -->
|
||||||
<div x-show="!$store.data.initialLoad" class="space-y-6">
|
<div x-show="!$store.data.initialLoad" class="space-y-6">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2 lg:gap-3">
|
||||||
<div
|
<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"
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-3 lg:p-4 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer min-w-0"
|
||||||
@click="$store.global.activeTab = 'accounts'"
|
@click="$store.global.activeTab = 'accounts'"
|
||||||
:title="$store.global.t('clickToViewAllAccounts')">
|
:title="$store.global.t('clickToViewAllAccounts')">
|
||||||
<!-- Icon 移到右上角,缩小并变灰 -->
|
<!-- Icon -->
|
||||||
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-cyan-400/70 transition-colors">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 sm:w-5 sm:h-5 stroke-current">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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">
|
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>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- 数字放大为主角 -->
|
<!-- Value -->
|
||||||
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.total"></div>
|
<div class="stat-value text-white font-mono text-2xl lg:text-3xl font-bold mb-1 truncate" x-text="stats.total"></div>
|
||||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
|
<!-- Title -->
|
||||||
|
<div class="stat-title text-gray-500 font-mono text-[10px] uppercase tracking-wider truncate"
|
||||||
x-text="$store.global.t('totalAccounts')"></div>
|
x-text="$store.global.t('totalAccounts')"></div>
|
||||||
|
<!-- Desc -->
|
||||||
<div class="stat-desc text-cyan-400/60 text-[10px] truncate flex items-center gap-1">
|
<div class="stat-desc text-cyan-400/60 text-[10px] truncate flex items-center gap-1">
|
||||||
<span x-text="$store.global.t('linkedAccounts')"></span>
|
<span x-text="$store.global.t('linkedAccounts')" class="truncate"></span>
|
||||||
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Subscription Tier Distribution -->
|
<!-- Tiers -->
|
||||||
<div class="flex items-center gap-2 mt-2 text-[10px] font-mono" x-show="stats.subscription">
|
<div class="flex items-center gap-1 mt-2 text-[10px] font-mono flex-wrap" x-show="stats.subscription">
|
||||||
<template x-if="stats.subscription?.ultra > 0">
|
<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 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 x-text="stats.subscription.ultra"></span> Ultra
|
||||||
@@ -90,42 +95,63 @@
|
|||||||
</div>
|
</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"
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-3 lg:p-4 hover:border-green-500/30 hover:bg-green-500/5 transition-all duration-300 group relative cursor-pointer min-w-0"
|
||||||
@click="$store.global.activeTab = 'models'"
|
@click="$store.global.activeTab = 'models'"
|
||||||
:title="$store.global.t('clickToViewModels')">
|
:title="$store.global.t('clickToViewModels')">
|
||||||
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-green-400/70 transition-colors">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 sm:w-5 sm:h-5 stroke-current">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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>
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.active"></div>
|
<div class="stat-value text-white font-mono text-2xl lg:text-3xl font-bold mb-1 truncate" x-text="stats.active"></div>
|
||||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
|
<div class="stat-title text-gray-500 font-mono text-[10px] uppercase tracking-wider truncate"
|
||||||
x-text="$store.global.t('active')"></div>
|
x-text="$store.global.t('active')"></div>
|
||||||
<div class="stat-desc text-green-400/60 text-[10px] truncate flex items-center gap-1">
|
<div class="stat-desc text-green-400/60 text-[10px] truncate flex items-center gap-1">
|
||||||
<span x-text="$store.global.t('operational')"></span>
|
<span x-text="$store.global.t('operational')" class="truncate"></span>
|
||||||
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-3 lg:p-4 hover:border-red-500/30 hover:bg-red-500/5 transition-all duration-300 group relative cursor-pointer min-w-0"
|
||||||
@click="$store.global.activeTab = 'accounts'"
|
@click="$store.global.activeTab = 'accounts'"
|
||||||
:title="$store.global.t('clickToViewLimitedAccounts')">
|
:title="$store.global.t('clickToViewLimitedAccounts')">
|
||||||
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-red-500/70 transition-colors">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 sm:w-5 sm:h-5 stroke-current">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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>
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.limited"></div>
|
<div class="stat-value text-white font-mono text-2xl lg:text-3xl font-bold mb-1 truncate" x-text="stats.limited"></div>
|
||||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
|
<div class="stat-title text-gray-500 font-mono text-[10px] uppercase tracking-wider truncate"
|
||||||
x-text="$store.global.t('rateLimited')"></div>
|
x-text="$store.global.t('rateLimited')"></div>
|
||||||
<div class="stat-desc text-red-500/60 text-[10px] truncate flex items-center gap-1">
|
<div class="stat-desc text-red-500/60 text-[10px] truncate flex items-center gap-1">
|
||||||
<span x-text="$store.global.t('cooldown')"></span>
|
<span x-text="$store.global.t('cooldown')" class="truncate"></span>
|
||||||
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" 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-3 lg:p-4 hover:border-orange-500/30 hover:bg-orange-500/5 transition-all duration-300 group relative cursor-pointer min-w-0"
|
||||||
|
@click="$store.global.activeTab = 'models'"
|
||||||
|
:title="$store.global.t('clickToViewModels')">
|
||||||
|
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-orange-500/70 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 sm:w-5 sm:h-5 stroke-current">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-white font-mono text-2xl lg:text-3xl font-bold mb-1 truncate" x-text="stats.modelUsage ? stats.modelUsage.limited : 0"></div>
|
||||||
|
<div class="stat-title text-gray-500 font-mono text-[10px] lg:text-xs uppercase tracking-wider truncate"
|
||||||
|
x-text="$store.global.t('quotasDepletedTitle')"></div>
|
||||||
|
<div class="stat-desc text-orange-500/60 text-[10px] truncate flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('outOfTracked', {total: stats.modelUsage ? stats.modelUsage.total : 0})" class="truncate"></span>
|
||||||
|
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,9 +159,9 @@
|
|||||||
|
|
||||||
<!-- Global Quota Chart -->
|
<!-- Global Quota Chart -->
|
||||||
<div
|
<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">
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-3 xl:p-4 h-full flex flex-row sm:flex-col items-center justify-between gap-2 overflow-hidden relative group hover:border-space-border/60 transition-colors col-span-2 sm:col-span-1 min-w-0">
|
||||||
<!-- Chart Container -->
|
<!-- Chart Container -->
|
||||||
<div class="h-14 w-14 lg:h-16 lg:w-16 relative flex-shrink-0">
|
<div class="h-14 w-14 xl:h-16 xl:w-16 relative flex-shrink-0 self-center">
|
||||||
<canvas id="quotaChart"></canvas>
|
<canvas id="quotaChart"></canvas>
|
||||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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 class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div>
|
||||||
@@ -143,32 +169,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend / Info -->
|
<!-- Legend / Info -->
|
||||||
<div class="flex flex-col justify-center gap-2 flex-grow min-w-0">
|
<div class="flex flex-col justify-center gap-1 flex-grow min-w-0 w-full sm:text-center">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between sm:justify-center h-full">
|
||||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono truncate"
|
<span class="text-[10px] text-gray-500 uppercase font-mono leading-tight whitespace-normal sm:px-1"
|
||||||
x-text="$store.global.t('globalQuota')">Global Quota</span>
|
x-text="$store.global.t('globalQuota')">Global Quota</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Legend -->
|
<!-- Custom Legend -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-0.5 sm:flex sm:flex-col sm:items-center w-full">
|
||||||
<div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-purple transition-colors group/legend"
|
<div class="flex items-center justify-between sm:justify-center sm:gap-2 text-[10px] text-gray-400 cursor-pointer hover:text-neon-purple transition-colors group/legend w-full sm:w-auto"
|
||||||
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'claude'; $store.data.computeQuotaRows(); })"
|
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'claude'; $store.data.computeQuotaRows(); })"
|
||||||
:title="$store.global.t('clickToFilterClaude')">
|
:title="$store.global.t('clickToFilterClaude')">
|
||||||
<div class="flex items-center gap-1.5 truncate">
|
<div class="flex items-center gap-1.5">
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-neon-purple flex-shrink-0"></div>
|
<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>
|
<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">
|
<!-- Hidden arrow on desktop/stacked view to save space -->
|
||||||
|
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity flex-shrink-0 sm:hidden" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-green transition-colors group/legend"
|
<div class="flex items-center justify-between sm:justify-center sm:gap-2 text-[10px] text-gray-400 cursor-pointer hover:text-neon-green transition-colors group/legend w-full sm:w-auto"
|
||||||
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'gemini'; $store.data.computeQuotaRows(); })"
|
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'gemini'; $store.data.computeQuotaRows(); })"
|
||||||
:title="$store.global.t('clickToFilterGemini')">
|
:title="$store.global.t('clickToFilterGemini')">
|
||||||
<div class="flex items-center gap-1.5 truncate">
|
<div class="flex items-center gap-1.5">
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-neon-green flex-shrink-0"></div>
|
<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>
|
<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">
|
<!-- Hidden arrow on desktop/stacked view to save space -->
|
||||||
|
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity flex-shrink-0 sm:hidden" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,11 +205,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usage Trend Chart -->
|
<!-- Usage Trend Chart -->
|
||||||
<div class="view-card">
|
<div class="view-card">
|
||||||
<!-- Header with Stats and Filter -->
|
<!-- 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-col lg:flex-row items-start lg:items-center justify-between gap-6 mb-8">
|
||||||
<div class="flex flex-wrap items-center gap-5">
|
<div class="flex flex-wrap items-center gap-5">
|
||||||
<div class="flex items-center gap-2.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"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
@@ -210,12 +237,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 w-full sm:w-auto justify-end flex-wrap">
|
<div class="flex items-center gap-2 sm:gap-3 w-full sm:w-auto justify-start sm:justify-end flex-wrap lg:flex-nowrap lg:gap-4 lg:bg-space-900/40 lg:p-1.5 lg:rounded-lg lg:border lg:border-space-border/30 lg:whitespace-nowrap lg:flex-shrink-0">
|
||||||
<!-- Time Range Dropdown -->
|
<!-- Time Range Dropdown -->
|
||||||
<div class="relative">
|
<div class="relative flex-1 sm:flex-none">
|
||||||
<button @click="showTimeRangeDropdown = !showTimeRangeDropdown; showDisplayModeDropdown = false; showModelFilter = false"
|
<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">
|
class="filter-control">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3.5 h-3.5 lg:w-4 lg:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -230,31 +257,31 @@
|
|||||||
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-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="transition ease-in duration-75"
|
||||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
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"
|
class="absolute right-0 mt-1 w-36 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 py-1"
|
||||||
style="display: none;">
|
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"
|
<button @click="setTimeRange('1h')" class="filter-control-item"
|
||||||
:class="timeRange === '1h' ? 'text-neon-cyan' : 'text-gray-400'"
|
:class="timeRange === '1h' ? 'text-neon-cyan' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('last1Hour')"></button>
|
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"
|
<button @click="setTimeRange('6h')" class="filter-control-item"
|
||||||
:class="timeRange === '6h' ? 'text-neon-cyan' : 'text-gray-400'"
|
:class="timeRange === '6h' ? 'text-neon-cyan' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('last6Hours')"></button>
|
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"
|
<button @click="setTimeRange('24h')" class="filter-control-item"
|
||||||
:class="timeRange === '24h' ? 'text-neon-cyan' : 'text-gray-400'"
|
:class="timeRange === '24h' ? 'text-neon-cyan' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('last24Hours')"></button>
|
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"
|
<button @click="setTimeRange('7d')" class="filter-control-item"
|
||||||
:class="timeRange === '7d' ? 'text-neon-cyan' : 'text-gray-400'"
|
:class="timeRange === '7d' ? 'text-neon-cyan' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('last7Days')"></button>
|
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"
|
<button @click="setTimeRange('all')" class="filter-control-item"
|
||||||
:class="timeRange === 'all' ? 'text-neon-cyan' : 'text-gray-400'"
|
:class="timeRange === 'all' ? 'text-neon-cyan' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('allTime')"></button>
|
x-text="$store.global.t('allTime')"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display Mode Dropdown -->
|
<!-- Display Mode Dropdown -->
|
||||||
<div class="relative">
|
<div class="relative flex-1 sm:flex-none">
|
||||||
<button @click="showDisplayModeDropdown = !showDisplayModeDropdown; showTimeRangeDropdown = false; showModelFilter = false"
|
<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">
|
class="flex items-center justify-center gap-2 px-3 py-1.5 lg:px-4 lg:py-2 text-[10px] lg:text-xs font-mono font-medium text-gray-400 bg-space-800 lg:bg-transparent border border-space-border/50 lg:border-transparent rounded lg:rounded-md hover:text-white lg:hover:bg-space-800 hover:border-neon-purple/50 lg:hover:border-neon-purple/30 lg:hover:shadow-lg lg:hover:shadow-neon-purple/10 transition-all duration-200 whitespace-nowrap w-full sm:w-auto">
|
||||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3 h-3 lg:w-4 lg:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
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>
|
</svg>
|
||||||
@@ -269,22 +296,22 @@
|
|||||||
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-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="transition ease-in duration-75"
|
||||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
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"
|
class="absolute right-0 mt-1 w-32 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 py-1"
|
||||||
style="display: none;">
|
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"
|
<button @click="setDisplayMode('family')" class="filter-control-item"
|
||||||
:class="displayMode === 'family' ? 'text-neon-purple' : 'text-gray-400'"
|
:class="displayMode === 'family' ? 'text-neon-purple' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('family')"></button>
|
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"
|
<button @click="setDisplayMode('model')" class="filter-control-item"
|
||||||
:class="displayMode === 'model' ? 'text-neon-purple' : 'text-gray-400'"
|
:class="displayMode === 'model' ? 'text-neon-purple' : 'text-gray-400'"
|
||||||
x-text="$store.global.t('model')"></button>
|
x-text="$store.global.t('model')"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Dropdown -->
|
<!-- Filter Dropdown -->
|
||||||
<div class="relative">
|
<div class="relative flex-1 sm:flex-none min-w-[120px]">
|
||||||
<button @click="showModelFilter = !showModelFilter; showTimeRangeDropdown = false; showDisplayModeDropdown = false"
|
<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">
|
class="flex items-center justify-center gap-2 px-3 py-1.5 lg:px-4 lg:py-2 text-[10px] lg:text-xs font-mono font-medium text-gray-400 bg-space-800 lg:bg-transparent border border-space-border/50 lg:border-transparent rounded lg:rounded-md hover:text-white lg:hover:bg-space-800 hover:border-neon-purple/50 lg:hover:border-neon-purple/30 lg:hover:shadow-lg lg:hover:shadow-neon-purple/10 transition-all duration-200 whitespace-nowrap w-full sm:w-auto">
|
||||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3 h-3 lg:w-4 lg:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
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>
|
</svg>
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
<!-- Compact Header -->
|
<!-- Compact Header -->
|
||||||
<div class="flex items-center justify-between gap-4 mb-6">
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
<!-- Title with inline subtitle -->
|
<!-- Title with inline subtitle -->
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('models')">
|
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('models')">
|
||||||
Models
|
Models
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
|
<div class="flex items-center h-6 px-3 rounded-full bg-space-800/80 border border-space-border/50 shadow-sm backdrop-blur-sm">
|
||||||
x-text="$store.global.t('modelsPageDesc')">
|
<span class="text-[10px] font-mono text-gray-400 uppercase tracking-wider"
|
||||||
Real-time quota and status for all available models.
|
x-text="$store.global.t('modelsPageDesc')">
|
||||||
</span>
|
Real-time quota and status for all available models.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
@@ -94,14 +96,60 @@
|
|||||||
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
|
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 py-3 pl-4 whitespace-nowrap" x-text="$store.global.t('stat')">Stat</th>
|
<th class="w-14 py-3 pl-4 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
<th class="py-3 whitespace-nowrap" x-text="$store.global.t('modelIdentity')">Model Identity</th>
|
@click="$store.data.setSort('avgQuota')">
|
||||||
<th class="min-w-[12rem] py-3 whitespace-nowrap" x-text="$store.global.t('globalQuota')">Global
|
<div class="flex items-center justify-center">
|
||||||
Quota</th>
|
<span x-text="$store.global.t('stat')">Stat</span>
|
||||||
<th class="min-w-[8rem] py-3 whitespace-nowrap" x-text="$store.global.t('nextReset')">Next Reset
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('modelId')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('modelIdentity')">Model Identity</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'modelId' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'modelId' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="min-w-[12rem] py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('avgQuota')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('globalQuota')">Global Quota</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'avgQuota' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'avgQuota' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="min-w-[8rem] py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('minResetTime')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('nextReset')">Next Reset</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'minResetTime' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'minResetTime' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('activeCount')">
|
||||||
|
<div class="flex items-center gap-1 justify-start">
|
||||||
|
<span x-text="$store.global.t('distribution')">Account Distribution</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'activeCount' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'activeCount' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</th>
|
</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 class="w-20 py-3 pr-4 text-right whitespace-nowrap" x-text="$store.global.t('actions')">Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -143,20 +191,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="text-[10px] font-mono text-gray-500 hidden xl:block text-right leading-tight opacity-70">
|
class="text-[10px] font-mono text-gray-500 hidden xl:block text-left leading-tight opacity-70">
|
||||||
<div
|
<div
|
||||||
x-text="$store.global.t('activeCount', {count: row.quotaInfo?.filter(q => q.pct > 0).length || 0})">
|
x-text="$store.global.t('activeCount', {count: row.quotaInfo?.filter(q => q.pct > 0).length || 0})">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Account Status Indicators -->
|
<!-- Account Status Indicators -->
|
||||||
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]" x-data="{ maxVisible: 12 }">
|
<div class="flex flex-wrap gap-1 justify-start max-w-[200px]" x-data="{ maxVisible: 12 }">
|
||||||
<template x-if="!row.quotaInfo || row.quotaInfo.length === 0">
|
<template x-if="!row.quotaInfo || row.quotaInfo.length === 0">
|
||||||
<div class="text-[10px] text-gray-600 italic">No data</div>
|
<div class="text-[10px] text-gray-600 italic">No data</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="row.quotaInfo && row.quotaInfo.length > 0">
|
<template x-if="row.quotaInfo && row.quotaInfo.length > 0">
|
||||||
<div class="flex flex-wrap gap-1 justify-end">
|
<div class="flex flex-wrap gap-1 justify-start">
|
||||||
<!-- Visible accounts (limited to maxVisible) -->
|
<!-- Visible accounts (limited to maxVisible) -->
|
||||||
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
|
<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="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
|
||||||
|
|||||||
Reference in New Issue
Block a user