feat(webui): add subscription tier and quota visualization
Backend:
This commit is contained in:
@@ -39,6 +39,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
refreshTimer: null,
|
||||
isTabVisible: true,
|
||||
|
||||
fetchData() {
|
||||
Alpine.store('data').fetchData();
|
||||
@@ -46,9 +47,39 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
startAutoRefresh() {
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
const interval = parseInt(Alpine.store('settings').refreshInterval);
|
||||
if (interval > 0) {
|
||||
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
|
||||
const baseInterval = parseInt(Alpine.store('settings').refreshInterval);
|
||||
if (baseInterval > 0) {
|
||||
// Setup visibility change listener (only once)
|
||||
if (!this._visibilitySetup) {
|
||||
this._visibilitySetup = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.isTabVisible = !document.hidden;
|
||||
if (this.isTabVisible) {
|
||||
// Tab became visible - fetch immediately and restart timer
|
||||
Alpine.store('data').fetchData();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule next refresh with jitter
|
||||
const scheduleNext = () => {
|
||||
// Add ±20% random jitter to prevent synchronized requests
|
||||
const jitter = (Math.random() - 0.5) * 0.4; // -0.2 to +0.2
|
||||
const interval = baseInterval * (1 + jitter);
|
||||
|
||||
// Slow down when tab is hidden (reduce frequency by 3x)
|
||||
const actualInterval = this.isTabVisible
|
||||
? interval
|
||||
: interval * 3;
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
Alpine.store('data').fetchData();
|
||||
scheduleNext(); // Reschedule with new jitter
|
||||
}, actualInterval * 1000);
|
||||
};
|
||||
|
||||
scheduleNext();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -163,5 +163,40 @@ window.Components.accountManager = () => ({
|
||||
} catch (e) {
|
||||
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get main model quota for display
|
||||
* Prioritizes flagship models (Opus > Sonnet > Flash)
|
||||
* @param {Object} account - Account object with limits
|
||||
* @returns {Object} { percent: number|null, model: string }
|
||||
*/
|
||||
getMainModelQuota(account) {
|
||||
const limits = account.limits || {};
|
||||
const modelIds = Object.keys(limits);
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
return { percent: null, model: '-' };
|
||||
}
|
||||
|
||||
// Priority: opus > sonnet > flash > others
|
||||
const priorityModels = [
|
||||
modelIds.find(m => m.toLowerCase().includes('opus')),
|
||||
modelIds.find(m => m.toLowerCase().includes('sonnet')),
|
||||
modelIds.find(m => m.toLowerCase().includes('flash')),
|
||||
modelIds[0] // Fallback to first model
|
||||
];
|
||||
|
||||
const selectedModel = priorityModels.find(m => m) || modelIds[0];
|
||||
const quota = limits[selectedModel];
|
||||
|
||||
if (!quota || quota.remainingFraction === null) {
|
||||
return { percent: null, model: selectedModel };
|
||||
}
|
||||
|
||||
return {
|
||||
percent: Math.round(quota.remainingFraction * 100),
|
||||
model: selectedModel
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,4 +40,18 @@ window.DashboardStats.updateStats = function(component) {
|
||||
component.stats.total = enabledAccounts.length;
|
||||
component.stats.active = active;
|
||||
component.stats.limited = limited;
|
||||
|
||||
// Calculate subscription tier distribution
|
||||
const subscription = { ultra: 0, pro: 0, free: 0 };
|
||||
enabledAccounts.forEach(acc => {
|
||||
const tier = acc.subscription?.tier || 'free';
|
||||
if (tier === 'ultra') {
|
||||
subscription.ultra++;
|
||||
} else if (tier === 'pro') {
|
||||
subscription.pro++;
|
||||
} else {
|
||||
subscription.free++;
|
||||
}
|
||||
});
|
||||
component.stats.subscription = subscription;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('accountEmail')">Account (Email)</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16">Tier</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32">Quota</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
|
||||
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
|
||||
</tr>
|
||||
@@ -112,6 +114,38 @@
|
||||
x-text="acc.source || 'oauth'">
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
|
||||
:class="{
|
||||
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra',
|
||||
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro',
|
||||
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
|
||||
}"
|
||||
x-text="(acc.subscription?.tier || 'free').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<div x-data="{ quota: getMainModelQuota(acc) }">
|
||||
<template x-if="quota.percent !== null">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-16 bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all"
|
||||
:class="{
|
||||
'bg-neon-green': quota.percent > 50,
|
||||
'bg-yellow-500': quota.percent > 20 && quota.percent <= 50,
|
||||
'bg-red-500': quota.percent <= 20
|
||||
}"
|
||||
:style="`width: ${quota.percent}%`">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-gray-400" x-text="quota.percent + '%'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="quota.percent === null">
|
||||
<span class="text-xs text-gray-600">-</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
|
||||
@@ -50,6 +50,24 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Subscription Tier Distribution -->
|
||||
<div class="flex items-center gap-2 mt-2 text-[10px] font-mono" x-show="stats.subscription">
|
||||
<template x-if="stats.subscription?.ultra > 0">
|
||||
<span class="px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/30">
|
||||
<span x-text="stats.subscription.ultra"></span> Ultra
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="stats.subscription?.pro > 0">
|
||||
<span class="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/30">
|
||||
<span x-text="stats.subscription.pro"></span> Pro
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="stats.subscription?.free > 0">
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-500/10 text-gray-400 border border-gray-500/30">
|
||||
<span x-text="stats.subscription.free"></span> Free
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -377,7 +395,7 @@
|
||||
<!-- Chart -->
|
||||
<div class="h-48 w-full relative">
|
||||
<canvas id="usageTrendChart"></canvas>
|
||||
|
||||
|
||||
<!-- Overall Loading State -->
|
||||
<div x-show="!stats.hasTrendData"
|
||||
class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10"
|
||||
@@ -395,7 +413,7 @@
|
||||
<div class="flex flex-col items-center gap-4 animate-fade-in">
|
||||
<div class="w-12 h-12 rounded-full bg-space-850 flex items-center justify-center text-gray-600 border border-space-border/50">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user