Merge pull request #101 from jgor20/overall-enhancements-to-web-ui
feat(webui): Comprehensive UI enhancements, responsive design, and routing improvements
This commit is contained in:
@@ -182,30 +182,56 @@ window.Components.accountManager = () => ({
|
||||
*/
|
||||
getMainModelQuota(account) {
|
||||
const limits = account.limits || {};
|
||||
const modelIds = Object.keys(limits);
|
||||
|
||||
const getQuotaVal = (id) => {
|
||||
const l = limits[id];
|
||||
if (!l) return -1;
|
||||
if (l.remainingFraction !== null) return l.remainingFraction;
|
||||
if (l.resetTime) return 0; // Rate limited
|
||||
return -1; // Unknown
|
||||
};
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
return { percent: null, model: '-' };
|
||||
}
|
||||
const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0);
|
||||
|
||||
if (validIds.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 DEAD_THRESHOLD = 0.01;
|
||||
|
||||
const MODEL_TIERS = [
|
||||
{ pattern: /\bopus\b/, aliveScore: 100, deadScore: 60 },
|
||||
{ pattern: /\bsonnet\b/, aliveScore: 90, deadScore: 55 },
|
||||
// Gemini 3 Pro / Ultra
|
||||
{ pattern: /\bgemini-3\b/, extraCheck: (l) => /\bpro\b/.test(l) || /\bultra\b/.test(l), aliveScore: 80, deadScore: 50 },
|
||||
{ pattern: /\bpro\b/, aliveScore: 75, deadScore: 45 },
|
||||
// Mid/Low Tier
|
||||
{ pattern: /\bhaiku\b/, aliveScore: 30, deadScore: 15 },
|
||||
{ pattern: /\bflash\b/, aliveScore: 20, deadScore: 10 }
|
||||
];
|
||||
|
||||
const selectedModel = priorityModels.find(m => m) || modelIds[0];
|
||||
const quota = limits[selectedModel];
|
||||
const getPriority = (id) => {
|
||||
const lower = id.toLowerCase();
|
||||
const val = getQuotaVal(id);
|
||||
const isAlive = val > DEAD_THRESHOLD;
|
||||
|
||||
for (const tier of MODEL_TIERS) {
|
||||
if (tier.pattern.test(lower)) {
|
||||
if (tier.extraCheck && !tier.extraCheck(lower)) continue;
|
||||
return isAlive ? tier.aliveScore : tier.deadScore;
|
||||
}
|
||||
}
|
||||
|
||||
return isAlive ? 5 : 0;
|
||||
};
|
||||
|
||||
if (!quota || quota.remainingFraction === null) {
|
||||
return { percent: null, model: selectedModel };
|
||||
}
|
||||
// Sort by priority desc
|
||||
validIds.sort((a, b) => getPriority(b) - getPriority(a));
|
||||
|
||||
const bestModel = validIds[0];
|
||||
const val = getQuotaVal(bestModel);
|
||||
|
||||
return {
|
||||
percent: Math.round(quota.remainingFraction * 100),
|
||||
model: selectedModel
|
||||
percent: Math.round(val * 100),
|
||||
model: bestModel
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,13 +45,24 @@ window.Components.dashboard = () => ({
|
||||
this.$watch('$store.data.accounts', () => {
|
||||
if (this.$store.global.activeTab === 'dashboard') {
|
||||
this.updateStats();
|
||||
this.$nextTick(() => this.updateCharts());
|
||||
// Debounce chart updates to prevent rapid flickering
|
||||
if (this._debouncedUpdateCharts) {
|
||||
this._debouncedUpdateCharts();
|
||||
} else {
|
||||
this._debouncedUpdateCharts = window.utils.debounce(() => this.updateCharts(), 100);
|
||||
this._debouncedUpdateCharts();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for history updates from data-store (automatically loaded with account data)
|
||||
this.$watch('$store.data.usageHistory', (newHistory) => {
|
||||
if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) {
|
||||
// Optimization: Skip if data hasn't changed (prevents double render on load)
|
||||
if (this.historyData && JSON.stringify(newHistory) === JSON.stringify(this.historyData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyData = newHistory;
|
||||
this.processHistory(newHistory);
|
||||
this.stats.hasTrendData = true;
|
||||
@@ -59,17 +70,22 @@ window.Components.dashboard = () => ({
|
||||
});
|
||||
|
||||
// Initial update if already on dashboard
|
||||
// Note: Alpine.store('data') may already have data from cache if initialized before this component
|
||||
if (this.$store.global.activeTab === 'dashboard') {
|
||||
this.$nextTick(() => {
|
||||
this.updateStats();
|
||||
this.updateCharts();
|
||||
|
||||
// Load history if already in store
|
||||
// Optimization: Only process history if it hasn't been processed yet
|
||||
// The usageHistory watcher above will handle updates if data changes
|
||||
const history = Alpine.store('data').usageHistory;
|
||||
if (history && Object.keys(history).length > 0) {
|
||||
this.historyData = history;
|
||||
this.processHistory(history);
|
||||
this.stats.hasTrendData = true;
|
||||
// Check if we already have this data to avoid redundant chart update
|
||||
if (!this.historyData || JSON.stringify(history) !== JSON.stringify(this.historyData)) {
|
||||
this.historyData = history;
|
||||
this.processHistory(history);
|
||||
this.stats.hasTrendData = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,16 +135,6 @@ window.DashboardCharts.createDataset = function (label, data, color, canvas) {
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardCharts.updateCharts = function (component) {
|
||||
// Safely destroy existing chart instance FIRST
|
||||
if (component.charts.quotaDistribution) {
|
||||
try {
|
||||
component.charts.quotaDistribution.destroy();
|
||||
} catch (e) {
|
||||
console.error("Failed to destroy quota chart:", e);
|
||||
}
|
||||
component.charts.quotaDistribution = null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("quotaChart");
|
||||
|
||||
// Safety checks
|
||||
@@ -152,6 +142,33 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
console.debug("quotaChart canvas not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// FORCE DESTROY: Check for existing chart on the canvas element property
|
||||
// This handles cases where Component state is lost but DOM persists
|
||||
if (canvas._chartInstance) {
|
||||
console.debug("Destroying existing quota chart from canvas property");
|
||||
try {
|
||||
canvas._chartInstance.destroy();
|
||||
} catch(e) { console.warn(e); }
|
||||
canvas._chartInstance = null;
|
||||
}
|
||||
|
||||
// Also check component state as backup
|
||||
if (component.charts.quotaDistribution) {
|
||||
try {
|
||||
component.charts.quotaDistribution.destroy();
|
||||
} catch(e) { }
|
||||
component.charts.quotaDistribution = null;
|
||||
}
|
||||
|
||||
// Also try Chart.js registry
|
||||
if (typeof Chart !== "undefined" && Chart.getChart) {
|
||||
const regChart = Chart.getChart(canvas);
|
||||
if (regChart) {
|
||||
try { regChart.destroy(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Chart === "undefined") {
|
||||
console.warn("Chart.js not loaded");
|
||||
return;
|
||||
@@ -178,13 +195,17 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
// Calculate average health from quotaInfo (each entry has { pct })
|
||||
// Health = average of all account quotas for this model
|
||||
const quotaInfo = row.quotaInfo || [];
|
||||
let avgHealth = 0;
|
||||
|
||||
if (quotaInfo.length > 0) {
|
||||
const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
||||
healthByFamily[family].total++;
|
||||
healthByFamily[family].weighted += avgHealth;
|
||||
totalHealthSum += avgHealth;
|
||||
totalModelCount++;
|
||||
avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
||||
}
|
||||
// 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
|
||||
@@ -193,9 +214,9 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
: 0;
|
||||
|
||||
const familyColors = {
|
||||
claude: getThemeColor("--color-neon-purple"),
|
||||
gemini: getThemeColor("--color-neon-green"),
|
||||
unknown: getThemeColor("--color-neon-cyan"),
|
||||
claude: getThemeColor("--color-neon-purple") || "#a855f7",
|
||||
gemini: getThemeColor("--color-neon-green") || "#22c55e",
|
||||
unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
|
||||
};
|
||||
|
||||
const data = [];
|
||||
@@ -240,43 +261,52 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
|
||||
// Inactive segment
|
||||
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);
|
||||
});
|
||||
|
||||
// Create Chart
|
||||
try {
|
||||
component.charts.quotaDistribution = new Chart(canvas, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data,
|
||||
backgroundColor: colors,
|
||||
borderColor: getThemeColor("--color-space-950"),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
borderRadius: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "85%",
|
||||
rotation: -90,
|
||||
circumference: 360,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
title: { display: false },
|
||||
},
|
||||
animation: {
|
||||
animateScale: true,
|
||||
animateRotate: true,
|
||||
},
|
||||
},
|
||||
const newChart = new Chart(canvas, {
|
||||
// ... config
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data,
|
||||
backgroundColor: colors,
|
||||
borderColor: getThemeColor("--color-space-950"),
|
||||
borderWidth: 0,
|
||||
hoverOffset: 0,
|
||||
borderRadius: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "85%",
|
||||
rotation: -90,
|
||||
circumference: 360,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
title: { display: false },
|
||||
},
|
||||
animation: {
|
||||
// Disable animation for quota chart to prevent "double refresh" visual glitch
|
||||
duration: 0
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// SAVE INSTANCE TO CANVAS AND COMPONENT
|
||||
canvas._chartInstance = newChart;
|
||||
component.charts.quotaDistribution = newChart;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Failed to create quota chart:", e);
|
||||
}
|
||||
@@ -296,28 +326,46 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
||||
|
||||
console.log("[updateTrendChart] Starting update...");
|
||||
|
||||
// Safely destroy existing chart instance FIRST
|
||||
if (component.charts.usageTrend) {
|
||||
console.log("[updateTrendChart] Destroying existing chart");
|
||||
try {
|
||||
// Stop all animations before destroying to prevent null context errors
|
||||
component.charts.usageTrend.stop();
|
||||
component.charts.usageTrend.destroy();
|
||||
} catch (e) {
|
||||
console.error("[updateTrendChart] Failed to destroy chart:", e);
|
||||
}
|
||||
component.charts.usageTrend = null;
|
||||
const canvas = document.getElementById("usageTrendChart");
|
||||
|
||||
// FORCE DESTROY: Check for existing chart on the canvas element property
|
||||
if (canvas) {
|
||||
if (canvas._chartInstance) {
|
||||
console.debug("Destroying existing trend chart from canvas property");
|
||||
try {
|
||||
canvas._chartInstance.stop();
|
||||
canvas._chartInstance.destroy();
|
||||
} catch(e) { console.warn(e); }
|
||||
canvas._chartInstance = null;
|
||||
}
|
||||
|
||||
// Also try Chart.js registry
|
||||
if (typeof Chart !== "undefined" && Chart.getChart) {
|
||||
const regChart = Chart.getChart(canvas);
|
||||
if (regChart) {
|
||||
try { regChart.stop(); regChart.destroy(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("usageTrendChart");
|
||||
// Also check component state
|
||||
if (component.charts.usageTrend) {
|
||||
try {
|
||||
component.charts.usageTrend.stop();
|
||||
component.charts.usageTrend.destroy();
|
||||
} catch (e) { }
|
||||
component.charts.usageTrend = null;
|
||||
}
|
||||
|
||||
// Safety checks
|
||||
if (!canvas) {
|
||||
console.error("[updateTrendChart] Canvas not found in DOM!");
|
||||
_trendChartUpdateLock = false; // Release lock!
|
||||
return;
|
||||
}
|
||||
if (typeof Chart === "undefined") {
|
||||
console.error("[updateTrendChart] Chart.js not loaded");
|
||||
_trendChartUpdateLock = false; // Release lock!
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,7 +518,7 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
||||
}
|
||||
|
||||
try {
|
||||
component.charts.usageTrend = new Chart(canvas, {
|
||||
const newChart = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
@@ -527,6 +575,11 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// SAVE INSTANCE
|
||||
canvas._chartInstance = newChart;
|
||||
component.charts.usageTrend = newChart;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Failed to create trend chart:", e);
|
||||
} finally {
|
||||
|
||||
@@ -21,9 +21,10 @@ window.DashboardStats = window.DashboardStats || {};
|
||||
*
|
||||
* 统计逻辑:
|
||||
* 1. 仅统计启用的账号(enabled !== false)
|
||||
* 2. 优先统计核心模型(Sonnet/Opus/Pro/Flash)的配额
|
||||
* 3. 配额 > 5% 视为 active,否则为 limited
|
||||
* 4. 状态非 'ok' 的账号归为 limited
|
||||
* 2. 检查账号下所有追踪模型的配额
|
||||
* 3. 如果任一追踪模型配额 <= 5%,则标记为 limited (Rate Limited Cooldown)
|
||||
* 4. 如果所有追踪模型配额 > 5%,则标记为 active
|
||||
* 5. 状态非 'ok' 的账号归为 limited
|
||||
*
|
||||
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
||||
* @param {object} component.stats - 统计数据对象(会被修改)
|
||||
@@ -37,24 +38,32 @@ window.DashboardStats.updateStats = function(component) {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
let active = 0, limited = 0;
|
||||
|
||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
||||
|
||||
// Only count enabled accounts in statistics
|
||||
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
||||
|
||||
enabledAccounts.forEach(acc => {
|
||||
if (acc.status === 'ok') {
|
||||
const limits = Object.entries(acc.limits || {});
|
||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
||||
|
||||
if (!hasActiveCore) {
|
||||
const hasAnyCore = limits.some(([id]) => isCore(id));
|
||||
if (!hasAnyCore) {
|
||||
hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05);
|
||||
}
|
||||
if (limits.length === 0) {
|
||||
// No limit data available, consider limited to be safe
|
||||
limited++;
|
||||
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 {
|
||||
limited++;
|
||||
}
|
||||
@@ -66,6 +75,25 @@ window.DashboardStats.updateStats = function(component) {
|
||||
component.stats.active = active;
|
||||
component.stats.limited = limited;
|
||||
|
||||
// Calculate model usage for rate limit details
|
||||
let totalLimitedModels = 0;
|
||||
let totalTrackedModels = 0;
|
||||
|
||||
enabledAccounts.forEach(acc => {
|
||||
const limits = Object.entries(acc.limits || {});
|
||||
limits.forEach(([id, l]) => {
|
||||
totalTrackedModels++;
|
||||
if (!l || l.remainingFraction == null || l.remainingFraction <= 0.05) {
|
||||
totalLimitedModels++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
component.stats.modelUsage = {
|
||||
limited: totalLimitedModels,
|
||||
total: totalTrackedModels
|
||||
};
|
||||
|
||||
// Calculate subscription tier distribution
|
||||
const subscription = { ultra: 0, pro: 0, free: 0 };
|
||||
enabledAccounts.forEach(acc => {
|
||||
|
||||
Reference in New Issue
Block a user