feat: Add Web UI for account and quota management
## Summary Add an optional Web UI for managing accounts and monitoring quotas. WebUI is implemented as a modular plugin with minimal changes to server.js (only 5 lines added). ## New Features - Dashboard: Real-time model quota visualization with Chart.js - Accounts: OAuth-based account management (add/enable/disable/refresh/remove) - Logs: Live server log streaming via SSE with search and level filtering - Settings: System configuration with 4 tabs - Interface: Language (EN/zh_CN), polling interval, log buffer size, display options - Claude CLI: Proxy connection config, model selection, alias overrides (~/.claude.json) - Models: Model visibility and ordering management - Server Info: Runtime info and account config reload ## Technical Changes - Add src/webui/index.js as modular plugin (all WebUI routes encapsulated) - Add src/config.js for centralized configuration (~/.config/antigravity-proxy/config.json) - Add authMiddleware for optional password protection (WEBUI_PASSWORD env var) - Enhance logger with EventEmitter for SSE log streaming - Make constants configurable via config.json - Merge with main v1.2.6 (model fallback, cross-model thinking) - server.js changes: only 5 lines added to import and mount WebUI module ## Bug Fixes - Fix Alpine.js $watch error in settings-store.js (not supported in store init) - Fix "OK" label to "SUCCESS" in logs filter - Add saveSettings() calls to settings toggles for proper persistence - Improve Claude CLI config robustness (handle empty/invalid JSON files) - Add safety check for empty config.env in claude-config component - Improve config.example.json instructions with clear Windows/macOS/Linux paths ## New Files - src/webui/index.js - WebUI module with all API routes - public/ - Complete Web UI frontend (Alpine.js + TailwindCSS + DaisyUI) - src/config.js - Configuration management - src/utils/claude-config.js - Claude CLI settings helper - tests/frontend/ - Frontend test suite ## API Endpoints Added - GET/POST /api/config - Server configuration - GET/POST /api/claude/config - Claude CLI configuration - POST /api/models/config - Model alias/hidden settings - GET /api/accounts - Account list with status - POST /api/accounts/:email/toggle - Enable/disable account - POST /api/accounts/:email/refresh - Refresh account token - DELETE /api/accounts/:email - Remove account - GET /api/logs - Log history - GET /api/logs/stream - Live log streaming (SSE) - GET /api/auth/url - OAuth URL generation - GET /oauth/callback - OAuth callback handler ## Backward Compatibility - Default port remains 8080 - All existing CLI/API functionality unchanged - WebUI is entirely optional - Can be disabled by removing mountWebUI() call
This commit is contained in:
130
public/app.js
Normal file
130
public/app.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Antigravity Console - Main Entry
|
||||
*
|
||||
* This file orchestrates Alpine.js initialization.
|
||||
* Components are loaded via separate script files that register themselves
|
||||
* to window.Components before this script runs.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Register Components (loaded from separate files via window.Components)
|
||||
Alpine.data('dashboard', window.Components.dashboard);
|
||||
Alpine.data('accountManager', window.Components.accountManager);
|
||||
Alpine.data('claudeConfig', window.Components.claudeConfig);
|
||||
Alpine.data('logsViewer', window.Components.logsViewer);
|
||||
|
||||
// View Loader Directive
|
||||
Alpine.directive('load-view', (el, { expression }, { evaluate }) => {
|
||||
if (!window.viewCache) window.viewCache = new Map();
|
||||
|
||||
// Evaluate the expression to get the actual view name (removes quotes)
|
||||
const viewName = evaluate(expression);
|
||||
|
||||
if (window.viewCache.has(viewName)) {
|
||||
el.innerHTML = window.viewCache.get(viewName);
|
||||
Alpine.initTree(el);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`views/${viewName}.html`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
window.viewCache.set(viewName, html);
|
||||
el.innerHTML = html;
|
||||
Alpine.initTree(el);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load view:', viewName, err);
|
||||
el.innerHTML = `<div class="p-4 border border-red-500/50 bg-red-500/10 rounded-lg text-red-400 font-mono text-sm">
|
||||
Error loading view: ${viewName}<br>
|
||||
<span class="text-xs opacity-75">${err.message}</span>
|
||||
</div>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Main App Controller
|
||||
Alpine.data('app', () => ({
|
||||
get connectionStatus() {
|
||||
return Alpine.store('data')?.connectionStatus || 'connecting';
|
||||
},
|
||||
get loading() {
|
||||
return Alpine.store('data')?.loading || false;
|
||||
},
|
||||
|
||||
init() {
|
||||
console.log('App controller initialized');
|
||||
|
||||
// Theme setup
|
||||
document.documentElement.setAttribute('data-theme', 'black');
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
// Chart Defaults
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.color = '#71717a';
|
||||
Chart.defaults.borderColor = '#27272a';
|
||||
Chart.defaults.font.family = '"JetBrains Mono", monospace';
|
||||
}
|
||||
|
||||
// Start Data Polling
|
||||
this.startAutoRefresh();
|
||||
document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
|
||||
|
||||
// Initial Fetch
|
||||
Alpine.store('data').fetchData();
|
||||
},
|
||||
|
||||
refreshTimer: null,
|
||||
|
||||
fetchData() {
|
||||
Alpine.store('data').fetchData();
|
||||
},
|
||||
|
||||
startAutoRefresh() {
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
const interval = parseInt(Alpine.store('settings')?.refreshInterval || 60);
|
||||
if (interval > 0) {
|
||||
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
|
||||
}
|
||||
},
|
||||
|
||||
t(key) {
|
||||
return Alpine.store('global')?.t(key) || key;
|
||||
},
|
||||
|
||||
async addAccountWeb(reAuthEmail = null) {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const urlPath = reAuthEmail
|
||||
? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
|
||||
: '/api/auth/url';
|
||||
|
||||
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
||||
|
||||
const messageHandler = (event) => {
|
||||
if (event.data?.type === 'oauth-success') {
|
||||
const action = reAuthEmail ? 're-authenticated' : 'added';
|
||||
Alpine.store('global').showToast(`Account ${event.data.email} ${action} successfully`, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
document.getElementById('add_account_modal')?.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to start OAuth flow: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
92
public/css/style.css
Normal file
92
public/css/style.css
Normal file
@@ -0,0 +1,92 @@
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.glass-panel {
|
||||
background: rgba(23, 23, 23, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(90deg, rgba(168, 85, 247, 0.15) 0%, transparent 100%);
|
||||
border-left: 3px solid #a855f7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.progress-gradient-success::-webkit-progress-value {
|
||||
background-image: linear-gradient(to right, #22c55e, #4ade80);
|
||||
}
|
||||
|
||||
.progress-gradient-warning::-webkit-progress-value {
|
||||
background-image: linear-gradient(to right, #eab308, #facc15);
|
||||
}
|
||||
|
||||
.progress-gradient-error::-webkit-progress-value {
|
||||
background-image: linear-gradient(to right, #dc2626, #f87171);
|
||||
}
|
||||
|
||||
/* Dashboard Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tooltip Customization */
|
||||
.tooltip:before {
|
||||
background-color: #171717 !important; /* space-800 */
|
||||
border: 1px solid #27272a; /* space-border */
|
||||
color: #e5e7eb !important;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tooltip-left:before {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
291
public/index.html
Normal file
291
public/index.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="black" class="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Antigravity Console</title>
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
||||
<!-- Alpine.js must be deferred so stores register their listeners first -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
// Deep Space Palette
|
||||
space: {
|
||||
950: '#050505', // Almost black
|
||||
900: '#0a0a0a',
|
||||
800: '#171717',
|
||||
border: '#27272a'
|
||||
},
|
||||
neon: {
|
||||
purple: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
green: '#22c55e'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="bg-space-950 text-gray-300 font-sans antialiased min-h-screen overflow-hidden selection:bg-neon-purple selection:text-white"
|
||||
x-cloak x-data="app" x-init="console.log('App initialized')">
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
<template x-if="$store.global.toast">
|
||||
<div x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-8 scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-x-4 scale-95"
|
||||
class="alert shadow-lg border backdrop-blur-md pointer-events-auto min-w-[300px]" :class="{
|
||||
'alert-info border-neon-cyan/20 bg-space-900/90 text-neon-cyan': $store.global.toast.type === 'info',
|
||||
'alert-success border-neon-green/20 bg-space-900/90 text-neon-green': $store.global.toast.type === 'success',
|
||||
'alert-error border-red-500/20 bg-space-900/90 text-red-400': $store.global.toast.type === 'error'
|
||||
}">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Icons based on type -->
|
||||
<template x-if="$store.global.toast.type === 'info'">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="$store.global.toast.type === 'success'">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="$store.global.toast.type === 'error'">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
<span x-text="$store.global.toast.message" class="font-mono text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Navbar -->
|
||||
<div
|
||||
class="h-14 border-b border-space-border flex items-center px-6 justify-between bg-space-900/50 backdrop-blur-md z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded bg-gradient-to-br from-neon-purple to-blue-600 flex items-center justify-center text-white font-bold shadow-[0_0_15px_rgba(168,85,247,0.4)]">
|
||||
AG</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold tracking-wide text-white">ANTIGRAVITY</span>
|
||||
<span class="text-[10px] text-gray-500 font-mono tracking-wider">CLAUDE PROXY SYSTEM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Connection Pill -->
|
||||
<div class="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-mono border transition-all duration-300"
|
||||
:class="connectionStatus === 'connected'
|
||||
? 'bg-neon-green/10 border-neon-green/20 text-neon-green'
|
||||
: (connectionStatus === 'connecting' ? 'bg-yellow-500/10 border-yellow-500/20 text-yellow-500' : 'bg-red-500/10 border-red-500/20 text-red-500')">
|
||||
<div class="w-1.5 h-1.5 rounded-full"
|
||||
:class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
|
||||
</div>
|
||||
<span
|
||||
x-text="connectionStatus === 'connected' ? 'ONLINE' : (connectionStatus === 'disconnected' ? 'OFFLINE' : 'CONNECTING')"></span>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-space-border"></div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
|
||||
@click="fetchData" :disabled="loading" title="Refresh Data">
|
||||
<svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="flex h-[calc(100vh-56px)]">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="w-64 bg-space-900 border-r border-space-border flex flex-col pt-6 pb-4">
|
||||
<div class="px-4 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest">Main</div>
|
||||
<nav class="flex flex-col gap-1">
|
||||
<button
|
||||
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
|
||||
:class="{'active': $store.global.activeTab === 'dashboard'}"
|
||||
@click="$store.global.activeTab = 'dashboard'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('dashboard')">Dashboard</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
|
||||
:class="{'active': $store.global.activeTab === '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">System</div>
|
||||
<nav class="flex flex-col gap-1">
|
||||
<button
|
||||
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
|
||||
:class="{'active': $store.global.activeTab === 'logs'}" @click="$store.global.activeTab = 'logs'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('logs')">Logs</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
|
||||
:class="{'active': $store.global.activeTab === 'settings'}"
|
||||
@click="$store.global.activeTab = 'settings'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('settings')">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="mt-auto px-6 text-[10px] text-gray-700 font-mono">
|
||||
<div class="flex justify-between">
|
||||
<span>V 1.0.0</span>
|
||||
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank"
|
||||
class="hover:text-neon-purple transition-colors">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto bg-space-950 p-6 relative">
|
||||
|
||||
<!-- Views Container -->
|
||||
<!-- Dashboard -->
|
||||
<div x-show="$store.global.activeTab === 'dashboard'" x-load-view="'dashboard'"
|
||||
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
|
||||
class="max-w-7xl mx-auto h-full"></div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div x-show="$store.global.activeTab === 'logs'" x-load-view="'logs'" x-transition:enter="fade-enter-active"
|
||||
x-transition:enter-start="fade-enter-from" class="h-full"></div>
|
||||
|
||||
<!-- Accounts -->
|
||||
<div x-show="$store.global.activeTab === 'accounts'" x-load-view="'accounts'"
|
||||
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
|
||||
class="max-w-4xl mx-auto h-full"></div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div x-show="$store.global.activeTab === 'settings'" x-load-view="'settings'"
|
||||
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
|
||||
class="max-w-2xl mx-auto h-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Account Modal -->
|
||||
<dialog id="add_account_modal" class="modal backdrop-blur-sm">
|
||||
<div class="modal-box bg-space-900 border border-space-border text-gray-300 shadow-[0_0_50px_rgba(0,0,0,0.5)]">
|
||||
<h3 class="font-bold text-lg text-white" x-text="t('addNode')">Add New Account</h3>
|
||||
|
||||
<div class="py-6 flex flex-col gap-4">
|
||||
<p class="text-sm text-gray-400">Connect a Google Workspace account to increase your API quota limit.
|
||||
The account will be used to proxy Claude requests via Antigravity.</p>
|
||||
|
||||
<button
|
||||
class="btn btn-primary bg-white text-black hover:bg-gray-200 border-none flex items-center justify-center gap-3 h-12"
|
||||
@click="addAccountWeb">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"></path>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"></path>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"></path>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"></path>
|
||||
</svg>
|
||||
<span x-text="t('connectGoogle')">Connect Google Account</span>
|
||||
</button>
|
||||
|
||||
<div class="divider text-xs text-gray-600">OR</div>
|
||||
|
||||
<div class="collapse collapse-arrow bg-space-800 border border-space-border/50">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-sm font-medium text-gray-400">
|
||||
Use CLI Command
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="mockup-code bg-black text-gray-300 border border-space-border scale-90 -ml-4 mt-2">
|
||||
<pre data-prefix="$"><code>npm run accounts:add</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-ghost hover:bg-white/10">Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Scripts - Loading Order Matters! -->
|
||||
<!-- 1. Utils (global helpers) -->
|
||||
<script src="js/utils.js"></script>
|
||||
<!-- 2. Alpine Stores (register alpine:init listeners) -->
|
||||
<script src="js/store.js"></script>
|
||||
<script src="js/data-store.js"></script>
|
||||
<script src="js/settings-store.js"></script>
|
||||
<!-- 3. Components (register to window.Components) -->
|
||||
<script src="js/components/dashboard.js"></script>
|
||||
<script src="js/components/account-manager.js"></script>
|
||||
<script src="js/components/claude-config.js"></script>
|
||||
<script src="js/components/logs-viewer.js"></script>
|
||||
<!-- 4. App (registers Alpine components from window.Components) -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
106
public/js/app-init.js
Normal file
106
public/js/app-init.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* App Initialization (Non-module version)
|
||||
* This must load BEFORE Alpine initializes
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Registering app component...');
|
||||
|
||||
// Main App Controller
|
||||
Alpine.data('app', () => ({
|
||||
// Re-expose store properties for easier access in navbar
|
||||
get connectionStatus() {
|
||||
return Alpine.store('data').connectionStatus;
|
||||
},
|
||||
get loading() {
|
||||
return Alpine.store('data').loading;
|
||||
},
|
||||
|
||||
init() {
|
||||
console.log('App component initializing...');
|
||||
|
||||
// Theme setup
|
||||
document.documentElement.setAttribute('data-theme', 'black');
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
// Chart Defaults
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.color = '#71717a';
|
||||
Chart.defaults.borderColor = '#27272a';
|
||||
Chart.defaults.font.family = '"JetBrains Mono", monospace';
|
||||
}
|
||||
|
||||
// Start Data Polling
|
||||
this.startAutoRefresh();
|
||||
document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
|
||||
|
||||
// Initial Fetch
|
||||
Alpine.store('data').fetchData();
|
||||
},
|
||||
|
||||
refreshTimer: null,
|
||||
|
||||
fetchData() {
|
||||
Alpine.store('data').fetchData();
|
||||
},
|
||||
|
||||
startAutoRefresh() {
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
const interval = parseInt(Alpine.store('settings').refreshInterval);
|
||||
if (interval > 0) {
|
||||
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
|
||||
}
|
||||
},
|
||||
|
||||
// Translation helper for modal (not in a component scope)
|
||||
t(key) {
|
||||
return Alpine.store('global').t(key);
|
||||
},
|
||||
|
||||
// Add account handler for modal
|
||||
async addAccountWeb(reAuthEmail = null) {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const urlPath = reAuthEmail
|
||||
? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
|
||||
: '/api/auth/url';
|
||||
|
||||
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
|
||||
window.open(
|
||||
data.url,
|
||||
'google_oauth',
|
||||
`width=${width},height=${height},top=${top},left=${left},scrollbars=yes`
|
||||
);
|
||||
|
||||
const messageHandler = (event) => {
|
||||
if (event.data?.type === 'oauth-success') {
|
||||
const action = reAuthEmail ? 're-authenticated' : 'added';
|
||||
Alpine.store('global').showToast(`Account ${event.data.email} ${action} successfully`, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
|
||||
const modal = document.getElementById('add_account_modal');
|
||||
if (modal) modal.close();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to start OAuth flow: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
104
public/js/components/account-manager.js
Normal file
104
public/js/components/account-manager.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Account Manager Component
|
||||
* Registers itself to window.Components for Alpine.js to consume
|
||||
*/
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.accountManager = () => ({
|
||||
async refreshAccount(email) {
|
||||
Alpine.store('global').showToast(`Refreshing ${email}...`, 'info');
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Refreshed ${email}`, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Refresh failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Refresh failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleAccount(email, enabled) {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Account ${email} ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Toggle failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Toggle failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async fixAccount(email) {
|
||||
Alpine.store('global').showToast(`Re-authenticating ${email}...`, 'info');
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
|
||||
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAccount(email) {
|
||||
if (!confirm(Alpine.store('global').t('confirmDelete'))) return;
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast(`Deleted ${email}`, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async reloadAccounts() {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
Alpine.store('global').showToast('Accounts reloaded', 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || 'Reload failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Reload failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
54
public/js/components/claude-config.js
Normal file
54
public/js/components/claude-config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Claude Config Component
|
||||
* Registers itself to window.Components for Alpine.js to consume
|
||||
*/
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.claudeConfig = () => ({
|
||||
config: { env: {} },
|
||||
models: [],
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
this.fetchConfig();
|
||||
this.$watch('$store.data.models', (val) => {
|
||||
this.models = val || [];
|
||||
});
|
||||
this.models = Alpine.store('data').models || [];
|
||||
},
|
||||
|
||||
async fetchConfig() {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/claude/config', {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
this.config = data.config || {};
|
||||
if (!this.config.env) this.config.env = {};
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Claude config:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async saveClaudeConfig() {
|
||||
this.loading = true;
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/claude/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.config)
|
||||
}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
Alpine.store('global').showToast('Claude config saved!', 'success');
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to save config: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
122
public/js/components/dashboard.js
Normal file
122
public/js/components/dashboard.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Dashboard Component
|
||||
* Registers itself to window.Components for Alpine.js to consume
|
||||
*/
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.dashboard = () => ({
|
||||
stats: { total: 0, active: 0, limited: 0, overallHealth: 0 },
|
||||
charts: { quotaDistribution: null },
|
||||
|
||||
init() {
|
||||
// Update stats when dashboard becomes active
|
||||
this.$watch('$store.global.activeTab', (val) => {
|
||||
if (val === 'dashboard') {
|
||||
this.$nextTick(() => {
|
||||
this.updateStats();
|
||||
this.updateCharts();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes
|
||||
this.$watch('$store.data.accounts', () => {
|
||||
if (this.$store.global.activeTab === 'dashboard') {
|
||||
this.updateStats();
|
||||
this.$nextTick(() => this.updateCharts());
|
||||
}
|
||||
});
|
||||
|
||||
// Initial update if already on dashboard
|
||||
if (this.$store.global.activeTab === 'dashboard') {
|
||||
this.$nextTick(() => {
|
||||
this.updateStats();
|
||||
this.updateCharts();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateStats() {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
let active = 0, limited = 0;
|
||||
accounts.forEach(acc => {
|
||||
if (acc.status === 'ok') {
|
||||
const hasQuota = Object.values(acc.limits || {}).some(l => l && l.remainingFraction > 0);
|
||||
if (hasQuota) active++; else limited++;
|
||||
} else {
|
||||
limited++;
|
||||
}
|
||||
});
|
||||
this.stats = { total: accounts.length, active, limited };
|
||||
},
|
||||
|
||||
updateCharts() {
|
||||
const ctx = document.getElementById('quotaChart');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
|
||||
if (this.charts.quotaDistribution) {
|
||||
this.charts.quotaDistribution.destroy();
|
||||
}
|
||||
|
||||
const rows = Alpine.store('data').quotaRows;
|
||||
const familyStats = { claude: { sum: 0, count: 0 }, gemini: { sum: 0, count: 0 }, other: { sum: 0, count: 0 } };
|
||||
|
||||
// Calculate overall system health
|
||||
let totalHealthSum = 0;
|
||||
let totalModelCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const f = familyStats[row.family] ? row.family : 'other';
|
||||
// Use avgQuota if available (new logic), fallback to minQuota (old logic compatibility)
|
||||
const quota = row.avgQuota !== undefined ? row.avgQuota : row.minQuota;
|
||||
|
||||
familyStats[f].sum += quota;
|
||||
familyStats[f].count++;
|
||||
|
||||
totalHealthSum += quota;
|
||||
totalModelCount++;
|
||||
});
|
||||
|
||||
this.stats.overallHealth = totalModelCount > 0 ? Math.round(totalHealthSum / totalModelCount) : 0;
|
||||
|
||||
const labels = ['Claude', 'Gemini', 'Other'];
|
||||
const data = [
|
||||
familyStats.claude.count ? Math.round(familyStats.claude.sum / familyStats.claude.count) : 0,
|
||||
familyStats.gemini.count ? Math.round(familyStats.gemini.sum / familyStats.gemini.count) : 0,
|
||||
familyStats.other.count ? Math.round(familyStats.other.sum / familyStats.other.count) : 0,
|
||||
];
|
||||
|
||||
this.charts.quotaDistribution = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: ['#a855f7', '#22c55e', '#52525b'],
|
||||
borderColor: '#09090b', // Matches bg-space-900 roughly
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
borderRadius: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '85%', // Thinner ring
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // Hide default legend
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false // Disable tooltip for cleaner look, or style it? Let's keep it simple.
|
||||
},
|
||||
title: { display: false }
|
||||
},
|
||||
animation: {
|
||||
animateScale: true,
|
||||
animateRotate: true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
87
public/js/components/logs-viewer.js
Normal file
87
public/js/components/logs-viewer.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Logs Viewer Component
|
||||
* Registers itself to window.Components for Alpine.js to consume
|
||||
*/
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.logsViewer = () => ({
|
||||
logs: [],
|
||||
isAutoScroll: true,
|
||||
eventSource: null,
|
||||
searchQuery: '',
|
||||
filters: {
|
||||
INFO: true,
|
||||
WARN: true,
|
||||
ERROR: true,
|
||||
SUCCESS: true,
|
||||
DEBUG: false
|
||||
},
|
||||
|
||||
get filteredLogs() {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.logs.filter(log => {
|
||||
// Level Filter
|
||||
if (!this.filters[log.level]) return false;
|
||||
|
||||
// Search Filter
|
||||
if (query && !log.message.toLowerCase().includes(query)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
this.startLogStream();
|
||||
|
||||
this.$watch('isAutoScroll', (val) => {
|
||||
if (val) this.scrollToBottom();
|
||||
});
|
||||
|
||||
// Watch filters to maintain auto-scroll if enabled
|
||||
this.$watch('searchQuery', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
|
||||
this.$watch('filters', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
|
||||
},
|
||||
|
||||
startLogStream() {
|
||||
if (this.eventSource) this.eventSource.close();
|
||||
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const url = password
|
||||
? `/api/logs/stream?history=true&password=${encodeURIComponent(password)}`
|
||||
: '/api/logs/stream?history=true';
|
||||
|
||||
this.eventSource = new EventSource(url);
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data);
|
||||
this.logs.push(log);
|
||||
|
||||
// Limit log buffer
|
||||
const limit = Alpine.store('settings')?.logLimit || 2000;
|
||||
if (this.logs.length > limit) {
|
||||
this.logs = this.logs.slice(-limit);
|
||||
}
|
||||
|
||||
if (this.isAutoScroll) {
|
||||
this.$nextTick(() => this.scrollToBottom());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Log parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
console.warn('Log stream disconnected, reconnecting...');
|
||||
setTimeout(() => this.startLogStream(), 3000);
|
||||
};
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
const container = document.getElementById('logs-container');
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
}
|
||||
});
|
||||
169
public/js/data-store.js
Normal file
169
public/js/data-store.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Data Store
|
||||
* Holds Accounts, Models, and Computed Quota Rows
|
||||
* Shared between Dashboard and AccountManager
|
||||
*/
|
||||
|
||||
// utils is loaded globally as window.utils in utils.js
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('data', {
|
||||
accounts: [],
|
||||
models: [], // Source of truth
|
||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||
quotaRows: [], // Filtered view
|
||||
loading: false,
|
||||
connectionStatus: 'connecting',
|
||||
lastUpdated: '-',
|
||||
|
||||
// Filters state
|
||||
filters: {
|
||||
account: 'all',
|
||||
family: 'all',
|
||||
search: ''
|
||||
},
|
||||
|
||||
// Settings for calculation
|
||||
// We need to access global settings? Or duplicate?
|
||||
// Let's assume settings are passed or in another store.
|
||||
// For simplicity, let's keep relevant filters here.
|
||||
|
||||
init() {
|
||||
// Watch filters to recompute
|
||||
// Alpine stores don't have $watch automatically unless inside a component?
|
||||
// We can manually call compute when filters change.
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
// Get password from global store
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
const { response, newPassword } = await window.utils.request('/account-limits', {}, password);
|
||||
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
this.accounts = data.accounts || [];
|
||||
if (data.models && data.models.length > 0) {
|
||||
this.models = data.models;
|
||||
}
|
||||
this.modelConfig = data.modelConfig || {};
|
||||
|
||||
this.computeQuotaRows();
|
||||
|
||||
this.connectionStatus = 'connected';
|
||||
this.lastUpdated = new Date().toLocaleTimeString();
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
this.connectionStatus = 'disconnected';
|
||||
Alpine.store('global').showToast('Connection Lost', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
computeQuotaRows() {
|
||||
const models = this.models || [];
|
||||
const rows = [];
|
||||
const showExhausted = Alpine.store('settings')?.showExhausted ?? true; // Need settings store
|
||||
// Temporary debug flag or settings flag to show hidden models
|
||||
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
||||
|
||||
models.forEach(modelId => {
|
||||
// Config
|
||||
const config = this.modelConfig[modelId] || {};
|
||||
const family = this.getModelFamily(modelId);
|
||||
|
||||
// Smart Visibility Logic:
|
||||
// 1. If explicit config exists, use it.
|
||||
// 2. If no config, default 'unknown' families to HIDDEN to prevent clutter.
|
||||
// 3. Known families (Claude/Gemini) default to VISIBLE.
|
||||
let isHidden = config.hidden;
|
||||
if (isHidden === undefined) {
|
||||
isHidden = (family === 'other' || family === 'unknown');
|
||||
}
|
||||
|
||||
// Skip hidden models unless "Show Hidden" is enabled
|
||||
if (isHidden && !showHidden) return;
|
||||
|
||||
// Filters
|
||||
if (this.filters.family !== 'all' && this.filters.family !== family) return;
|
||||
if (this.filters.search) {
|
||||
const searchLower = this.filters.search.toLowerCase();
|
||||
const aliasMatch = config.alias && config.alias.toLowerCase().includes(searchLower);
|
||||
const idMatch = modelId.toLowerCase().includes(searchLower);
|
||||
if (!aliasMatch && !idMatch) return;
|
||||
}
|
||||
|
||||
// Data Collection
|
||||
const quotaInfo = [];
|
||||
let minQuota = 100;
|
||||
let totalQuotaSum = 0;
|
||||
let validAccountCount = 0;
|
||||
let minResetTime = null;
|
||||
|
||||
this.accounts.forEach(acc => {
|
||||
if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
|
||||
|
||||
const limit = acc.limits?.[modelId];
|
||||
if (!limit) return;
|
||||
|
||||
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
||||
minQuota = Math.min(minQuota, pct);
|
||||
|
||||
// Accumulate for average
|
||||
totalQuotaSum += pct;
|
||||
validAccountCount++;
|
||||
|
||||
if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
|
||||
minResetTime = limit.resetTime;
|
||||
}
|
||||
|
||||
quotaInfo.push({
|
||||
email: acc.email.split('@')[0],
|
||||
fullEmail: acc.email,
|
||||
pct: pct,
|
||||
resetTime: limit.resetTime
|
||||
});
|
||||
});
|
||||
|
||||
if (quotaInfo.length === 0) return;
|
||||
const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
|
||||
|
||||
if (!showExhausted && minQuota === 0) return;
|
||||
|
||||
rows.push({
|
||||
modelId,
|
||||
displayName: config.alias || modelId, // Use alias if available
|
||||
family,
|
||||
minQuota,
|
||||
avgQuota, // Added Average Quota
|
||||
minResetTime,
|
||||
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
||||
quotaInfo,
|
||||
pinned: !!config.pinned,
|
||||
hidden: !!isHidden // Use computed visibility
|
||||
});
|
||||
});
|
||||
|
||||
// Sort: Pinned first, then by avgQuota (descending)
|
||||
this.quotaRows = rows.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return b.avgQuota - a.avgQuota;
|
||||
});
|
||||
|
||||
// Trigger Dashboard Update if active
|
||||
// Ideally dashboard watches this store.
|
||||
},
|
||||
|
||||
getModelFamily(modelId) {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.includes('claude')) return 'claude';
|
||||
if (lower.includes('gemini')) return 'gemini';
|
||||
return 'other';
|
||||
}
|
||||
});
|
||||
});
|
||||
57
public/js/settings-store.js
Normal file
57
public/js/settings-store.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Settings Store
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('settings', {
|
||||
refreshInterval: 60,
|
||||
logLimit: 2000,
|
||||
showExhausted: true,
|
||||
showHiddenModels: false, // New field
|
||||
compact: false,
|
||||
port: 8080, // Display only
|
||||
|
||||
init() {
|
||||
this.loadSettings();
|
||||
},
|
||||
|
||||
// Call this method when toggling settings in the UI
|
||||
toggle(key) {
|
||||
if (this.hasOwnProperty(key) && typeof this[key] === 'boolean') {
|
||||
this[key] = !this[key];
|
||||
this.saveSettings(true);
|
||||
}
|
||||
},
|
||||
|
||||
loadSettings() {
|
||||
const saved = localStorage.getItem('antigravity_settings');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
Object.keys(parsed).forEach(k => {
|
||||
// Only load keys that exist in our default state (safety)
|
||||
if (this.hasOwnProperty(k)) this[k] = parsed[k];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings(silent = false) {
|
||||
const toSave = {
|
||||
refreshInterval: this.refreshInterval,
|
||||
logLimit: this.logLimit,
|
||||
showExhausted: this.showExhausted,
|
||||
showHiddenModels: this.showHiddenModels,
|
||||
compact: this.compact
|
||||
};
|
||||
localStorage.setItem('antigravity_settings', JSON.stringify(toSave));
|
||||
|
||||
if (!silent) {
|
||||
Alpine.store('global').showToast('Configuration Saved', 'success');
|
||||
}
|
||||
|
||||
// Trigger updates
|
||||
document.dispatchEvent(new CustomEvent('refresh-interval-changed'));
|
||||
if (Alpine.store('data')) {
|
||||
Alpine.store('data').computeQuotaRows();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
222
public/js/store.js
Normal file
222
public/js/store.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Global Store for Antigravity Console
|
||||
* Handles Translations, Toasts, and Shared Config
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('global', {
|
||||
// App State
|
||||
version: '1.0.0',
|
||||
activeTab: 'dashboard',
|
||||
webuiPassword: localStorage.getItem('antigravity_webui_password') || '',
|
||||
|
||||
// i18n
|
||||
lang: localStorage.getItem('app_lang') || 'en',
|
||||
translations: {
|
||||
en: {
|
||||
dashboard: "Dashboard",
|
||||
accounts: "Accounts",
|
||||
logs: "Logs",
|
||||
settings: "Settings",
|
||||
online: "ONLINE",
|
||||
offline: "OFFLINE",
|
||||
totalAccounts: "TOTAL ACCOUNTS",
|
||||
active: "ACTIVE",
|
||||
operational: "Operational",
|
||||
rateLimited: "RATE LIMITED",
|
||||
cooldown: "Cooldown",
|
||||
searchPlaceholder: "Search models...",
|
||||
allAccounts: "All Accounts",
|
||||
stat: "STAT",
|
||||
modelIdentity: "MODEL IDENTITY",
|
||||
globalQuota: "GLOBAL QUOTA",
|
||||
nextReset: "NEXT RESET",
|
||||
distribution: "ACCOUNT DISTRIBUTION",
|
||||
systemConfig: "System Configuration",
|
||||
language: "Language",
|
||||
pollingInterval: "Polling Interval",
|
||||
logBufferSize: "Log Buffer Size",
|
||||
showExhausted: "Show Exhausted Models",
|
||||
showExhaustedDesc: "Display models even if they have 0% remaining quota.",
|
||||
compactMode: "Compact Mode",
|
||||
compactModeDesc: "Reduce padding in tables for higher information density.",
|
||||
saveChanges: "Save Changes",
|
||||
autoScroll: "Auto-scroll",
|
||||
clearLogs: "Clear Logs",
|
||||
accessCredentials: "Access Credentials",
|
||||
manageTokens: "Manage OAuth tokens and session states",
|
||||
addNode: "Add Node",
|
||||
status: "STATUS",
|
||||
enabled: "ENABLED",
|
||||
health: "HEALTH",
|
||||
identity: "IDENTITY (EMAIL)",
|
||||
projectId: "PROJECT ID",
|
||||
sessionState: "SESSION STATE",
|
||||
operations: "OPERATIONS",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Are you sure you want to remove this account?",
|
||||
connectGoogle: "Connect Google Account",
|
||||
manualReload: "Reload from Disk",
|
||||
// Tabs
|
||||
tabInterface: "Interface",
|
||||
tabClaude: "Claude CLI",
|
||||
tabModels: "Models",
|
||||
tabServer: "Server Info",
|
||||
// Dashboard
|
||||
registeredNodes: "Registered Nodes",
|
||||
noSignal: "NO SIGNAL DETECTED",
|
||||
establishingUplink: "ESTABLISHING UPLINK...",
|
||||
// Settings - Models
|
||||
modelsDesc: "Manage visibility and ordering of models in the dashboard.",
|
||||
showHidden: "Show Hidden Models",
|
||||
modelId: "Model ID",
|
||||
alias: "Alias",
|
||||
actions: "Actions",
|
||||
pinToTop: "Pin to top",
|
||||
toggleVisibility: "Toggle Visibility",
|
||||
noModels: "NO MODELS DETECTED",
|
||||
// Settings - Claude
|
||||
proxyConnection: "Proxy Connection",
|
||||
modelSelection: "Model Selection",
|
||||
aliasOverrides: "ALIAS OVERRIDES",
|
||||
opusAlias: "Opus Alias",
|
||||
sonnetAlias: "Sonnet Alias",
|
||||
haikuAlias: "Haiku Alias",
|
||||
claudeSettingsAlert: "Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply.",
|
||||
writeToConfig: "Write to Config",
|
||||
// Settings - Server
|
||||
port: "Port",
|
||||
uiVersion: "UI Version",
|
||||
debugMode: "Debug Mode",
|
||||
environment: "Environment",
|
||||
serverReadOnly: "Server settings are read-only. Modify config.json or .env and restart the server to change.",
|
||||
dangerZone: "Danger Zone / Advanced",
|
||||
reloadConfigTitle: "Reload Account Config",
|
||||
reloadConfigDesc: "Force reload accounts.json from disk",
|
||||
reload: "Reload",
|
||||
// Config Specific
|
||||
primaryModel: "Primary Model",
|
||||
subAgentModel: "Sub-agent Model",
|
||||
advancedOverrides: "Default Model Overrides",
|
||||
opusModel: "Opus Model",
|
||||
sonnetModel: "Sonnet Model",
|
||||
haikuModel: "Haiku Model",
|
||||
authToken: "Auth Token",
|
||||
saveConfig: "Save to ~/.claude/settings.json",
|
||||
envVar: "Env",
|
||||
},
|
||||
zh: {
|
||||
dashboard: "仪表盘",
|
||||
accounts: "账号管理",
|
||||
logs: "运行日志",
|
||||
settings: "系统设置",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
totalAccounts: "账号总数",
|
||||
active: "活跃状态",
|
||||
operational: "运行中",
|
||||
rateLimited: "受限状态",
|
||||
cooldown: "冷却中",
|
||||
searchPlaceholder: "搜索模型...",
|
||||
allAccounts: "所有账号",
|
||||
stat: "状态",
|
||||
modelIdentity: "模型标识",
|
||||
globalQuota: "全局配额",
|
||||
nextReset: "重置时间",
|
||||
distribution: "账号分布",
|
||||
systemConfig: "系统配置",
|
||||
language: "语言设置",
|
||||
pollingInterval: "数据轮询间隔",
|
||||
logBufferSize: "日志缓冲大小",
|
||||
showExhausted: "显示耗尽模型",
|
||||
showExhaustedDesc: "即使配额为 0% 也显示模型。",
|
||||
compactMode: "紧凑模式",
|
||||
compactModeDesc: "减少表格间距以显示更多信息。",
|
||||
saveChanges: "保存更改",
|
||||
autoScroll: "自动滚动",
|
||||
clearLogs: "清除日志",
|
||||
accessCredentials: "访问凭证",
|
||||
manageTokens: "管理 OAuth 令牌和会话状态",
|
||||
addNode: "添加节点",
|
||||
status: "状态",
|
||||
enabled: "启用",
|
||||
health: "健康度",
|
||||
identity: "身份 (邮箱)",
|
||||
projectId: "项目 ID",
|
||||
sessionState: "会话状态",
|
||||
operations: "操作",
|
||||
delete: "删除",
|
||||
confirmDelete: "确定要移除此账号吗?",
|
||||
connectGoogle: "连接 Google 账号",
|
||||
manualReload: "重新加载配置",
|
||||
// Tabs
|
||||
tabInterface: "界面设置",
|
||||
tabClaude: "Claude CLI",
|
||||
tabModels: "模型管理",
|
||||
tabServer: "服务器信息",
|
||||
// Dashboard
|
||||
registeredNodes: "已注册节点",
|
||||
noSignal: "无信号连接",
|
||||
establishingUplink: "正在建立上行链路...",
|
||||
// Settings - Models
|
||||
modelsDesc: "管理仪表盘中模型的可见性和排序。",
|
||||
showHidden: "显示隐藏模型",
|
||||
modelId: "模型 ID",
|
||||
alias: "别名",
|
||||
actions: "操作",
|
||||
pinToTop: "置顶",
|
||||
toggleVisibility: "切换可见性",
|
||||
noModels: "未检测到模型",
|
||||
// Settings - Claude
|
||||
proxyConnection: "代理连接",
|
||||
modelSelection: "模型选择",
|
||||
aliasOverrides: "别名覆盖",
|
||||
opusAlias: "Opus 别名",
|
||||
sonnetAlias: "Sonnet 别名",
|
||||
haikuAlias: "Haiku 别名",
|
||||
claudeSettingsAlert: "以下设置直接修改 ~/.claude/settings.json。重启 Claude CLI 生效。",
|
||||
writeToConfig: "写入配置",
|
||||
// Settings - Server
|
||||
port: "端口",
|
||||
uiVersion: "UI 版本",
|
||||
debugMode: "调试模式",
|
||||
environment: "运行环境",
|
||||
serverReadOnly: "服务器设置只读。修改 config.json 或 .env 并重启服务器以生效。",
|
||||
dangerZone: "危险区域 / 高级",
|
||||
reloadConfigTitle: "重载账号配置",
|
||||
reloadConfigDesc: "强制从磁盘重新读取 accounts.json",
|
||||
reload: "重载",
|
||||
// Config Specific
|
||||
primaryModel: "主模型",
|
||||
subAgentModel: "子代理模型",
|
||||
advancedOverrides: "默认模型覆盖 (高级)",
|
||||
opusModel: "Opus 模型",
|
||||
sonnetModel: "Sonnet 模型",
|
||||
haikuModel: "Haiku 模型",
|
||||
authToken: "认证令牌",
|
||||
saveConfig: "保存到 ~/.claude/settings.json",
|
||||
envVar: "环境变量",
|
||||
}
|
||||
},
|
||||
|
||||
// Toast Messages
|
||||
toast: null,
|
||||
|
||||
t(key) {
|
||||
return this.translations[this.lang][key] || key;
|
||||
},
|
||||
|
||||
setLang(l) {
|
||||
this.lang = l;
|
||||
localStorage.setItem('app_lang', l);
|
||||
},
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const id = Date.now();
|
||||
this.toast = { message, type, id };
|
||||
setTimeout(() => {
|
||||
if (this.toast && this.toast.id === id) this.toast = null;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
41
public/js/utils.js
Normal file
41
public/js/utils.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Utility functions for Antigravity Console
|
||||
*/
|
||||
|
||||
window.utils = {
|
||||
// Shared Request Wrapper
|
||||
async request(url, options = {}, webuiPassword = '') {
|
||||
options.headers = options.headers || {};
|
||||
if (webuiPassword) {
|
||||
options.headers['x-webui-password'] = webuiPassword;
|
||||
}
|
||||
|
||||
let response = await fetch(url, options);
|
||||
|
||||
if (response.status === 401) {
|
||||
const password = prompt('Enter Web UI Password:');
|
||||
if (password) {
|
||||
// Return new password so caller can update state
|
||||
// This implies we need a way to propagate the new password back
|
||||
// For simplicity in this functional utility, we might need a callback or state access
|
||||
// But generally utils shouldn't probably depend on global state directly if possible
|
||||
// let's stick to the current logic but wrapped
|
||||
localStorage.setItem('antigravity_webui_password', password);
|
||||
options.headers['x-webui-password'] = password;
|
||||
response = await fetch(url, options);
|
||||
return { response, newPassword: password };
|
||||
}
|
||||
}
|
||||
|
||||
return { response, newPassword: null };
|
||||
},
|
||||
|
||||
formatTimeUntil(isoTime) {
|
||||
const diff = new Date(isoTime) - new Date();
|
||||
if (diff <= 0) return 'READY';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) return `${hrs}H ${mins % 60}M`;
|
||||
return `${mins}M`;
|
||||
}
|
||||
};
|
||||
80
public/views/accounts.html
Normal file
80
public/views/accounts.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<div x-data="accountManager" class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accessCredentials')">
|
||||
Access
|
||||
Credentials</h2>
|
||||
<p class="text-gray-500 text-sm" x-text="$store.global.t('manageTokens')">Manage OAuth tokens
|
||||
and session
|
||||
states</p>
|
||||
</div>
|
||||
<button class="btn btn-sm bg-neon-purple hover:bg-purple-600 text-white border-none gap-2"
|
||||
onclick="document.getElementById('add_account_modal').showModal()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('addNode')">Add Node</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel rounded-xl overflow-hidden">
|
||||
<table class="table w-full">
|
||||
<thead class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border">
|
||||
<tr>
|
||||
<th class="pl-6 w-24" x-text="$store.global.t('enabled')">Enabled</th>
|
||||
<th x-text="$store.global.t('identity')">Identity (Email)</th>
|
||||
<th x-text="$store.global.t('projectId')">Project ID</th>
|
||||
<th class="w-32" x-text="$store.global.t('health')">Health</th>
|
||||
<th class="text-right pr-6" x-text="$store.global.t('operations')">Operations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-space-border/50">
|
||||
<template x-for="acc in $store.data.accounts" :key="acc.email">
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="pl-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer" :checked="acc.enabled !== false"
|
||||
@change="toggleAccount(acc.email, $el.checked)">
|
||||
<div
|
||||
class="w-9 h-5 bg-space-800 border border-space-border peer-focus:outline-none peer-focus:ring-1 peer-focus:ring-neon-purple rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-gray-400 after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green/20 peer-checked:border-neon-green peer-checked:after:bg-neon-green peer-checked:after:shadow-[0_0_8px_rgba(34,197,94,0.8)]">
|
||||
</div>
|
||||
</label>
|
||||
</td>
|
||||
<td class="font-medium text-gray-200" x-text="acc.email"></td>
|
||||
<td class="font-mono text-xs text-gray-400" x-text="acc.projectId || '-'"></td>
|
||||
<td>
|
||||
<span class="badge badge-sm border-0 font-mono"
|
||||
:class="acc.status === 'ok' ? 'bg-neon-green/10 text-neon-green' : 'bg-red-500/10 text-red-500'"
|
||||
x-text="acc.status.toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="text-right pr-6">
|
||||
<div class="flex justify-end gap-1">
|
||||
<!-- Fix Button -->
|
||||
<button x-show="acc.status === 'invalid'"
|
||||
class="btn btn-xs bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20 border-none mr-1 px-2 font-mono"
|
||||
@click="fixAccount(acc.email)">
|
||||
FIX
|
||||
</button>
|
||||
<button class="btn btn-xs btn-square btn-ghost text-gray-400 hover:text-white"
|
||||
@click="refreshAccount(acc.email)" title="Refresh Token">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-square btn-ghost text-red-400 hover:text-red-500 hover:bg-red-500/10"
|
||||
@click="deleteAccount(acc.email)" :title="$store.global.t('delete')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
221
public/views/dashboard.html
Normal file
221
public/views/dashboard.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<div x-data="dashboard" class="space-y-6 animate-fade-in">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
|
||||
<div class="stat-figure text-neon-cyan group-hover:scale-110 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
class="inline-block w-8 h-8 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
|
||||
x-text="$store.global.t('totalAccounts')"></div>
|
||||
<div class="stat-value text-white font-mono text-3xl" x-text="stats.total"></div>
|
||||
<div class="stat-desc text-gray-600 text-[10px] mt-1">Registered Nodes</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
|
||||
<div class="stat-figure text-neon-green group-hover:scale-110 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
class="inline-block w-8 h-8 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
|
||||
x-text="$store.global.t('active')"></div>
|
||||
<div class="stat-value text-white font-mono text-3xl" x-text="stats.active"></div>
|
||||
<div class="stat-desc text-neon-green/60 text-[10px] mt-1" x-text="$store.global.t('operational')"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-6 hover:border-space-border/60 transition-colors group">
|
||||
<div class="stat-figure text-red-500 group-hover:scale-110 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
class="inline-block w-8 h-8 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider"
|
||||
x-text="$store.global.t('rateLimited')"></div>
|
||||
<div class="stat-value text-white font-mono text-3xl" x-text="stats.limited"></div>
|
||||
<div class="stat-desc text-red-500/60 text-[10px] mt-1" x-text="$store.global.t('cooldown')"></div>
|
||||
</div>
|
||||
|
||||
<!-- Global Quota Chart -->
|
||||
<div
|
||||
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 col-span-1 md:col-start-4 md:row-start-1 h-full flex items-center justify-between gap-4 overflow-hidden relative group hover:border-space-border/60 transition-colors">
|
||||
<!-- Chart Container -->
|
||||
<div class="h-20 w-20 relative flex-shrink-0">
|
||||
<canvas id="quotaChart"></canvas>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend / Info -->
|
||||
<div class="flex flex-col justify-center gap-2 flex-grow min-w-0 z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono">Global Quota</span>
|
||||
<!-- <span class="text-xs font-bold text-neon-purple" x-text="stats.overallHealth + '%'"></span> -->
|
||||
</div>
|
||||
|
||||
<!-- Custom Legend -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-neon-purple shadow-[0_0_4px_rgba(168,85,247,0.4)]"></div>
|
||||
<span>Claude</span>
|
||||
</div>
|
||||
<!-- <span class="font-mono text-gray-600">--</span> -->
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-neon-green shadow-[0_0_4px_rgba(34,197,94,0.4)]"></div>
|
||||
<span>Gemini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Glow -->
|
||||
<div class="absolute -right-6 -top-6 w-24 h-24 bg-neon-purple/5 rounded-full blur-2xl pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center justify-between gap-4 glass-panel p-2 rounded-lg h-16">
|
||||
<div class="flex items-center gap-4 h-full">
|
||||
<!-- Custom Select -->
|
||||
<div class="relative w-64 h-10">
|
||||
<select
|
||||
class="appearance-none w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-4 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all truncate text-sm"
|
||||
x-model="$store.data.filters.account" @change="$store.data.computeQuotaRows()">
|
||||
<option value="all" x-text="$store.global.t('allAccounts')">All Accounts</option>
|
||||
<template x-for="acc in $store.data.accounts" :key="acc.email">
|
||||
<option :value="acc.email" x-text="acc.email.split('@')[0]"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-500">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="join h-10">
|
||||
<button
|
||||
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
|
||||
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'all'}"
|
||||
@click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()">ALL</button>
|
||||
<button
|
||||
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
|
||||
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'claude'}"
|
||||
@click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()">CLAUDE</button>
|
||||
<button
|
||||
class="join-item btn btn-sm h-full px-6 border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium text-xs tracking-wide"
|
||||
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'gemini'}"
|
||||
@click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()">GEMINI</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative w-72 h-10">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
|
||||
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-4 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-sm placeholder-gray-600"
|
||||
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Table -->
|
||||
<div class="glass-panel rounded-xl overflow-hidden min-h-[400px]">
|
||||
<table class="table w-full" :class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
|
||||
<thead
|
||||
class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase tracking-wider border-b border-space-border">
|
||||
<tr>
|
||||
<th class="w-8 py-3 pl-4" x-text="$store.global.t('stat')">Stat</th>
|
||||
<th class="py-3" x-text="$store.global.t('modelIdentity')">Model Identity</th>
|
||||
<th class="w-48 py-3" x-text="$store.global.t('globalQuota')">Global Quota</th>
|
||||
<th class="w-32 py-3" x-text="$store.global.t('nextReset')">Next Reset</th>
|
||||
<th class="py-3 pr-4 text-right" x-text="$store.global.t('distribution')">Account
|
||||
Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-space-border/50 text-sm">
|
||||
<template x-for="row in $store.data.quotaRows" :key="row.modelId">
|
||||
<tr class="hover:bg-white/5 transition-colors group">
|
||||
<td class="pl-4">
|
||||
<div class="w-2 h-2 rounded-full transition-all duration-500"
|
||||
:class="row.avgQuota > 0 ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-bold text-gray-200 group-hover:text-neon-purple transition-colors"
|
||||
x-text="row.modelId"></div>
|
||||
<div class="text-[10px] font-mono text-gray-500 uppercase" x-text="row.family">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-col gap-1 pr-4">
|
||||
<div class="flex justify-between text-xs font-mono">
|
||||
<span x-text="row.avgQuota + '%'"
|
||||
:class="row.avgQuota > 0 ? 'text-white' : 'text-red-500'"></span>
|
||||
</div>
|
||||
<progress class="progress w-full h-1.5 bg-space-800"
|
||||
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
|
||||
:value="row.avgQuota" max="100"></progress>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono text-xs">
|
||||
<span x-text="row.resetIn" :class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-3 pr-4">
|
||||
<div class="text-[10px] font-mono text-gray-500 hidden xl:block text-right leading-tight opacity-70">
|
||||
<div x-text="row.quotaInfo.filter(q => q.pct > 0).length + ' Active'"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 justify-end max-w-[150px]">
|
||||
<template x-for="q in row.quotaInfo" :key="q.fullEmail">
|
||||
<div class="tooltip tooltip-left" :data-tip="q.fullEmail + ' (' + q.pct + '%)'">
|
||||
<div class="w-1.5 h-3 rounded-[1px] transition-all hover:scale-125 cursor-help"
|
||||
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Loading -->
|
||||
<tr x-show="$store.data.loading && !$store.data.quotaRows.length">
|
||||
<td colspan="5" class="h-64 text-center">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<span class="loading loading-bars loading-md text-neon-purple"></span>
|
||||
<span class="text-xs font-mono text-gray-600 animate-pulse">ESTABLISHING
|
||||
UPLINK...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Empty -->
|
||||
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0">
|
||||
<td colspan="5" class="h-64 text-center text-gray-600 font-mono text-xs">
|
||||
NO SIGNAL DETECTED
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
85
public/views/logs.html
Normal file
85
public/views/logs.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<div x-data="logsViewer" class="h-full flex flex-col glass-panel rounded-xl overflow-hidden border-space-border">
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px]">
|
||||
|
||||
<!-- Left: Decor & Title -->
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<div class="flex gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500/50"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500/20 border border-green-500/50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-gray-500 hidden sm:inline-block">~/logs</span>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search & Filters -->
|
||||
<div class="flex-1 flex items-center justify-center gap-4 px-4 min-w-0">
|
||||
<!-- Search -->
|
||||
<div class="relative w-full max-w-xs group">
|
||||
<div class="absolute inset-y-0 left-0 pl-2 flex items-center pointer-events-none">
|
||||
<svg class="h-3 w-3 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" x-model="searchQuery" placeholder="grep logs..."
|
||||
class="w-full h-7 bg-space-950 border border-space-border rounded text-xs font-mono pl-7 pr-2 focus:border-neon-purple focus:outline-none transition-colors placeholder-gray-700 text-gray-300">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="hidden md:flex gap-3 text-[10px] font-mono font-bold uppercase select-none">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-blue-400 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.INFO}">
|
||||
<input type="checkbox" class="checkbox checkbox-xs checkbox-info rounded-[2px] w-3 h-3 border-blue-400/50" x-model="filters.INFO"> INFO
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-neon-green opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.SUCCESS}">
|
||||
<input type="checkbox" class="checkbox checkbox-xs checkbox-success rounded-[2px] w-3 h-3 border-neon-green/50" x-model="filters.SUCCESS"> SUCCESS
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-yellow-400 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.WARN}">
|
||||
<input type="checkbox" class="checkbox checkbox-xs checkbox-warning rounded-[2px] w-3 h-3 border-yellow-400/50" x-model="filters.WARN"> WARN
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-red-500 opacity-50 hover:opacity-100 transition-opacity" :class="{'opacity-100': filters.ERROR}">
|
||||
<input type="checkbox" class="checkbox checkbox-xs checkbox-error rounded-[2px] w-3 h-3 border-red-500/50" x-model="filters.ERROR"> ERR
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<div class="text-[10px] font-mono text-gray-600 hidden lg:block">
|
||||
<span x-text="filteredLogs.length"></span>/<span x-text="logs.length"></span>
|
||||
</div>
|
||||
<label class="cursor-pointer flex items-center gap-2" title="Auto-scroll to bottom">
|
||||
<span class="text-[10px] font-mono text-gray-500 uppercase hidden sm:inline-block"
|
||||
x-text="$store.global.t('autoScroll')">Auto-Scroll</span>
|
||||
<input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll">
|
||||
</label>
|
||||
<button class="btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white" @click="clearLogs" :title="$store.global.t('clearLogs')">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Content -->
|
||||
<div id="logs-container" class="flex-1 overflow-auto p-4 font-mono text-xs space-y-0.5 bg-[#050505]">
|
||||
<template x-for="(log, idx) in filteredLogs" :key="idx">
|
||||
<div class="hover:bg-white/5 rounded px-2 py-0.5 -mx-2 break-words leading-tight flex gap-3 group">
|
||||
<span class="text-gray-600 w-16 shrink-0 select-none group-hover:text-gray-500"
|
||||
x-text="new Date(log.timestamp).toLocaleTimeString([], {hour12:false})"></span>
|
||||
<span class="font-bold w-12 shrink-0 select-none" :class="{
|
||||
'text-blue-400': log.level === 'INFO',
|
||||
'text-yellow-400': log.level === 'WARN',
|
||||
'text-red-500': log.level === 'ERROR',
|
||||
'text-neon-green': log.level === 'SUCCESS',
|
||||
'text-purple-400': log.level === 'DEBUG'
|
||||
}" x-text="log.level"></span>
|
||||
<span class="text-gray-300 break-all" x-html="log.message.replace(/\n/g, '<br>')"></span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Blinking Cursor -->
|
||||
<div class="h-4 w-2 bg-gray-500 animate-pulse mt-1 inline-block" x-show="filteredLogs.length === logs.length && !searchQuery"></div>
|
||||
<div x-show="filteredLogs.length === 0 && logs.length > 0" class="text-gray-600 italic mt-4 text-center">
|
||||
No logs match filter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
519
public/views/settings.html
Normal file
519
public/views/settings.html
Normal file
@@ -0,0 +1,519 @@
|
||||
<div x-data="{ activeTab: 'ui' }" class="glass-panel rounded-xl border border-space-border flex flex-col h-[calc(100vh-140px)] overflow-hidden">
|
||||
<!-- Header & Tabs -->
|
||||
<div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-neon-purple" 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('systemConfig')">System Configuration</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<button @click="activeTab = 'ui'"
|
||||
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
|
||||
:class="activeTab === 'ui' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
|
||||
<span x-text="$store.global.t('tabInterface')">Interface</span>
|
||||
</button>
|
||||
<button @click="activeTab = 'claude'"
|
||||
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
|
||||
:class="activeTab === 'claude' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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('tabClaude')">Claude CLI</span>
|
||||
</button>
|
||||
<button @click="activeTab = 'models'"
|
||||
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
|
||||
:class="activeTab === 'models' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
<span x-text="$store.global.t('tabModels')">Models</span>
|
||||
</button>
|
||||
<button @click="activeTab = 'server'"
|
||||
class="pb-3 border-b-2 transition-colors font-medium text-sm flex items-center gap-2"
|
||||
:class="activeTab === 'server' ? 'border-neon-purple text-white' : 'border-transparent text-gray-500 hover:text-gray-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
|
||||
<span x-text="$store.global.t('tabServer')">Server Info</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="p-8 overflow-y-auto flex-1 custom-scrollbar">
|
||||
|
||||
<!-- Tab 1: UI Preferences -->
|
||||
<div x-show="activeTab === 'ui'" class="space-y-8 max-w-2xl animate-fade-in">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Language -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-gray-300" x-text="$store.global.t('language')">Language</span>
|
||||
</label>
|
||||
<div class="join w-full grid grid-cols-2">
|
||||
<button
|
||||
class="join-item btn btn-sm border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium"
|
||||
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.global.lang === 'en'}"
|
||||
@click="$store.global.setLang('en')">English</button>
|
||||
<button
|
||||
class="join-item btn btn-sm border-space-border bg-space-800 text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-600 transition-all font-medium"
|
||||
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.global.lang === 'zh'}"
|
||||
@click="$store.global.setLang('zh')">中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polling Interval -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-gray-300" x-text="$store.global.t('pollingInterval')">Polling
|
||||
Interval</span>
|
||||
<span class="label-text-alt font-mono text-neon-purple"
|
||||
x-text="$store.settings.refreshInterval + 's'"></span>
|
||||
</label>
|
||||
<input type="range" min="10" max="300" class="range range-xs range-primary"
|
||||
x-model="$store.settings.refreshInterval"
|
||||
@change="$store.settings.saveSettings(true)"
|
||||
style="--range-shdw: #a855f7">
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
|
||||
<span>10s</span>
|
||||
<span>300s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Buffer -->
|
||||
<div class="form-control col-span-full">
|
||||
<label class="label">
|
||||
<span class="label-text text-gray-300" x-text="$store.global.t('logBufferSize')">Log Buffer Size</span>
|
||||
<span class="label-text-alt font-mono text-neon-purple"
|
||||
x-text="$store.settings.logLimit + ' lines'"></span>
|
||||
</label>
|
||||
<input type="range" min="500" max="5000" step="500" class="range range-xs range-secondary"
|
||||
x-model="$store.settings.logLimit"
|
||||
@change="$store.settings.saveSettings(true)"
|
||||
style="--range-shdw: #22c55e">
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
|
||||
<span>500</span>
|
||||
<span>5000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider border-space-border/50"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="form-control bg-space-900/50 p-4 rounded-lg border transition-all duration-300 hover:border-space-border cursor-pointer group"
|
||||
:class="$store.settings.showExhausted ? 'border-neon-purple/50 bg-neon-purple/5 shadow-[0_0_15px_rgba(168,85,247,0.1)]' : 'border-space-border/50'"
|
||||
@click="$store.settings.showExhausted = !$store.settings.showExhausted; $store.settings.saveSettings(true)">
|
||||
<label class="label cursor-pointer pointer-events-none"> <!-- pointer-events-none to prevent double toggle -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="label-text font-medium transition-colors"
|
||||
:class="$store.settings.showExhausted ? 'text-neon-purple' : 'text-gray-300'"
|
||||
x-text="$store.global.t('showExhausted')">Show Exhausted Models</span>
|
||||
<span class="text-xs text-gray-500" x-text="$store.global.t('showExhaustedDesc')">Display models even if they have 0% remaining quota.</span>
|
||||
</div>
|
||||
<input type="checkbox" class="toggle toggle-primary" x-model="$store.settings.showExhausted">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-control bg-space-900/50 p-4 rounded-lg border transition-all duration-300 hover:border-space-border cursor-pointer group"
|
||||
:class="$store.settings.compact ? 'border-neon-green/50 bg-neon-green/5 shadow-[0_0_15px_rgba(34,197,94,0.1)]' : 'border-space-border/50'"
|
||||
@click="$store.settings.compact = !$store.settings.compact; $store.settings.saveSettings(true)">
|
||||
<label class="label cursor-pointer pointer-events-none">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="label-text font-medium transition-colors"
|
||||
:class="$store.settings.compact ? 'text-neon-green' : 'text-gray-300'"
|
||||
x-text="$store.global.t('compactMode')">Compact Mode</span>
|
||||
<span class="text-xs text-gray-500" x-text="$store.global.t('compactModeDesc')">Reduce padding in tables for higher information density.</span>
|
||||
</div>
|
||||
<input type="checkbox" class="toggle toggle-secondary" x-model="$store.settings.compact">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save button removed (Auto-save enabled) -->
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Claude CLI Configuration -->
|
||||
<div x-show="activeTab === 'claude'" x-data="claudeConfig" class="space-y-6 max-w-3xl animate-fade-in">
|
||||
<div class="alert bg-space-900/50 border-space-border text-sm shadow-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span class="text-gray-400">Settings below directly modify <code class="text-neon-cyan font-mono">~/.claude/settings.json</code>. Restart Claude CLI to apply.</span>
|
||||
</div>
|
||||
|
||||
<!-- Base URL -->
|
||||
<div class="card bg-space-900/30 border border-space-border/50 p-5">
|
||||
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2">Proxy Connection</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-400 mb-1 font-mono">ANTHROPIC_BASE_URL</div>
|
||||
<input type="text" x-model="config.env.ANTHROPIC_BASE_URL" placeholder="http://localhost:8080"
|
||||
class="input input-sm input-bordered bg-space-800/50 w-full font-mono text-sm border-space-border focus:border-neon-purple focus:bg-space-800 text-gray-200">
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] text-gray-400 mb-1 font-mono">ANTHROPIC_AUTH_TOKEN</div>
|
||||
<input type="password" x-model="config.env.ANTHROPIC_AUTH_TOKEN" placeholder="any-string"
|
||||
class="input input-sm input-bordered bg-space-800/50 w-full font-mono text-sm border-space-border focus:border-neon-purple focus:bg-space-800 text-gray-200">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models Selection -->
|
||||
<div class="card bg-space-900/30 border border-space-border/50 p-5">
|
||||
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2">Model Selection</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Primary -->
|
||||
<div class="form-control">
|
||||
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider">Primary Model</label>
|
||||
<div class="relative w-full" x-data="{ open: false }">
|
||||
<input type="text" x-model="config.env.ANTHROPIC_MODEL"
|
||||
@focus="open = true"
|
||||
@click.away="open = false"
|
||||
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-white focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
|
||||
placeholder="Type to search or select...">
|
||||
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent>▼</div>
|
||||
|
||||
<ul x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || ''))" :key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="config.env.ANTHROPIC_MODEL = modelId; open = false"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 hover:text-neon-cyan border-b border-space-border/30 last:border-0"
|
||||
:class="config.env.ANTHROPIC_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<span x-text="modelId"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || '')).length === 0">
|
||||
<span class="text-xs text-gray-500 italic py-2">No matching models</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-600 mt-1 font-mono">ANTHROPIC_MODEL</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-agent -->
|
||||
<div class="form-control">
|
||||
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider">Sub-agent Model</label>
|
||||
<div class="relative w-full" x-data="{ open: false }">
|
||||
<input type="text" x-model="config.env.CLAUDE_CODE_SUBAGENT_MODEL"
|
||||
@focus="open = true"
|
||||
@click.away="open = false"
|
||||
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-white focus:bg-space-800 focus:border-neon-purple pr-8 placeholder-gray-600"
|
||||
placeholder="Type to search or select...">
|
||||
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent>▼</div>
|
||||
|
||||
<ul x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || ''))" :key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="config.env.CLAUDE_CODE_SUBAGENT_MODEL = modelId; open = false"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 hover:text-neon-purple border-b border-space-border/30 last:border-0"
|
||||
:class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId ? 'text-neon-purple bg-space-800/50' : 'text-gray-300'">
|
||||
<span x-text="modelId"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || '')).length === 0">
|
||||
<span class="text-xs text-gray-500 italic py-2">No matching models</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-600 mt-1 font-mono">CLAUDE_CODE_SUBAGENT_MODEL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs font-mono text-gray-600 my-2">ALIAS OVERRIDES</div>
|
||||
|
||||
<!-- Overrides -->
|
||||
<div class="space-y-4">
|
||||
<!-- Opus -->
|
||||
<div class="form-control">
|
||||
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Opus Alias</label>
|
||||
<div class="relative w-full" x-data="{ open: false }">
|
||||
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
|
||||
@focus="open = true"
|
||||
@click.away="open = false"
|
||||
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
|
||||
placeholder="Search...">
|
||||
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent>▼</div>
|
||||
<ul x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.toLowerCase() || ''))" :key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelId; open = false"
|
||||
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<span x-text="modelId"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sonnet -->
|
||||
<div class="form-control">
|
||||
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Sonnet Alias</label>
|
||||
<div class="relative w-full" x-data="{ open: false }">
|
||||
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
@focus="open = true"
|
||||
@click.away="open = false"
|
||||
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
|
||||
placeholder="Search...">
|
||||
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent>▼</div>
|
||||
<ul x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.toLowerCase() || ''))" :key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelId; open = false"
|
||||
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<span x-text="modelId"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Haiku -->
|
||||
<div class="form-control">
|
||||
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold">Haiku Alias</label>
|
||||
<div class="relative w-full" x-data="{ open: false }">
|
||||
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
@focus="open = true"
|
||||
@click.away="open = false"
|
||||
class="input input-sm w-full font-mono text-xs bg-space-800/50 border-space-border text-gray-300 focus:bg-space-800 focus:border-neon-cyan pr-8 placeholder-gray-600"
|
||||
placeholder="Search...">
|
||||
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" @click="open = !open; if(open) $el.previousElementSibling.focus()" @mousedown.prevent>▼</div>
|
||||
<ul x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.toLowerCase() || ''))" :key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelId; open = false"
|
||||
class="font-mono text-xs py-1 hover:bg-space-800 hover:text-white border-b border-space-border/30 last:border-0"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<span x-text="modelId"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button class="btn btn-sm bg-neon-purple hover:bg-purple-600 border-none text-white px-6 gap-2"
|
||||
@click="saveClaudeConfig" :disabled="loading">
|
||||
<svg x-show="!loading" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /></svg>
|
||||
<span x-show="!loading">Write to Config</span>
|
||||
<span x-show="loading" class="loading loading-spinner loading-xs"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2.5: Models Configuration -->
|
||||
<div x-show="activeTab === 'models'" class="space-y-6 max-w-3xl animate-fade-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-400">Manage visibility and ordering of models in the dashboard.</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<span class="label-text text-xs text-gray-500">Show Hidden Models</span>
|
||||
<input type="checkbox" class="toggle toggle-xs toggle-primary" x-model="$store.settings.showHiddenModels" @change="$store.settings.saveSettings(true)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models List -->
|
||||
<div class="bg-space-900/30 border border-space-border/50 rounded-lg overflow-hidden">
|
||||
<table class="table table-sm w-full">
|
||||
<thead class="bg-space-900/50 text-gray-500 font-mono text-xs uppercase tracking-wider border-b border-space-border/50">
|
||||
<tr>
|
||||
<th class="pl-4">Model ID</th>
|
||||
<th>Alias</th>
|
||||
<th class="text-right pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-space-border/30">
|
||||
<template x-for="modelId in $store.data.models" :key="modelId">
|
||||
<tr class="hover:bg-white/5 transition-colors group"
|
||||
x-data="{
|
||||
isEditing: false,
|
||||
newAlias: '',
|
||||
// Use getters for reactivity - accessing store directly ensures updates are reflected immediately
|
||||
get config() { return $store.data.modelConfig[modelId] || {} },
|
||||
get isPinned() { return !!this.config.pinned },
|
||||
get isHidden() {
|
||||
if (this.config.hidden !== undefined) return this.config.hidden;
|
||||
// Smart default: unknown models are hidden by default
|
||||
const family = $store.data.getModelFamily(modelId);
|
||||
return (family === 'other' || family === 'unknown');
|
||||
}
|
||||
}"
|
||||
x-init="newAlias = config.alias || ''"
|
||||
>
|
||||
<td class="pl-4 font-mono text-xs text-gray-300" x-text="modelId"></td>
|
||||
<td>
|
||||
<div x-show="!isEditing" class="flex items-center gap-2 group-hover:text-white transition-colors cursor-pointer" @click="isEditing = true; newAlias = config.alias || ''">
|
||||
<span x-text="config.alias || '-'" :class="{'text-gray-600 italic': !config.alias}"></span>
|
||||
<svg class="w-3 h-3 text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</div>
|
||||
<div x-show="isEditing" class="flex items-center gap-1">
|
||||
<input type="text" x-model="newAlias"
|
||||
class="input input-xs bg-space-800 border-space-border text-white focus:outline-none focus:border-neon-purple w-32"
|
||||
@keydown.enter="
|
||||
$store.data.modelConfig[modelId] = { ...config, alias: newAlias };
|
||||
fetch('/api/models/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ modelId, config: { alias: newAlias } })
|
||||
});
|
||||
isEditing = false;
|
||||
$store.data.computeQuotaRows();
|
||||
"
|
||||
@keydown.escape="isEditing = false"
|
||||
>
|
||||
<button class="btn btn-xs btn-ghost btn-square text-green-500" @click="
|
||||
$store.data.modelConfig[modelId] = { ...config, alias: newAlias };
|
||||
fetch('/api/models/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ modelId, config: { alias: newAlias } })
|
||||
});
|
||||
isEditing = false;
|
||||
$store.data.computeQuotaRows();
|
||||
"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg></button>
|
||||
<button class="btn btn-xs btn-ghost btn-square text-gray-500" @click="isEditing = false"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right pr-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<!-- Pin Toggle -->
|
||||
<button class="btn btn-xs btn-circle transition-colors"
|
||||
:class="isPinned ? 'bg-neon-purple/20 text-neon-purple border-neon-purple/50 hover:bg-neon-purple/30' : 'btn-ghost text-gray-600 hover:text-gray-300'"
|
||||
@click="
|
||||
const newVal = !isPinned;
|
||||
// Optimistic update
|
||||
$store.data.modelConfig[modelId] = { ...config, pinned: newVal };
|
||||
fetch('/api/models/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ modelId, config: { pinned: newVal } })
|
||||
});
|
||||
$store.data.computeQuotaRows();
|
||||
"
|
||||
title="Pin to top">
|
||||
<!-- Solid Icon when Pinned -->
|
||||
<svg x-show="isPinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
|
||||
</svg>
|
||||
<!-- Outline Icon when Unpinned -->
|
||||
<svg x-show="!isPinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hide Toggle -->
|
||||
<button class="btn btn-xs btn-circle transition-colors"
|
||||
:class="isHidden ? 'bg-red-500/20 text-red-400 border-red-500/50 hover:bg-red-500/30' : 'btn-ghost text-gray-400 hover:text-white'"
|
||||
@click="
|
||||
const currentHidden = isHidden;
|
||||
const newVal = !currentHidden;
|
||||
// Optimistic update
|
||||
$store.data.modelConfig[modelId] = { ...config, hidden: newVal };
|
||||
fetch('/api/models/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ modelId, config: { hidden: newVal } })
|
||||
});
|
||||
$store.data.computeQuotaRows();
|
||||
"
|
||||
title="Toggle Visibility">
|
||||
<svg x-show="!isHidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg x-show="isHidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<tr x-show="!$store.data.models.length">
|
||||
<td colspan="3" class="text-center py-8 text-gray-600 text-xs font-mono">
|
||||
NO MODELS DETECTED
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Server Info -->
|
||||
<div x-show="activeTab === 'server'" class="space-y-6 max-w-2xl animate-fade-in">
|
||||
<div class="grid grid-cols-2 gap-4 bg-space-900/30 p-6 rounded-lg border border-space-border/50">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">Port</div>
|
||||
<div class="text-sm font-mono text-neon-cyan" x-text="$store.settings.port || '-'"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">UI Version</div>
|
||||
<div class="text-sm font-mono text-neon-cyan" x-text="$store.global.version"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">Debug Mode</div>
|
||||
<div class="text-sm text-gray-300">Enabled (See Logs)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">Environment</div>
|
||||
<div class="text-sm font-mono text-gray-300">Production</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert bg-blue-500/10 border-blue-500/20 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-blue-400 shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<div class="text-blue-200">
|
||||
Server settings are read-only. Modify <code class="font-mono bg-blue-500/20 px-1 rounded">config.json</code> or <code class="font-mono bg-blue-500/20 px-1 rounded">.env</code> and restart the server to change.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Reload -->
|
||||
<div class="pt-6 border-t border-space-border/30">
|
||||
<h4 class="text-sm font-bold text-white mb-4">Danger Zone / Advanced</h4>
|
||||
<div class="flex items-center justify-between p-4 border border-space-border/50 rounded-lg bg-space-900/20">
|
||||
<div>
|
||||
<div class="font-medium text-gray-300 text-sm">Reload Account Config</div>
|
||||
<div class="text-xs text-gray-500">Force reload accounts.json from disk</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline border-space-border hover:bg-white hover:text-black hover:border-white text-gray-400"
|
||||
@click="Alpine.store('global').showToast('Reloading...', 'info'); fetch('/api/accounts/reload', {method: 'POST'}).then(() => Alpine.store('global').showToast('Reloaded', 'success'))">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user