style(webui): refine UI polish and enhance component interactions

This commit is contained in:
Wha1eChai
2026-01-09 07:54:50 +08:00
parent 40a766ded6
commit e909ea6fe3
13 changed files with 808 additions and 253 deletions

View File

@@ -114,16 +114,47 @@ document.addEventListener('alpine:init', () => {
Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info'); Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info');
// Open OAuth window // Open OAuth window
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes'); const oauthWindow = window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
// Poll for account changes instead of relying on postMessage // Poll for account changes instead of relying on postMessage
// (since OAuth callback is now on port 51121, not this server) // (since OAuth callback is now on port 51121, not this server)
const initialAccountCount = Alpine.store('data').accounts.length; const initialAccountCount = Alpine.store('data').accounts.length;
let pollCount = 0; let pollCount = 0;
const maxPolls = 60; // 2 minutes (2 second intervals) const maxPolls = 60; // 2 minutes (2 second intervals)
let cancelled = false;
// Show progress modal
Alpine.store('global').oauthProgress = {
active: true,
current: 0,
max: maxPolls,
cancel: () => {
cancelled = true;
clearInterval(pollInterval);
Alpine.store('global').oauthProgress.active = false;
Alpine.store('global').showToast(Alpine.store('global').t('oauthCancelled'), 'info');
if (oauthWindow && !oauthWindow.closed) {
oauthWindow.close();
}
}
};
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
if (cancelled) {
clearInterval(pollInterval);
return;
}
pollCount++; pollCount++;
Alpine.store('global').oauthProgress.current = pollCount;
// Check if OAuth window was closed manually
if (oauthWindow && oauthWindow.closed && !cancelled) {
clearInterval(pollInterval);
Alpine.store('global').oauthProgress.active = false;
Alpine.store('global').showToast(Alpine.store('global').t('oauthWindowClosed'), 'warning');
return;
}
// Refresh account list // Refresh account list
await Alpine.store('data').fetchData(); await Alpine.store('data').fetchData();
@@ -132,6 +163,8 @@ document.addEventListener('alpine:init', () => {
const currentAccountCount = Alpine.store('data').accounts.length; const currentAccountCount = Alpine.store('data').accounts.length;
if (currentAccountCount > initialAccountCount) { if (currentAccountCount > initialAccountCount) {
clearInterval(pollInterval); clearInterval(pollInterval);
Alpine.store('global').oauthProgress.active = false;
const actionKey = reAuthEmail ? 'reauthenticated' : 'added'; const actionKey = reAuthEmail ? 'reauthenticated' : 'added';
const action = Alpine.store('global').t(actionKey); const action = Alpine.store('global').t(actionKey);
const successfully = Alpine.store('global').t('successfully'); const successfully = Alpine.store('global').t('successfully');
@@ -140,11 +173,20 @@ document.addEventListener('alpine:init', () => {
'success' 'success'
); );
document.getElementById('add_account_modal')?.close(); document.getElementById('add_account_modal')?.close();
if (oauthWindow && !oauthWindow.closed) {
oauthWindow.close();
}
} }
// Stop polling after max attempts // Stop polling after max attempts
if (pollCount >= maxPolls) { if (pollCount >= maxPolls) {
clearInterval(pollInterval); clearInterval(pollInterval);
Alpine.store('global').oauthProgress.active = false;
Alpine.store('global').showToast(
Alpine.store('global').t('oauthTimeout'),
'warning'
);
} }
}, 2000); // Poll every 2 seconds }, 2000); // Poll every 2 seconds
} else { } else {

View File

@@ -1,18 +1,34 @@
:root { :root {
/* === Background Layers === */
--color-space-950: #09090b; --color-space-950: #09090b;
--color-space-900: #0f0f11; --color-space-900: #0f0f11;
--color-space-850: #121214; --color-space-850: #121214;
--color-space-800: #18181b; --color-space-800: #18181b;
--color-space-border: #27272a; --color-space-border: #27272a;
/* === Neon Accents (Full Saturation) === */
--color-neon-purple: #a855f7; --color-neon-purple: #a855f7;
--color-neon-green: #22c55e; --color-neon-green: #22c55e;
--color-neon-cyan: #06b6d4; --color-neon-cyan: #06b6d4;
--color-neon-yellow: #eab308; --color-neon-yellow: #eab308;
--color-neon-red: #ef4444; --color-neon-red: #ef4444;
--color-text-main: #d1d5db; /* gray-300 */
--color-text-dim: #71717a; /* zinc-400 */ /* === Soft Neon (Reduced Saturation for Fills) === */
--color-text-muted: #6b7280; /* gray-500 */ --color-neon-purple-soft: #9333ea;
--color-text-bright: #ffffff; --color-neon-green-soft: #16a34a;
--color-neon-cyan-soft: #0891b2;
/* === Text Hierarchy (WCAG AA Compliant) === */
--color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */
--color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */
--color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */
--color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */
/* === Legacy Aliases (Backward Compatibility) === */
--color-text-main: var(--color-text-secondary);
--color-text-dim: var(--color-text-tertiary);
--color-text-muted: var(--color-text-tertiary);
--color-text-bright: var(--color-text-primary);
/* Gradient Accents */ /* Gradient Accents */
--color-green-400: #4ade80; --color-green-400: #4ade80;
@@ -44,20 +60,25 @@
/* Custom Scrollbar */ /* Custom Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: rgba(9, 9, 11, 0.3);
border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-space-800 rounded-full; background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s ease;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-space-border; background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%);
border-color: rgba(168, 85, 247, 0.3);
} }
/* Animations */ /* Animations */
@@ -82,9 +103,23 @@
/* Utility */ /* Utility */
.glass-panel { .glass-panel {
background: theme('colors.space.900 / 70%'); background: linear-gradient(135deg,
rgba(15, 15, 17, 0.75) 0%,
rgba(18, 18, 20, 0.70) 100%
);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid theme('colors.white / 8%'); border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02) inset,
0 4px 24px rgba(0, 0, 0, 0.4);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.glass-panel:hover {
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 8px 32px rgba(0, 0, 0, 0.5);
} }
.nav-item.active { .nav-item.active {
@@ -128,23 +163,77 @@
/* Refactored Global Utilities */ /* Refactored Global Utilities */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* View Containers */ /* Standard Layout Constants */
.view-container { :root {
@apply mx-auto p-6 space-y-6 animate-fade-in; --view-padding: 2rem; /* 32px - Standard Padding */
/* Responsive max-width: use most of screen on small displays, --view-gap: 1.5rem; /* 24px - Standard component gap */
but cap at 1600px on large displays for reading comfort */ --card-radius: 0.75rem; /* 12px */
max-width: min(95%, 1600px);
} }
/* Section Headers */ @media (max-width: 768px) {
.section-header { :root {
@apply flex justify-between items-center mb-6; --view-padding: 1rem;
--view-gap: 1rem;
}
} }
.section-title {
/* Base View Container */
.view-container {
@apply mx-auto w-full animate-fade-in flex flex-col;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px); /* Align with navbar height */
max-width: 1400px;
scrollbar-gutter: stable;
}
/* Specialized container for data-heavy pages (Logs) */
.view-container-full {
@apply w-full animate-fade-in flex flex-col;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px);
max-width: 100%;
}
/* Centered container for form-heavy pages (Settings/Accounts) */
.view-container-centered {
@apply mx-auto w-full animate-fade-in flex flex-col;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px);
max-width: 900px; /* Comfortable reading width for forms */
}
/* Standard Section Header */
.view-header {
@apply flex flex-col md:flex-row md:items-end justify-between mb-2;
gap: 1rem;
}
.view-header-title {
@apply flex flex-col;
}
.view-header-title h2 {
@apply text-2xl font-bold text-white tracking-tight; @apply text-2xl font-bold text-white tracking-tight;
} }
.section-desc {
@apply text-gray-500 text-sm; .view-header-title p {
@apply text-sm text-gray-500 mt-1;
}
.view-header-actions {
@apply flex items-center gap-3;
}
/* Standard Card Panel */
.view-card {
@apply glass-panel rounded-xl p-6 border border-space-border/50 relative overflow-hidden;
}
.view-card-header {
@apply flex items-center justify-between mb-4 pb-4 border-b border-space-border/30;
} }
/* Component Unification */ /* Component Unification */
@@ -155,77 +244,73 @@
@apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border; @apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border;
} }
.standard-table tbody tr { .standard-table tbody tr {
@apply hover:bg-white/5 transition-colors border-b border-space-border/30 last:border-0; @apply transition-all duration-200 border-b border-space-border/30 last:border-0;
} }
/* Custom Range Slider */ .standard-table tbody tr:hover {
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0.03) 100%
);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Custom Range Slider - Simplified */
.custom-range { .custom-range {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 100%; width: 100%;
height: 6px; height: 4px;
background: var(--color-space-800); background: var(--color-space-800);
border-radius: 999px; border-radius: 999px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
position: relative;
background-image: linear-gradient(to right, var(--range-color) 0%, var(--range-color) 100%);
background-repeat: no-repeat;
background-size: 0% 100%;
} }
.custom-range::-webkit-slider-thumb { .custom-range::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 18px; width: 14px;
height: 18px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: #ffffff; background: var(--range-color, var(--color-neon-purple));
box-shadow: 0 0 10px var(--range-color-glow);
cursor: pointer; cursor: pointer;
margin-top: -6px; transition: transform 0.1s ease;
transition: transform 0.1s ease, box-shadow 0.2s ease;
} }
.custom-range::-webkit-slider-thumb:hover { .custom-range::-webkit-slider-thumb:hover {
transform: scale(1.1); transform: scale(1.15);
box-shadow: 0 0 15px var(--range-color-glow);
} }
.custom-range::-moz-range-thumb { .custom-range::-moz-range-thumb {
width: 18px; width: 14px;
height: 18px; height: 14px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: #ffffff; background: var(--range-color, var(--color-neon-purple));
box-shadow: 0 0 10px var(--range-color-glow);
cursor: pointer; cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.2s ease; transition: transform 0.1s ease;
} }
.custom-range::-moz-range-thumb:hover { .custom-range::-moz-range-thumb:hover {
transform: scale(1.1); transform: scale(1.15);
box-shadow: 0 0 15px var(--range-color-glow);
} }
/* Color Variants */ /* Color Variants */
.custom-range-purple { .custom-range-purple {
--range-color: var(--color-neon-purple); --range-color: var(--color-neon-purple);
--range-color-glow: rgba(168, 85, 247, 0.5);
} }
.custom-range-green { .custom-range-green {
--range-color: var(--color-neon-green); --range-color: var(--color-neon-green);
--range-color-glow: rgba(34, 197, 94, 0.5);
} }
.custom-range-cyan { .custom-range-cyan {
--range-color: var(--color-neon-cyan); --range-color: var(--color-neon-cyan);
--range-color-glow: rgba(6, 182, 212, 0.5);
} }
.custom-range-yellow { .custom-range-yellow {
--range-color: var(--color-neon-yellow); --range-color: var(--color-neon-yellow);
--range-color-glow: rgba(234, 179, 8, 0.5);
} }
.custom-range-accent { .custom-range-accent {
--range-color: var(--color-neon-cyan); /* Default accent to cyan if needed, or match DaisyUI */ --range-color: var(--color-neon-cyan);
--range-color-glow: rgba(6, 182, 212, 0.5);
} }

View File

@@ -225,32 +225,32 @@
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="flex-1 overflow-auto bg-space-950 relative"> <div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar" style="scrollbar-gutter: stable;">
<!-- Views Container --> <!-- Views Container -->
<!-- Dashboard --> <!-- Dashboard -->
<div x-show="$store.global.activeTab === 'dashboard'" x-load-view="'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" x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-7xl mx-auto h-full"></div> class="w-full"></div>
<!-- Models --> <!-- Models -->
<div x-show="$store.global.activeTab === 'models'" x-load-view="'models'" <div x-show="$store.global.activeTab === 'models'" x-load-view="'models'"
x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from" x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-7xl mx-auto h-full"></div> class="w-full"></div>
<!-- Logs --> <!-- Logs -->
<div x-show="$store.global.activeTab === 'logs'" x-load-view="'logs'" x-transition:enter="fade-enter-active" <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> x-transition:enter-start="fade-enter-from" class="w-full h-full"></div>
<!-- Accounts --> <!-- Accounts -->
<div x-show="$store.global.activeTab === 'accounts'" x-load-view="'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" x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-4xl mx-auto h-full"></div> class="w-full"></div>
<!-- Settings --> <!-- Settings -->
<div x-show="$store.global.activeTab === 'settings'" x-load-view="'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" x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
class="max-w-2xl mx-auto h-full"></div> class="w-full"></div>
</div> </div>
</div> </div>
@@ -309,6 +309,42 @@
</form> </form>
</dialog> </dialog>
<!-- OAuth Progress Modal -->
<dialog id="oauth_progress_modal" class="modal" :class="{ 'modal-open': $store.global.oauthProgress.active }">
<div class="modal-box bg-space-900 border border-neon-purple/50">
<h3 class="font-bold text-lg text-white flex items-center gap-2">
<svg class="w-6 h-6 text-neon-purple animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span x-text="$store.global.t('oauthWaiting')">Waiting for OAuth...</span>
</h3>
<p class="py-4 text-gray-400 text-sm" x-text="$store.global.t('oauthWaitingDesc')">
Please complete authentication in the popup window.
</p>
<!-- Progress Bar -->
<div class="w-full bg-space-800 rounded-full h-2 mb-4 overflow-hidden">
<div class="bg-neon-purple h-2 rounded-full transition-all duration-500"
:style="`width: ${($store.global.oauthProgress.current / $store.global.oauthProgress.max) * 100}%`">
</div>
</div>
<!-- Progress Text -->
<div class="flex justify-between text-xs text-gray-600 mb-4">
<span x-text="`${$store.global.oauthProgress.current} / ${$store.global.oauthProgress.max}s`"></span>
<span x-text="`${Math.round(($store.global.oauthProgress.current / $store.global.oauthProgress.max) * 100)}%`"></span>
</div>
<div class="modal-action">
<button class="btn btn-sm btn-ghost text-gray-400"
@click="$store.global.oauthProgress.cancel && $store.global.oauthProgress.cancel()"
x-text="$store.global.t('cancelOAuth')">
Cancel
</button>
</div>
</div>
</dialog>
<!-- Scripts - Loading Order Matters! --> <!-- Scripts - Loading Order Matters! -->
<!-- 1. Utils (global helpers) --> <!-- 1. Utils (global helpers) -->
<script src="js/utils.js"></script> <script src="js/utils.js"></script>

View File

@@ -5,6 +5,36 @@
window.Components = window.Components || {}; window.Components = window.Components || {};
window.Components.accountManager = () => ({ window.Components.accountManager = () => ({
searchQuery: '',
deleteTarget: '',
get filteredAccounts() {
const accounts = Alpine.store('data').accounts || [];
if (!this.searchQuery || this.searchQuery.trim() === '') {
return accounts;
}
const query = this.searchQuery.toLowerCase().trim();
return accounts.filter(acc => {
return acc.email.toLowerCase().includes(query) ||
(acc.projectId && acc.projectId.toLowerCase().includes(query)) ||
(acc.source && acc.source.toLowerCase().includes(query));
});
},
formatEmail(email) {
if (!email || email.length <= 40) return email;
const [user, domain] = email.split('@');
if (!domain) return email;
// Preserve domain integrity, truncate username if needed
if (user.length > 20) {
return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`;
}
return email;
},
async refreshAccount(email) { async refreshAccount(email) {
const store = Alpine.store('global'); const store = Alpine.store('global');
store.showToast(store.t('refreshingAccount', { email }), 'info'); store.showToast(store.t('refreshingAccount', { email }), 'info');
@@ -88,10 +118,16 @@ window.Components.accountManager = () => ({
} }
}, },
async deleteAccount(email) { confirmDeleteAccount(email) {
this.deleteTarget = email;
document.getElementById('delete_account_modal').showModal();
},
async executeDelete() {
const email = this.deleteTarget;
const store = Alpine.store('global'); const store = Alpine.store('global');
if (!confirm(store.t('confirmDelete'))) return;
const password = store.webuiPassword; const password = store.webuiPassword;
try { try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password); const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
if (newPassword) store.webuiPassword = newPassword; if (newPassword) store.webuiPassword = newPassword;
@@ -100,6 +136,8 @@ window.Components.accountManager = () => ({
if (data.status === 'ok') { if (data.status === 'ok') {
store.showToast(store.t('deletedAccount', { email }), 'success'); store.showToast(store.t('deletedAccount', { email }), 'success');
Alpine.store('data').fetchData(); Alpine.store('data').fetchData();
document.getElementById('delete_account_modal').close();
this.deleteTarget = '';
} else { } else {
store.showToast(data.error || store.t('deleteFailed'), 'error'); store.showToast(data.error || store.t('deleteFailed'), 'error');
} }

View File

@@ -464,7 +464,9 @@ window.Components.dashboard = () => ({
createDataset(label, data, color, ctx) { createDataset(label, data, color, ctx) {
const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200); const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, this.hexToRgba(color, 0.3)); // Reduced opacity from 0.3 to 0.12 for less visual noise
gradient.addColorStop(0, this.hexToRgba(color, 0.12));
gradient.addColorStop(0.6, this.hexToRgba(color, 0.05));
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
return { return {
@@ -472,12 +474,14 @@ window.Components.dashboard = () => ({
data, data,
borderColor: color, borderColor: color,
backgroundColor: gradient, backgroundColor: gradient,
borderWidth: 2, borderWidth: 2.5, // Slightly thicker line for better visibility
tension: 0.4, tension: 0.35, // Smoother curves
fill: true, fill: true,
pointRadius: 3, pointRadius: 2.5,
pointHoverRadius: 5, pointHoverRadius: 6,
pointBackgroundColor: color pointBackgroundColor: color,
pointBorderColor: 'rgba(9, 9, 11, 0.8)',
pointBorderWidth: 1.5
}; };
}, },

View File

@@ -18,15 +18,28 @@ window.Components.logsViewer = () => ({
}, },
get filteredLogs() { get filteredLogs() {
const query = this.searchQuery.toLowerCase(); const query = this.searchQuery.trim();
if (!query) {
return this.logs.filter(log => this.filters[log.level]);
}
// Try regex first, fallback to plain text search
let matcher;
try {
const regex = new RegExp(query, 'i');
matcher = (msg) => regex.test(msg);
} catch (e) {
// Invalid regex, fallback to case-insensitive string search
const lowerQuery = query.toLowerCase();
matcher = (msg) => msg.toLowerCase().includes(lowerQuery);
}
return this.logs.filter(log => { return this.logs.filter(log => {
// Level Filter // Level Filter
if (!this.filters[log.level]) return false; if (!this.filters[log.level]) return false;
// Search Filter // Search Filter
if (query && !log.message.toLowerCase().includes(query)) return false; return matcher(log.message);
return true;
}); });
}, },

View File

@@ -76,17 +76,17 @@ document.addEventListener('alpine:init', () => {
const config = this.modelConfig[modelId] || {}; const config = this.modelConfig[modelId] || {};
const family = this.getModelFamily(modelId); const family = this.getModelFamily(modelId);
// Visibility Logic for Models Tab (quotaRows): // Visibility Logic for Models Page (quotaRows):
// 1. If explicitly hidden via config, always hide // 1. If explicitly hidden via config, ALWAYS hide (clean interface)
// 2. If no config, default 'unknown' families to HIDDEN // 2. If no config, default 'unknown' families to HIDDEN
// 3. Known families (Claude/Gemini) default to VISIBLE // 3. Known families (Claude/Gemini) default to VISIBLE
// Note: showHiddenModels toggle is for Settings page only, NOT here // Note: To manage hidden models, use Settings → Models tab
let isHidden = config.hidden; let isHidden = config.hidden;
if (isHidden === undefined) { if (isHidden === undefined) {
isHidden = (family === 'other' || family === 'unknown'); isHidden = (family === 'other' || family === 'unknown');
} }
// Models Tab: ALWAYS hide hidden models (no toggle check) // Models Page: ALWAYS hide hidden models (use Settings to restore)
if (isHidden) return; if (isHidden) return;
// Filters // Filters

View File

@@ -219,6 +219,30 @@ document.addEventListener('alpine:init', () => {
cancel: "Cancel", cancel: "Cancel",
passwordsNotMatch: "Passwords do not match", passwordsNotMatch: "Passwords do not match",
passwordTooShort: "Password must be at least 6 characters", passwordTooShort: "Password must be at least 6 characters",
// Dashboard drill-down
clickToViewAllAccounts: "Click to view all accounts",
clickToViewModels: "Click to view Models page",
clickToViewLimitedAccounts: "Click to view rate-limited accounts",
clickToFilterClaude: "Click to filter Claude models",
clickToFilterGemini: "Click to filter Gemini models",
// Accounts page
searchAccounts: "Search accounts...",
noAccountsYet: "No Accounts Yet",
noAccountsDesc: "Get started by adding a Google account via OAuth, or use the CLI command to import credentials.",
addFirstAccount: "Add Your First Account",
noSearchResults: "No accounts match your search",
clearSearch: "Clear Search",
disabledAccountsNote: "<strong>Disabled accounts</strong> will not be used for request routing but remain in the configuration. Dashboard statistics only include enabled accounts.",
dangerousOperation: "⚠️ Dangerous Operation",
confirmDeletePrompt: "Are you sure you want to delete account",
deleteWarning: "⚠️ This action cannot be undone. All configuration and historical records will be permanently deleted.",
// OAuth progress
oauthWaiting: "Waiting for OAuth authorization...",
oauthWaitingDesc: "Please complete the authentication in the popup window. This may take up to 2 minutes.",
oauthCancelled: "OAuth authorization cancelled",
oauthTimeout: "⏱️ OAuth authorization timed out. Please try again.",
oauthWindowClosed: "OAuth window was closed. Authorization may be incomplete.",
cancelOAuth: "Cancel",
}, },
zh: { zh: {
dashboard: "仪表盘", dashboard: "仪表盘",
@@ -427,12 +451,44 @@ document.addEventListener('alpine:init', () => {
cancel: "取消", cancel: "取消",
passwordsNotMatch: "密码不匹配", passwordsNotMatch: "密码不匹配",
passwordTooShort: "密码至少需要 6 个字符", passwordTooShort: "密码至少需要 6 个字符",
// Dashboard drill-down
clickToViewAllAccounts: "点击查看所有账号",
clickToViewModels: "点击查看模型页面",
clickToViewLimitedAccounts: "点击查看受限账号",
clickToFilterClaude: "点击筛选 Claude 模型",
clickToFilterGemini: "点击筛选 Gemini 模型",
// 账号页面
searchAccounts: "搜索账号...",
noAccountsYet: "还没有添加任何账号",
noAccountsDesc: "点击上方的 \"添加节点\" 按钮通过 OAuth 添加 Google 账号,或者使用 CLI 命令导入凭证。",
addFirstAccount: "添加第一个账号",
noSearchResults: "没有找到匹配的账号",
clearSearch: "清除搜索",
disabledAccountsNote: "<strong>已禁用的账号</strong>不会用于请求路由,但仍保留在配置中。仪表盘统计数据仅包含已启用的账号。",
dangerousOperation: "⚠️ 危险操作",
confirmDeletePrompt: "确定要删除账号",
deleteWarning: "⚠️ 此操作不可撤销,账号的所有配置和历史记录将永久删除。",
// OAuth 进度
oauthWaiting: "等待 OAuth 授权中...",
oauthWaitingDesc: "请在弹出窗口中完成认证。此过程最长可能需要 2 分钟。",
oauthCancelled: "已取消 OAuth 授权",
oauthTimeout: "⏱️ OAuth 授权超时,请重试。",
oauthWindowClosed: "OAuth 窗口已关闭,授权可能未完成。",
cancelOAuth: "取消",
} }
}, },
// Toast Messages // Toast Messages
toast: null, toast: null,
// OAuth Progress
oauthProgress: {
active: false,
current: 0,
max: 60,
cancel: null
},
t(key, params = {}) { t(key, params = {}) {
let str = this.translations[this.lang][key] || key; let str = this.translations[this.lang][key] || key;
if (typeof str === 'string') { if (typeof str === 'string') {

View File

@@ -1,25 +1,40 @@
<div x-data="accountManager" class="view-container"> <div x-data="accountManager" class="view-container">
<!-- Header --> <!-- Compact Header -->
<div class="flex items-start justify-between mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<div> <!-- Title with inline subtitle -->
<h2 class="text-2xl font-bold text-white tracking-tight mb-1" x-text="$store.global.t('accessCredentials')"> <div class="flex items-baseline gap-3">
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('accessCredentials')">
Access Credentials Access Credentials
</h2> </h1>
<p class="text-sm text-gray-500" x-text="$store.global.t('manageTokens')"> <span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('manageTokens')">
Manage OAuth tokens and session states Manage OAuth tokens and session states
</p> </span>
</div> </div>
<!-- Action Buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="btn btn-sm btn-outline border-space-border text-gray-400 hover:text-white hover:border-white transition-all gap-2" <!-- Search -->
<div class="relative" x-show="$store.data.accounts.length > 0">
<input type="text"
x-model="searchQuery"
:placeholder="$store.global.t('searchAccounts')"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-48 pl-9 text-xs h-8"
@keydown.escape="searchQuery = ''">
<svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="reloadAccounts()"> @click="reloadAccounts()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <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> </svg>
<span x-text="$store.global.t('reload')">Reload</span> <span x-text="$store.global.t('reload')">Reload</span>
</button> </button>
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20" <button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-xs gap-2 shadow-lg shadow-neon-purple/20 h-8"
onclick="document.getElementById('add_account_modal').showModal()"> onclick="document.getElementById('add_account_modal').showModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
<span x-text="$store.global.t('addNode')">Add Node</span> <span x-text="$store.global.t('addNode')">Add Node</span>
@@ -27,21 +42,54 @@
</div> </div>
</div> </div>
<!-- Table Container --> <!-- Table Card -->
<div class="bg-space-900/40 border border-space-border/30 rounded-xl overflow-hidden backdrop-blur-sm"> <div class="view-card !p-0">
<table class="w-full"> <table class="w-full">
<thead> <thead x-show="$store.data.accounts.length > 0">
<tr class="border-b border-space-border/50"> <tr class="bg-space-900/50 border-b border-space-border/50">
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('enabled')">Enabled</th> <th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider" x-text="$store.global.t('identity')">Identity (Email)</th> <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('identity')">Identity (Email)</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('source')">Source</th> <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('projectId')">Project ID</th>
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th> <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th> <th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template x-for="acc in $store.data.accounts" :key="acc.email"> <!-- Empty State -->
<template x-if="$store.data.accounts.length === 0">
<tr>
<td colspan="6" class="py-16 text-center">
<div class="flex flex-col items-center gap-4 max-w-lg mx-auto">
<svg class="w-20 h-20 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h3 class="text-xl font-semibold text-gray-400" x-text="$store.global.t('noAccountsYet')">
No Accounts Yet
</h3>
<p class="text-sm text-gray-600 max-w-md leading-relaxed" x-text="$store.global.t('noAccountsDesc')">
Get started by adding a Google account via OAuth, or use the CLI command to import credentials.
</p>
<div class="flex items-center gap-4 mt-2">
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20"
onclick="document.getElementById('add_account_modal').showModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('addFirstAccount')">Add Your First Account</span>
</button>
<span class="text-xs text-gray-600" x-text="$store.global.t('or')">or</span>
<div class="text-xs font-mono bg-space-800 px-3 py-2 rounded border border-space-border text-gray-400">
npm run accounts:add
</div>
</div>
</div>
</td>
</tr>
</template>
<!-- Account Rows -->
<template x-for="acc in filteredAccounts" :key="acc.email">
<tr class="border-b border-space-border/30 last:border-0 hover:bg-white/5 transition-colors group"> <tr class="border-b border-space-border/30 last:border-0 hover:bg-white/5 transition-colors group">
<td class="pl-6 py-4"> <td class="pl-6 py-4">
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
@@ -54,9 +102,7 @@
<td class="py-4"> <td class="py-4">
<div class="tooltip tooltip-right" :data-tip="acc.email"> <div class="tooltip tooltip-right" :data-tip="acc.email">
<span class="font-mono text-sm text-gray-300 truncate max-w-[320px] inline-block cursor-help group-hover:text-white transition-colors" <span class="font-mono text-sm text-gray-300 truncate max-w-[320px] inline-block cursor-help group-hover:text-white transition-colors"
x-text="acc.email.length > 40 ? x-text="formatEmail(acc.email)">
acc.email.substring(0, 15) + '...' + acc.email.slice(-18) :
acc.email">
</span> </span>
</div> </div>
</td> </td>
@@ -66,7 +112,6 @@
x-text="acc.source || 'oauth'"> x-text="acc.source || 'oauth'">
</span> </span>
</td> </td>
<td class="py-4 font-mono text-xs text-gray-500" x-text="acc.projectId || '-'"></td>
<td class="py-4"> <td class="py-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full flex-shrink-0" <div class="w-2 h-2 rounded-full flex-shrink-0"
@@ -81,7 +126,7 @@
</div> </div>
</td> </td>
<td class="py-4 pr-6"> <td class="py-4 pr-6">
<div class="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex justify-end gap-2">
<!-- Fix Button --> <!-- Fix Button -->
<button x-show="acc.status === 'invalid'" <button x-show="acc.status === 'invalid'"
class="px-3 py-1 text-[10px] font-bold font-mono uppercase tracking-wider rounded bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20 border border-yellow-500/30 hover:border-yellow-500/50 transition-all" class="px-3 py-1 text-[10px] font-bold font-mono uppercase tracking-wider rounded bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20 border border-yellow-500/30 hover:border-yellow-500/50 transition-all"
@@ -100,7 +145,7 @@
class="p-2 rounded transition-colors" class="p-2 rounded transition-colors"
:class="acc.source === 'database' ? 'text-gray-700 cursor-not-allowed' : 'hover:bg-red-500/10 text-gray-500 hover:text-red-400'" :class="acc.source === 'database' ? 'text-gray-700 cursor-not-allowed' : 'hover:bg-red-500/10 text-gray-500 hover:text-red-400'"
:disabled="acc.source === 'database'" :disabled="acc.source === 'database'"
@click="acc.source !== 'database' && deleteAccount(acc.email)" @click="acc.source !== 'database' && confirmDeleteAccount(acc.email)"
:title="acc.source === 'database' ? $store.global.t('cannotDeleteDatabase') : $store.global.t('delete')"> :title="acc.source === 'database' ? $store.global.t('cannotDeleteDatabase') : $store.global.t('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -111,7 +156,71 @@
</td> </td>
</tr> </tr>
</template> </template>
<!-- No Search Results -->
<template x-if="$store.data.accounts.length > 0 && filteredAccounts.length === 0">
<tr>
<td colspan="6" class="py-12 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p>
<button class="btn btn-xs btn-ghost text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
</div>
</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Account Status Info -->
<div x-show="$store.data.accounts.length > 0" class="mt-4 px-6 py-3 bg-space-900/20 rounded-lg border border-space-border/20">
<p class="text-xs text-gray-600 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span x-html="$store.global.t('disabledAccountsNote')"></span>
</p>
</div>
<!-- Delete Confirmation Modal -->
<dialog id="delete_account_modal" class="modal">
<div class="modal-box bg-space-900 border-2 border-red-500/50">
<h3 class="font-bold text-lg text-red-400 flex items-center gap-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('dangerousOperation')">Dangerous Operation</span>
</h3>
<p class="py-4 text-gray-300">
<span x-text="$store.global.t('confirmDeletePrompt')">Are you sure you want to delete account</span>
<strong class="text-white font-mono" x-text="deleteTarget"></strong>?
</p>
<div class="bg-red-500/10 border border-red-500/30 rounded p-3 mb-4">
<p class="text-sm text-red-300 flex items-start gap-2">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('deleteWarning')">This action cannot be undone. All configuration and historical records will be permanently deleted.</span>
</p>
</div>
<div class="modal-action">
<button class="btn btn-ghost text-gray-400" onclick="document.getElementById('delete_account_modal').close()" x-text="$store.global.t('cancel')">
Cancel
</button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
@click="executeDelete()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span x-text="$store.global.t('confirmDelete')">Confirm Delete</span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div> </div>

View File

@@ -1,10 +1,39 @@
<div x-data="dashboard" class="view-container"> <div x-data="dashboard" class="view-container">
<!-- Compact Header -->
<div class="flex items-center justify-between gap-4 mb-6">
<!-- Title with inline subtitle -->
<div class="flex items-baseline gap-3">
<h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('dashboard')">
Dashboard
</h1>
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('systemDesc')">
CLAUDE PROXY SYSTEM
</span>
</div>
<!-- Compact Status Indicator -->
<div class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-space-900/60 border border-space-border/40">
<div class="relative flex items-center justify-center">
<span class="absolute w-1.5 h-1.5 bg-neon-green rounded-full animate-ping opacity-75"></span>
<span class="relative w-1.5 h-1.5 bg-neon-green rounded-full"></span>
</div>
<span class="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Live</span>
<span class="text-gray-700"></span>
<span class="text-[10px] font-mono text-gray-400 tabular-nums"
x-text="new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'})">
</span>
</div>
</div>
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div <div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 hover:border-space-border/60 transition-colors group relative"> class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'accounts'"
:title="$store.global.t('clickToViewAllAccounts')">
<!-- Icon 移到右上角,缩小并变灰 --> <!-- Icon 移到右上角,缩小并变灰 -->
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-gray-600/60 transition-colors"> <div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-cyan-400/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z"> d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z">
@@ -15,14 +44,19 @@
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.total"></div> <div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.total"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate" <div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('totalAccounts')"></div> x-text="$store.global.t('totalAccounts')"></div>
<div class="stat-desc text-gray-600 text-[10px] truncate" x-text="$store.global.t('registeredNodes')"> <div class="stat-desc text-cyan-400/60 text-[10px] truncate flex items-center gap-1">
Registered <span x-text="$store.global.t('registeredNodes')"></span>
Nodes</div> <svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div> </div>
<div <div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 hover:border-space-border/60 transition-colors group relative"> class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-green-500/30 hover:bg-green-500/5 transition-all duration-300 group relative cursor-pointer"
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-neon-green/50 transition-colors"> @click="$store.global.activeTab = 'models'"
:title="$store.global.t('clickToViewModels')">
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-green-400/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
@@ -31,13 +65,19 @@
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.active"></div> <div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.active"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate" <div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('active')"></div> x-text="$store.global.t('active')"></div>
<div class="stat-desc text-neon-green/60 text-[10px] truncate" x-text="$store.global.t('operational')"> <div class="stat-desc text-green-400/60 text-[10px] truncate flex items-center gap-1">
<span x-text="$store.global.t('operational')"></span>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div> </div>
</div> </div>
<div <div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 hover:border-space-border/60 transition-colors group relative"> class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-red-500/30 hover:bg-red-500/5 transition-all duration-300 group relative cursor-pointer"
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-red-500/50 transition-colors"> @click="$store.global.activeTab = 'accounts'"
:title="$store.global.t('clickToViewLimitedAccounts')">
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-red-500/70 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
@@ -46,14 +86,19 @@
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.limited"></div> <div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.limited"></div>
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate" <div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
x-text="$store.global.t('rateLimited')"></div> x-text="$store.global.t('rateLimited')"></div>
<div class="stat-desc text-red-500/60 text-[10px] truncate" x-text="$store.global.t('cooldown')"></div> <div class="stat-desc text-red-500/60 text-[10px] truncate flex items-center gap-1">
<span x-text="$store.global.t('cooldown')"></span>
<svg class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div> </div>
<!-- Global Quota Chart --> <!-- Global Quota Chart -->
<div <div
class="stat bg-space-900/40 border border-space-border/30 rounded-lg p-4 col-span-1 lg:col-start-4 lg:row-start-1 h-full flex items-center justify-between gap-3 overflow-hidden relative group hover:border-space-border/60 transition-colors"> class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 col-span-1 lg:col-start-4 lg:row-start-1 h-full flex items-center justify-between gap-3 overflow-hidden relative group hover:border-space-border/60 transition-colors">
<!-- Chart Container --> <!-- Chart Container -->
<div class="h-16 w-16 lg:h-20 lg:w-20 relative flex-shrink-0"> <div class="h-14 w-14 lg:h-16 lg:w-16 relative flex-shrink-0">
<canvas id="quotaChart"></canvas> <canvas id="quotaChart"></canvas>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none"> <div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div> <div class="text-[10px] font-bold text-white font-mono" x-text="stats.overallHealth + '%'">%</div>
@@ -61,7 +106,7 @@
</div> </div>
<!-- Legend / Info --> <!-- Legend / Info -->
<div class="flex flex-col justify-center gap-2 flex-grow min-w-0 z-10"> <div class="flex flex-col justify-center gap-2 flex-grow min-w-0">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono truncate" <span class="text-[10px] text-gray-500 uppercase tracking-wider font-mono truncate"
x-text="$store.global.t('globalQuota')">Global Quota</span> x-text="$store.global.t('globalQuota')">Global Quota</span>
@@ -69,35 +114,37 @@
<!-- Custom Legend --> <!-- Custom Legend -->
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center justify-between text-[10px] text-gray-400"> <div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-purple transition-colors group/legend"
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'claude'; $store.data.computeQuotaRows(); })"
:title="$store.global.t('clickToFilterClaude')">
<div class="flex items-center gap-1.5 truncate"> <div class="flex items-center gap-1.5 truncate">
<div <div class="w-1.5 h-1.5 rounded-full bg-neon-purple flex-shrink-0"></div>
class="w-1.5 h-1.5 rounded-full bg-neon-purple shadow-[0_0_4px_rgba(168,85,247,0.4)] flex-shrink-0">
</div>
<span class="truncate" x-text="$store.global.t('familyClaude')">Claude</span> <span class="truncate" x-text="$store.global.t('familyClaude')">Claude</span>
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-[10px] text-gray-400"> <div class="flex items-center justify-between text-[10px] text-gray-400 cursor-pointer hover:text-neon-green transition-colors group/legend"
@click="$store.global.activeTab = 'models'; $nextTick(() => { $store.data.filters.family = 'gemini'; $store.data.computeQuotaRows(); })"
:title="$store.global.t('clickToFilterGemini')">
<div class="flex items-center gap-1.5 truncate"> <div class="flex items-center gap-1.5 truncate">
<div <div class="w-1.5 h-1.5 rounded-full bg-neon-green flex-shrink-0"></div>
class="w-1.5 h-1.5 rounded-full bg-neon-green shadow-[0_0_4px_rgba(34,197,94,0.4)] flex-shrink-0">
</div>
<span class="truncate" x-text="$store.global.t('familyGemini')">Gemini</span> <span class="truncate" x-text="$store.global.t('familyGemini')">Gemini</span>
<svg class="w-2.5 h-2.5 opacity-0 group-hover/legend:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div> </div>
</div> </div>
</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>
</div> </div>
<!-- Usage Trend Chart --> <!-- Usage Trend Chart -->
<div class="glass-panel p-4 rounded-lg"> <div class="view-card">
<!-- Header with Stats and Filter --> <!-- Header with Stats and Filter -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div class="flex flex-wrap items-center gap-4"> <div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
@@ -111,17 +158,17 @@
<!-- Usage Stats Pills --> <!-- Usage Stats Pills -->
<div class="flex flex-wrap gap-2 text-[10px] font-mono"> <div class="flex flex-wrap gap-2 text-[10px] font-mono">
<div class="px-2 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('totalColon')">Total:</span> <span class="text-gray-500" x-text="$store.global.t('totalColon')">Total:</span>
<span class="text-white ml-1" x-text="usageStats.total"></span> <span class="text-white ml-1 font-bold" x-text="usageStats.total"></span>
</div> </div>
<div class="px-2 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('todayColon')">Today:</span> <span class="text-gray-500" x-text="$store.global.t('todayColon')">Today:</span>
<span class="text-neon-cyan ml-1" x-text="usageStats.today"></span> <span class="text-neon-cyan ml-1 font-bold" x-text="usageStats.today"></span>
</div> </div>
<div class="px-2 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('hour1Colon')">1H:</span> <span class="text-gray-500" x-text="$store.global.t('hour1Colon')">1H:</span>
<span class="text-neon-green ml-1" x-text="usageStats.thisHour"></span> <span class="text-neon-green ml-1 font-bold" x-text="usageStats.thisHour"></span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -62,24 +62,33 @@
</div> </div>
<!-- Log Content --> <!-- Log Content -->
<div id="logs-container" class="flex-1 overflow-auto p-4 font-mono text-xs space-y-0.5 bg-space-950"> <div id="logs-container" class="flex-1 overflow-auto p-4 font-mono text-[11px] leading-relaxed bg-space-950 custom-scrollbar">
<template x-for="(log, idx) in filteredLogs" :key="idx"> <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"> <div class="flex gap-4 px-2 py-0.5 -mx-2 hover:bg-white/[0.03] transition-colors group">
<span class="text-gray-600 w-16 shrink-0 select-none group-hover:text-gray-500" <!-- Timestamp: Muted & Fixed Width -->
<span class="text-zinc-600 w-16 shrink-0 select-none group-hover:text-zinc-500 transition-colors"
x-text="new Date(log.timestamp).toLocaleTimeString([], {hour12:false})"></span> 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', <!-- Level: Tag Style -->
'text-yellow-400': log.level === 'WARN', <div class="w-14 shrink-0 flex items-center">
'text-red-500': log.level === 'ERROR', <span class="px-1.5 py-0.5 rounded-[2px] text-[10px] font-bold uppercase tracking-wider leading-none border"
'text-neon-green': log.level === 'SUCCESS', :class="{
'text-purple-400': log.level === 'DEBUG' 'bg-blue-500/10 text-blue-400 border-blue-500/20': log.level === 'INFO',
'bg-yellow-500/10 text-yellow-400 border-yellow-500/20': log.level === 'WARN',
'bg-red-500/10 text-red-500 border-red-500/20': log.level === 'ERROR',
'bg-emerald-500/10 text-emerald-400 border-emerald-500/20': log.level === 'SUCCESS',
'bg-purple-500/10 text-purple-400 border-purple-500/20': log.level === 'DEBUG'
}" x-text="log.level"></span> }" x-text="log.level"></span>
<span class="text-gray-300 break-all" x-html="log.message.replace(/\n/g, '<br>')"></span> </div>
<!-- Message: Clean & High Contrast -->
<span class="text-zinc-300 break-all group-hover:text-white transition-colors flex-1"
x-html="log.message.replace(/\n/g, '<br>')"></span>
</div> </div>
</template> </template>
<!-- Blinking Cursor --> <!-- 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 class="h-3 w-1.5 bg-zinc-600 animate-pulse mt-1 inline-block" x-show="filteredLogs.length === logs.length && !searchQuery"></div>
<div x-show="filteredLogs.length === 0 && logs.length > 0" class="text-gray-600 italic mt-4 text-center" <div x-show="filteredLogs.length === 0 && logs.length > 0" class="text-zinc-700 italic mt-8 text-center"
x-text="$store.global.t('noLogsMatch')"> x-text="$store.global.t('noLogsMatch')">
No logs match filter No logs match filter
</div> </div>

View File

@@ -1,20 +1,48 @@
<div x-data="models" class="view-container"> <div x-data="models" class="view-container">
<!-- Header --> <!-- Compact Header -->
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<div> <!-- Title with inline subtitle -->
<h2 class="text-xl font-bold text-white" x-text="$store.global.t('models')">Models</h2> <div class="flex items-baseline gap-3">
<p class="text-sm text-gray-500" x-text="$store.global.t('modelsPageDesc')">Real-time quota and status for <h1 class="text-2xl font-bold text-white tracking-tight" x-text="$store.global.t('models')">
all available models.</p> Models
</h1>
<span class="text-[10px] font-mono text-gray-600 uppercase tracking-[0.15em]"
x-text="$store.global.t('modelsPageDesc')">
Real-time quota and status for all available models.
</span>
</div>
<!-- Search Bar -->
<div class="relative w-72 h-9">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-xs placeholder-gray-600"
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
<button x-show="$store.data.filters.search"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-75"
x-transition:enter-end="opacity-100 scale-100"
@click="$store.data.filters.search = ''; $store.data.computeQuotaRows()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-white transition-colors">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
</div> </div>
<!-- Controls --> <!-- Controls -->
<div class="flex flex-col lg:flex-row items-center justify-between gap-4 glass-panel p-4 rounded-lg h-auto mb-6"> <div class="view-card !p-4 flex flex-col lg:flex-row items-center justify-between gap-4">
<div class="flex flex-col md:flex-row items-center gap-4 w-full lg:w-auto"> <div class="flex flex-col md:flex-row items-center gap-4 w-full lg:w-auto flex-wrap">
<!-- Custom Select --> <!-- Custom Select -->
<div class="relative w-full md:w-64 h-10"> <div class="relative w-full md:w-64 h-9">
<select <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" 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-xs"
x-model="$store.data.filters.account" @change="$store.data.computeQuotaRows()"> x-model="$store.data.filters.account" @change="$store.data.computeQuotaRows()">
<option value="all" x-text="$store.global.t('allAccounts')">All Accounts</option> <option value="all" x-text="$store.global.t('allAccounts')">All Accounts</option>
<template x-for="acc in $store.data.accounts" :key="acc.email"> <template x-for="acc in $store.data.accounts" :key="acc.email">
@@ -29,41 +57,35 @@
</div> </div>
<!-- Filter Buttons --> <!-- Filter Buttons -->
<div class="join h-10 w-full md:w-auto overflow-x-auto"> <div class="join h-9 w-full md:w-auto overflow-x-auto">
<button <button
class="join-item btn btn-sm h-full flex-1 md:flex-none px-4 md: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 whitespace-nowrap" class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'all'}" :class="$store.data.filters.family === 'all'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()" @click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()"
x-text="$store.global.t('allCaps')">ALL</button> x-text="$store.global.t('allCaps')">ALL</button>
<button <button
class="join-item btn btn-sm h-full flex-1 md:flex-none px-4 md: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 whitespace-nowrap" class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'claude'}" :class="$store.data.filters.family === 'claude'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()" @click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()"
x-text="$store.global.t('claudeCaps')">CLAUDE</button> x-text="$store.global.t('claudeCaps')">CLAUDE</button>
<button <button
class="join-item btn btn-sm h-full flex-1 md:flex-none px-4 md: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 whitespace-nowrap" class="join-item btn btn-xs h-full flex-1 md:flex-none px-6 border-space-border/50 bg-space-800 transition-all font-medium text-[10px] tracking-wide"
:class="{'bg-neon-purple text-white border-neon-purple hover:bg-purple-600 hover:border-purple-500': $store.data.filters.family === 'gemini'}" :class="$store.data.filters.family === 'gemini'
? 'bg-neon-green/20 text-neon-green border-neon-green/60 shadow-lg shadow-neon-green/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()" @click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()"
x-text="$store.global.t('geminiCaps')">GEMINI</button> x-text="$store.global.t('geminiCaps')">GEMINI</button>
</div> </div>
</div> </div>
<!-- Search -->
<div class="relative w-full md: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> </div>
<!-- Main Table --> <!-- Main Table Card -->
<div class="glass-panel rounded-xl overflow-x-auto min-h-[400px]"> <div class="view-card !p-0">
<div class="overflow-x-auto min-h-[400px]">
<table class="standard-table" <table class="standard-table"
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}"> :class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
<thead> <thead>
@@ -100,6 +122,9 @@
<div class="flex justify-between text-xs font-mono"> <div class="flex justify-between text-xs font-mono">
<span x-text="row.avgQuota + '%'" <span x-text="row.avgQuota + '%'"
:class="row.avgQuota > 0 ? 'text-white' : 'text-red-500'"></span> :class="row.avgQuota > 0 ? 'text-white' : 'text-red-500'"></span>
<!-- Available/Total Accounts Indicator -->
<span class="text-gray-500 text-[10px]"
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
</div> </div>
<progress class="progress w-full h-1 bg-space-800" <progress class="progress w-full h-1 bg-space-800"
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')" :class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
@@ -107,8 +132,21 @@
</div> </div>
</td> </td>
<td class="font-mono text-xs"> <td class="font-mono text-xs">
<div class="tooltip tooltip-left"
x-data="{
get tooltipText() {
if (!row.quotaInfo || row.quotaInfo.length === 0) return 'No reset data';
const resets = row.quotaInfo
.filter(q => q.resetTime)
.map(q => `${q.email}: ${q.resetTime}`)
.join('&#10;');
return resets || 'No reset scheduled';
}
}"
:data-tip="tooltipText">
<span x-text="row.resetIn" <span x-text="row.resetIn"
:class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span> :class="(row.resetIn && row.resetIn.indexOf('h') === -1 && row.resetIn !== '-') ? 'text-neon-purple font-bold' : 'text-gray-400'"></span>
</div>
</td> </td>
<td> <td>
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3">
@@ -121,7 +159,7 @@
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]"> <div class="flex flex-wrap gap-1 justify-end max-w-[200px]">
<template x-for="q in row.quotaInfo" :key="q.fullEmail"> <template x-for="q in row.quotaInfo" :key="q.fullEmail">
<div class="tooltip tooltip-left" :data-tip="q.fullEmail + ' (' + q.pct + '%)"> <div class="tooltip tooltip-left" :data-tip="q.fullEmail + ' (' + q.pct + '%)">
<div class="w-1.5 h-3 rounded-[1px] transition-all hover:scale-125 cursor-help" <div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')"> :class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div> </div>
</div> </div>
@@ -136,13 +174,18 @@
<button class="btn btn-xs btn-circle transition-colors" <button class="btn btn-xs btn-circle transition-colors"
:class="row.pinned ? 'bg-neon-purple/20 text-neon-purple border-neon-purple/50 hover:bg-neon-purple/30' : 'btn-ghost text-gray-600 hover:text-gray-300'" :class="row.pinned ? 'bg-neon-purple/20 text-neon-purple border-neon-purple/50 hover:bg-neon-purple/30' : 'btn-ghost text-gray-600 hover:text-gray-300'"
@click="await updateModelConfig(row.modelId, { pinned: !row.pinned })" @click="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
:title="$store.global.t('pinToTop')"> @keydown.enter="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
@keydown.space.prevent="await updateModelConfig(row.modelId, { pinned: !row.pinned })"
:title="$store.global.t('pinToTop')"
:aria-label="row.pinned ? 'Unpin model ' + row.modelId : 'Pin model ' + row.modelId + ' to top'"
:aria-pressed="row.pinned ? 'true' : 'false'"
tabindex="0">
<svg x-show="row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" <svg x-show="row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5"
viewBox="0 0 20 20" fill="currentColor"> viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" /> <path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
</svg> </svg>
<svg x-show="!row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" <svg x-show="!row.pinned" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <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" /> d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg> </svg>
@@ -151,16 +194,21 @@
<button class="btn btn-xs btn-circle transition-colors" <button class="btn btn-xs btn-circle transition-colors"
:class="row.hidden ? 'bg-red-500/20 text-red-400 border-red-500/50 hover:bg-red-500/30' : 'btn-ghost text-gray-400 hover:text-white'" :class="row.hidden ? 'bg-red-500/20 text-red-400 border-red-500/50 hover:bg-red-500/30' : 'btn-ghost text-gray-400 hover:text-white'"
@click="await updateModelConfig(row.modelId, { hidden: !row.hidden })" @click="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
:title="$store.global.t('toggleVisibility')"> @keydown.enter="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
@keydown.space.prevent="await updateModelConfig(row.modelId, { hidden: !row.hidden })"
:title="$store.global.t('toggleVisibility')"
:aria-label="row.hidden ? 'Show model ' + row.modelId : 'Hide model ' + row.modelId"
:aria-pressed="row.hidden ? 'true' : 'false'"
tabindex="0">
<svg x-show="!row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" <svg x-show="!row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <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" /> 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>
<svg x-show="row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" <svg x-show="row.hidden" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <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" /> 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> </svg>
@@ -191,3 +239,4 @@
</table> </table>
</div> </div>
</div> </div>
</div>

View File

@@ -73,12 +73,16 @@
</label> </label>
<div class="join w-full grid grid-cols-2"> <div class="join w-full grid grid-cols-2">
<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="join-item btn btn-sm border-space-border/50 bg-space-800 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'}" :class="$store.global.lang === 'en'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.global.setLang('en')">English</button> @click="$store.global.setLang('en')">English</button>
<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="join-item btn btn-sm border-space-border/50 bg-space-800 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'}" :class="$store.global.lang === 'zh'
? 'bg-neon-purple/20 text-neon-purple border-neon-purple/60 shadow-lg shadow-neon-purple/10'
: 'text-gray-400 hover:text-white hover:bg-space-700 hover:border-space-border'"
@click="$store.global.setLang('zh')">中文</button> @click="$store.global.setLang('zh')">中文</button>
</div> </div>
</div> </div>
@@ -91,10 +95,16 @@
<span class="label-text-alt font-mono text-neon-purple" <span class="label-text-alt font-mono text-neon-purple"
x-text="$store.settings.refreshInterval + 's'"></span> x-text="$store.settings.refreshInterval + 's'"></span>
</label> </label>
<input type="range" min="10" max="300" class="custom-range custom-range-purple" <div class="flex gap-3 items-center">
x-model="$store.settings.refreshInterval" <input type="range" min="10" max="300" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.refreshInterval"
:style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`" :style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)">
<input type="number" min="10" max="300"
class="input input-sm input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.refreshInterval"
@change="$store.settings.saveSettings(true)">
</div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>10s</span> <span>10s</span>
<span>300s</span> <span>300s</span>
@@ -109,10 +119,16 @@
<span class="label-text-alt font-mono text-neon-purple" <span class="label-text-alt font-mono text-neon-purple"
x-text="$store.settings.logLimit + ' ' + $store.global.t('lines')"></span> x-text="$store.settings.logLimit + ' ' + $store.global.t('lines')"></span>
</label> </label>
<input type="range" min="500" max="5000" step="500" class="custom-range custom-range-purple" <div class="flex gap-3 items-center">
x-model="$store.settings.logLimit" <input type="range" min="500" max="5000" step="500" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.logLimit"
:style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`" :style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)">
<input type="number" min="500" max="5000" step="500"
class="input input-sm input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.logLimit"
@change="$store.settings.saveSettings(true)">
</div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>500</span> <span>500</span>
<span>5000</span> <span>5000</span>
@@ -406,15 +422,14 @@
<!-- Tab 3: Models Configuration --> <!-- Tab 3: Models Configuration -->
<div x-show="activeTab === 'models'" x-data="window.Components.modelManager()" <div x-show="activeTab === 'models'" x-data="window.Components.modelManager()"
class="space-y-6 max-w-3xl animate-fade-in"> class="space-y-6 max-w-3xl animate-fade-in">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-sm text-gray-400" x-text="$store.global.t('modelsDesc')">Manage visibility and <div class="text-sm text-gray-400" x-text="$store.global.t('modelsDesc')">Configure model visibility, pinning, and request mapping.</div>
ordering of models in the dashboard.</div> <div class="text-xs text-gray-600 mt-1" x-text="$store.global.t('modelMappingHint')">Model mapping: server-side redirection. Claude Code users: see 'Claude CLI' tab for client-side setup.</div>
<div class="text-xs text-gray-600 mt-1" x-text="$store.global.t('modelMappingHint')"></div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-gray-500" x-text="$store.global.t('showHidden')">Show Hidden <span class="text-xs text-gray-500" x-text="$store.global.t('showHidden')">Show Hidden Models</span>
Models</span>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="$store.settings.showHiddenModels === true" :checked="$store.settings.showHiddenModels === true"
@@ -431,17 +446,17 @@
<table class="standard-table"> <table class="standard-table">
<thead> <thead>
<tr> <tr>
<th class="pl-4 w-1/2" x-text="$store.global.t('modelId')">Model ID</th> <th class="pl-4 w-5/12" x-text="$store.global.t('modelId')">Model ID</th>
<th class="w-1/3" x-text="$store.global.t('modelMapping')">Mapping (Target Model ID) <th class="w-5/12" x-text="$store.global.t('modelMapping')">Mapping (Target Model ID)</th>
</th> <th class="w-2/12 text-right pr-4" x-text="$store.global.t('actions')">Actions</th>
<th class="text-right pr-4 w-24" x-text="$store.global.t('actions')">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template x-for="modelId in $store.data.models" :key="modelId"> <template x-for="modelId in $store.data.models" :key="modelId">
<tr class="hover:bg-white/5 transition-colors group" <tr class="hover:bg-white/5 transition-colors group"
:class="isHidden ? 'opacity-50' : ''" :class="isHidden ? 'opacity-50' : ''"
x-show="!isHidden || $store.settings.showHiddenModels" x-data="{ x-show="!isHidden || $store.settings.showHiddenModels"
x-data="{
newMapping: '', newMapping: '',
get config() { return $store.data.modelConfig[modelId] || {} }, get config() { return $store.data.modelConfig[modelId] || {} },
get isPinned() { return !!this.config.pinned }, get isPinned() { return !!this.config.pinned },
@@ -451,13 +466,20 @@
return (family === 'other' || family === 'unknown'); return (family === 'other' || family === 'unknown');
} }
}" x-init="newMapping = config.mapping || ''"> }" x-init="newMapping = config.mapping || ''">
<td class="pl-4 font-mono text-xs text-gray-300" x-text="modelId"></td> <td class="pl-4 font-mono text-xs text-gray-300">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
</td>
<td> <td>
<div x-show="!isEditing(modelId)" <div x-show="!isEditing(modelId)"
class="flex items-center gap-2 group-hover:text-white transition-colors cursor-pointer" class="flex items-center gap-2 group-hover:text-white transition-colors cursor-pointer py-2"
@click="startEditing(modelId); newMapping = config.mapping || ''"> @click="startEditing(modelId); newMapping = config.mapping || ''; $nextTick(() => $refs['input-' + modelId]?.focus())">
<span x-text="config.mapping || '-'" <span x-text="config.mapping || 'Click to set...'"
:class="{'text-gray-600 italic': !config.mapping}"></span> :class="{'text-gray-600 italic': !config.mapping, 'text-gray-300': config.mapping}"
class="text-xs font-mono"></span>
<svg class="w-3 h-3 text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity" <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"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -466,24 +488,39 @@
</div> </div>
<div x-show="isEditing(modelId)" class="flex items-center gap-1"> <div x-show="isEditing(modelId)" class="flex items-center gap-1">
<input type="text" x-model="newMapping" <input type="text" x-model="newMapping"
class="input input-xs bg-space-800 border-space-border text-white focus:outline-none focus:border-neon-cyan w-40" :x-ref="'input-' + modelId"
placeholder="e.g. claude-sonnet-4-5" class="input input-xs bg-space-800 border-space-border text-white focus:outline-none focus:border-neon-cyan flex-1 font-mono text-xs"
placeholder="e.g. claude-sonnet-4-5 or gemini-3-flash"
@keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()" @keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
@keydown.escape="newMapping = config.mapping || ''; stopEditing()"> @keydown.escape="newMapping = config.mapping || ''; stopEditing()">
<button class="btn btn-xs btn-ghost btn-square text-green-500" <button class="btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20"
@click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"><svg @click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" title="Save">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M5 13l4 4L19 7" /> stroke-width="2" d="M5 13l4 4L19 7" />
</svg></button> </svg>
<button class="btn btn-xs btn-ghost btn-square text-gray-500" </button>
@click="newMapping = config.mapping || ''; stopEditing()"><svg <button class="btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20"
xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" @click="newMapping = config.mapping || ''; stopEditing()"
title="Cancel">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 18L18 6M6 6l12 12" /> stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg></button> </svg>
</button>
<button x-show="config.mapping"
class="btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20"
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
title="Clear mapping">
<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="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>
</td> </td>
<td class="text-right pr-4"> <td class="text-right pr-4">
@@ -533,7 +570,7 @@
</tr> </tr>
</template> </template>
<tr x-show="!$store.data.models.length"> <tr x-show="!$store.data.models.length">
<td colspan="4" class="text-center py-8 text-gray-600 text-xs font-mono" <td colspan="3" class="text-center py-8 text-gray-600 text-xs font-mono"
x-text="$store.global.t('noModels')"> x-text="$store.global.t('noModels')">
NO MODELS DETECTED NO MODELS DETECTED
</td> </td>
@@ -675,10 +712,16 @@
<span class="label-text-alt font-mono text-neon-purple text-xs font-semibold" <span class="label-text-alt font-mono text-neon-purple text-xs font-semibold"
x-text="serverConfig.maxRetries || 5"></span> x-text="serverConfig.maxRetries || 5"></span>
</label> </label>
<input type="range" min="1" max="20" class="custom-range custom-range-purple" <div class="flex gap-3 items-center">
<input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1"
:value="serverConfig.maxRetries || 5" :value="serverConfig.maxRetries || 5"
:style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`" :style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`"
@input="toggleMaxRetries($event.target.value)"> @input="toggleMaxRetries($event.target.value)">
<input type="number" min="1" max="20"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxRetries || 5"
@change="toggleMaxRetries($event.target.value)">
</div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@@ -689,11 +732,17 @@
<span class="label-text-alt font-mono text-neon-green text-xs font-semibold" <span class="label-text-alt font-mono text-neon-green text-xs font-semibold"
x-text="((serverConfig.retryBaseMs || 1000) < 10000 ? (serverConfig.retryBaseMs || 1000) + 'ms' : Math.round((serverConfig.retryBaseMs || 1000) / 1000) + 's')"></span> x-text="((serverConfig.retryBaseMs || 1000) < 10000 ? (serverConfig.retryBaseMs || 1000) + 'ms' : Math.round((serverConfig.retryBaseMs || 1000) / 1000) + 's')"></span>
</label> </label>
<div class="flex gap-2 items-center">
<input type="range" min="100" max="10000" step="100" <input type="range" min="100" max="10000" step="100"
class="custom-range custom-range-green" class="custom-range custom-range-green flex-1"
:value="serverConfig.retryBaseMs || 1000" :value="serverConfig.retryBaseMs || 1000"
:style="`background-size: ${((serverConfig.retryBaseMs || 1000) - 100) / 99}% 100%`" :style="`background-size: ${((serverConfig.retryBaseMs || 1000) - 100) / 99}% 100%`"
@input="toggleRetryBaseMs($event.target.value)"> @input="toggleRetryBaseMs($event.target.value)">
<input type="number" min="100" max="10000" step="100"
class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.retryBaseMs || 1000"
@change="toggleRetryBaseMs($event.target.value)">
</div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label pt-0"> <label class="label pt-0">
@@ -702,11 +751,17 @@
<span class="label-text-alt font-mono text-neon-green text-xs font-semibold" <span class="label-text-alt font-mono text-neon-green text-xs font-semibold"
x-text="Math.round((serverConfig.retryMaxMs || 30000) / 1000) + 's'"></span> x-text="Math.round((serverConfig.retryMaxMs || 30000) / 1000) + 's'"></span>
</label> </label>
<div class="flex gap-2 items-center">
<input type="range" min="1000" max="120000" step="1000" <input type="range" min="1000" max="120000" step="1000"
class="custom-range custom-range-green" class="custom-range custom-range-green flex-1"
:value="serverConfig.retryMaxMs || 30000" :value="serverConfig.retryMaxMs || 30000"
:style="`background-size: ${((serverConfig.retryMaxMs || 30000) - 1000) / 1190}% 100%`" :style="`background-size: ${((serverConfig.retryMaxMs || 30000) - 1000) / 1190}% 100%`"
@input="toggleRetryMaxMs($event.target.value)"> @input="toggleRetryMaxMs($event.target.value)">
<input type="number" min="1000" max="120000" step="1000"
class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.retryMaxMs || 30000"
@change="toggleRetryMaxMs($event.target.value)">
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -725,11 +780,17 @@
<span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold" <span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
x-text="Math.round((serverConfig.defaultCooldownMs || 60000) / 1000) + 's'"></span> x-text="Math.round((serverConfig.defaultCooldownMs || 60000) / 1000) + 's'"></span>
</label> </label>
<div class="flex gap-3 items-center">
<input type="range" min="1000" max="300000" step="1000" <input type="range" min="1000" max="300000" step="1000"
class="custom-range custom-range-cyan" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.defaultCooldownMs || 60000" :value="serverConfig.defaultCooldownMs || 60000"
:style="`background-size: ${((serverConfig.defaultCooldownMs || 60000) - 1000) / 2990}% 100%`" :style="`background-size: ${((serverConfig.defaultCooldownMs || 60000) - 1000) / 2990}% 100%`"
@input="toggleDefaultCooldownMs($event.target.value)"> @input="toggleDefaultCooldownMs($event.target.value)">
<input type="number" min="1000" max="300000" step="1000"
class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.defaultCooldownMs || 60000"
@change="toggleDefaultCooldownMs($event.target.value)">
</div>
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -739,11 +800,17 @@
<span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold" <span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
x-text="((serverConfig.maxWaitBeforeErrorMs || 120000) >= 60000 ? Math.round((serverConfig.maxWaitBeforeErrorMs || 120000) / 60000) + 'm' : Math.round((serverConfig.maxWaitBeforeErrorMs || 120000) / 1000) + 's')"></span> x-text="((serverConfig.maxWaitBeforeErrorMs || 120000) >= 60000 ? Math.round((serverConfig.maxWaitBeforeErrorMs || 120000) / 60000) + 'm' : Math.round((serverConfig.maxWaitBeforeErrorMs || 120000) / 1000) + 's')"></span>
</label> </label>
<div class="flex gap-3 items-center">
<input type="range" min="0" max="600000" step="10000" <input type="range" min="0" max="600000" step="10000"
class="custom-range custom-range-cyan" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.maxWaitBeforeErrorMs || 120000" :value="serverConfig.maxWaitBeforeErrorMs || 120000"
:style="`background-size: ${(serverConfig.maxWaitBeforeErrorMs || 120000) / 6000}% 100%`" :style="`background-size: ${(serverConfig.maxWaitBeforeErrorMs || 120000) / 6000}% 100%`"
@input="toggleMaxWaitBeforeErrorMs($event.target.value)"> @input="toggleMaxWaitBeforeErrorMs($event.target.value)">
<input type="number" min="0" max="600000" step="10000"
class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxWaitBeforeErrorMs || 120000"
@change="toggleMaxWaitBeforeErrorMs($event.target.value)">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('maxWaitDesc')">Maximum time to wait for a sticky account to x-text="$store.global.t('maxWaitDesc')">Maximum time to wait for a sticky account to
reset before switching.</p> reset before switching.</p>