Refactor dashboard layout, quota logic, and connection status pill
This commit is contained in:
5
package-lock.json
generated
5
package-lock.json
generated
@@ -395,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1415,6 +1416,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -1791,6 +1793,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2608,6 +2611,7 @@
|
|||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -2732,6 +2736,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
2
public/css/style.css
generated
2
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -85,7 +85,7 @@
|
|||||||
:class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
|
:class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
x-text="$store.global.connectionStatus === 'connected' ? $store.global.t('online') : ($store.global.connectionStatus === 'disconnected' ? $store.global.t('offline') : $store.global.t('connecting'))"></span>
|
x-text="connectionStatus === 'connected' ? $store.global.t('online') : (connectionStatus === 'disconnected' ? $store.global.t('offline') : $store.global.t('connecting'))"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-4 w-px bg-space-border"></div>
|
<div class="h-4 w-px bg-space-border"></div>
|
||||||
|
|||||||
@@ -178,13 +178,17 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
// Calculate average health from quotaInfo (each entry has { pct })
|
// Calculate average health from quotaInfo (each entry has { pct })
|
||||||
// Health = average of all account quotas for this model
|
// Health = average of all account quotas for this model
|
||||||
const quotaInfo = row.quotaInfo || [];
|
const quotaInfo = row.quotaInfo || [];
|
||||||
|
let avgHealth = 0;
|
||||||
|
|
||||||
if (quotaInfo.length > 0) {
|
if (quotaInfo.length > 0) {
|
||||||
const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
||||||
healthByFamily[family].total++;
|
|
||||||
healthByFamily[family].weighted += avgHealth;
|
|
||||||
totalHealthSum += avgHealth;
|
|
||||||
totalModelCount++;
|
|
||||||
}
|
}
|
||||||
|
// If quotaInfo is empty, avgHealth remains 0 (depleted/unknown)
|
||||||
|
|
||||||
|
healthByFamily[family].total++;
|
||||||
|
healthByFamily[family].weighted += avgHealth;
|
||||||
|
totalHealthSum += avgHealth;
|
||||||
|
totalModelCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update overall health for dashboard display
|
// Update overall health for dashboard display
|
||||||
@@ -193,9 +197,9 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const familyColors = {
|
const familyColors = {
|
||||||
claude: getThemeColor("--color-neon-purple"),
|
claude: getThemeColor("--color-neon-purple") || "#a855f7",
|
||||||
gemini: getThemeColor("--color-neon-green"),
|
gemini: getThemeColor("--color-neon-green") || "#22c55e",
|
||||||
unknown: getThemeColor("--color-neon-cyan"),
|
unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -240,7 +244,9 @@ window.DashboardCharts.updateCharts = function (component) {
|
|||||||
|
|
||||||
// Inactive segment
|
// Inactive segment
|
||||||
data.push(inactiveVal);
|
data.push(inactiveVal);
|
||||||
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.1));
|
// Use higher opacity (0.6) to ensure the ring color matches the legend more closely
|
||||||
|
// while still differentiating "depleted" from "active" (1.0 opacity)
|
||||||
|
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6));
|
||||||
labels.push(depletedLabel);
|
labels.push(depletedLabel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ window.DashboardStats = window.DashboardStats || {};
|
|||||||
*
|
*
|
||||||
* 统计逻辑:
|
* 统计逻辑:
|
||||||
* 1. 仅统计启用的账号(enabled !== false)
|
* 1. 仅统计启用的账号(enabled !== false)
|
||||||
* 2. 优先统计核心模型(Sonnet/Opus/Pro/Flash)的配额
|
* 2. 检查账号下所有追踪模型的配额
|
||||||
* 3. 配额 > 5% 视为 active,否则为 limited
|
* 3. 如果任一追踪模型配额 <= 5%,则标记为 limited (Rate Limited Cooldown)
|
||||||
* 4. 状态非 'ok' 的账号归为 limited
|
* 4. 如果所有追踪模型配额 > 5%,则标记为 active
|
||||||
|
* 5. 状态非 'ok' 的账号归为 limited
|
||||||
*
|
*
|
||||||
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
||||||
* @param {object} component.stats - 统计数据对象(会被修改)
|
* @param {object} component.stats - 统计数据对象(会被修改)
|
||||||
@@ -37,24 +38,32 @@ window.DashboardStats.updateStats = function(component) {
|
|||||||
const accounts = Alpine.store('data').accounts;
|
const accounts = Alpine.store('data').accounts;
|
||||||
let active = 0, limited = 0;
|
let active = 0, limited = 0;
|
||||||
|
|
||||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
|
||||||
|
|
||||||
// Only count enabled accounts in statistics
|
// Only count enabled accounts in statistics
|
||||||
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
||||||
|
|
||||||
enabledAccounts.forEach(acc => {
|
enabledAccounts.forEach(acc => {
|
||||||
if (acc.status === 'ok') {
|
if (acc.status === 'ok') {
|
||||||
const limits = Object.entries(acc.limits || {});
|
const limits = Object.entries(acc.limits || {});
|
||||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
|
||||||
|
|
||||||
if (!hasActiveCore) {
|
if (limits.length === 0) {
|
||||||
const hasAnyCore = limits.some(([id]) => isCore(id));
|
// No limit data available, consider limited to be safe
|
||||||
if (!hasAnyCore) {
|
limited++;
|
||||||
hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasActiveCore) active++; else limited++;
|
// Check if ANY tracked model is rate limited (<= 5%)
|
||||||
|
// We consider all models in the limits object as "tracked"
|
||||||
|
const hasRateLimitedModel = limits.some(([_, l]) => {
|
||||||
|
// Treat null/undefined fraction as 0 (limited)
|
||||||
|
if (!l || l.remainingFraction === null || l.remainingFraction === undefined) return true;
|
||||||
|
return l.remainingFraction <= 0.05;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRateLimitedModel) {
|
||||||
|
limited++;
|
||||||
|
} else {
|
||||||
|
active++;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
limited++;
|
limited++;
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,25 @@ window.DashboardStats.updateStats = function(component) {
|
|||||||
component.stats.active = active;
|
component.stats.active = active;
|
||||||
component.stats.limited = limited;
|
component.stats.limited = limited;
|
||||||
|
|
||||||
|
// Calculate model usage for rate limit details
|
||||||
|
let totalLimitedModels = 0;
|
||||||
|
let totalTrackedModels = 0;
|
||||||
|
|
||||||
|
enabledAccounts.forEach(acc => {
|
||||||
|
const limits = Object.entries(acc.limits || {});
|
||||||
|
limits.forEach(([id, l]) => {
|
||||||
|
totalTrackedModels++;
|
||||||
|
if (!l || l.remainingFraction <= 0.05) {
|
||||||
|
totalLimitedModels++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
component.stats.modelUsage = {
|
||||||
|
limited: totalLimitedModels,
|
||||||
|
total: totalTrackedModels
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate subscription tier distribution
|
// Calculate subscription tier distribution
|
||||||
const subscription = { ultra: 0, pro: 0, free: 0 };
|
const subscription = { ultra: 0, pro: 0, free: 0 };
|
||||||
enabledAccounts.forEach(acc => {
|
enabledAccounts.forEach(acc => {
|
||||||
|
|||||||
@@ -227,7 +227,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
quotaInfo.push({ pct });
|
quotaInfo.push({ pct });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (quotaInfo.length === 0) return;
|
if (quotaInfo.length === 0) {
|
||||||
|
// Include model even if no quota info is available (treat as 0% or unknown)
|
||||||
|
// This ensures the family appears in the charts
|
||||||
|
}
|
||||||
|
|
||||||
rows.push({ modelId, family, quotaInfo });
|
rows.push({ modelId, family, quotaInfo });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
active: "ACTIVE",
|
active: "ACTIVE",
|
||||||
operational: "Operational",
|
operational: "Operational",
|
||||||
rateLimited: "RATE LIMITED",
|
rateLimited: "RATE LIMITED",
|
||||||
|
quotasDepleted: "{count}/{total} Quotas Depleted",
|
||||||
|
quotasDepletedTitle: "QUOTAS DEPLETED",
|
||||||
|
outOfTracked: "Out of {total} Tracked",
|
||||||
cooldown: "Cooldown",
|
cooldown: "Cooldown",
|
||||||
searchPlaceholder: "Search models...",
|
searchPlaceholder: "Search models...",
|
||||||
allAccounts: "All Accounts",
|
allAccounts: "All Accounts",
|
||||||
@@ -273,6 +276,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
active: "活跃状态",
|
active: "活跃状态",
|
||||||
operational: "运行中",
|
operational: "运行中",
|
||||||
rateLimited: "受限状态",
|
rateLimited: "受限状态",
|
||||||
|
quotasDepleted: "{count}/{total} 配额耗尽",
|
||||||
|
quotasDepletedTitle: "配额耗尽数",
|
||||||
|
outOfTracked: "共追踪 {total} 个",
|
||||||
cooldown: "冷却中",
|
cooldown: "冷却中",
|
||||||
searchPlaceholder: "搜索模型...",
|
searchPlaceholder: "搜索模型...",
|
||||||
allAccounts: "所有账号",
|
allAccounts: "所有账号",
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<!-- Skeleton Loading (仅在首次加载时显示) -->
|
<!-- Skeleton Loading (仅在首次加载时显示) -->
|
||||||
<div x-show="$store.data.initialLoad" class="space-y-6">
|
<div x-show="$store.data.initialLoad" class="space-y-6">
|
||||||
<!-- Skeleton Stats Grid -->
|
<!-- Skeleton Stats Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
<div class="skeleton-stat-card"></div>
|
<div class="skeleton-stat-card"></div>
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
<!-- Actual Content (首次加载完成后显示) -->
|
<!-- Actual Content (首次加载完成后显示) -->
|
||||||
<div x-show="!$store.data.initialLoad" class="space-y-6">
|
<div x-show="!$store.data.initialLoad" class="space-y-6">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
<div
|
<div
|
||||||
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
|
||||||
@click="$store.global.activeTab = 'accounts'"
|
@click="$store.global.activeTab = 'accounts'"
|
||||||
@@ -131,9 +132,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-orange-500/30 hover:bg-orange-500/5 transition-all duration-300 group relative cursor-pointer"
|
||||||
|
@click="$store.global.activeTab = 'models'"
|
||||||
|
:title="$store.global.t('clickToViewModels')">
|
||||||
|
<div class="absolute top-3 right-3 text-gray-700/40 group-hover:text-orange-500/70 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-white font-mono text-4xl font-bold mb-1" x-text="stats.modelUsage ? stats.modelUsage.limited : 0"></div>
|
||||||
|
<div class="stat-title text-gray-500 font-mono text-xs uppercase tracking-wider truncate"
|
||||||
|
x-text="$store.global.t('quotasDepletedTitle')"></div>
|
||||||
|
<div class="stat-desc text-orange-500/60 text-[10px] truncate flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('outOfTracked', {total: stats.modelUsage ? stats.modelUsage.total : 0})"></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>
|
||||||
|
|
||||||
<!-- Global Quota Chart -->
|
<!-- Global Quota Chart -->
|
||||||
<div
|
<div
|
||||||
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 col-span-1 lg:col-start-4 lg:row-start-1 h-full flex items-center justify-between gap-3 overflow-hidden relative group hover:border-space-border/60 transition-colors">
|
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 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-14 w-14 lg:h-16 lg:w-16 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user