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:
Badri Narayanan S
2026-01-12 12:12:57 +05:30
committed by GitHub
16 changed files with 706 additions and 260 deletions

View File

@@ -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
};
}
});

View File

@@ -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;
}
}
});
}

View File

@@ -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 {

View File

@@ -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 => {

View File

@@ -23,7 +23,9 @@ document.addEventListener('alpine:init', () => {
filters: {
account: 'all',
family: 'all',
search: ''
search: '',
sortCol: 'avgQuota',
sortAsc: true
},
// Settings for calculation
@@ -32,12 +34,66 @@ document.addEventListener('alpine:init', () => {
// For simplicity, let's keep relevant filters here.
init() {
// Restore from cache first for instant render
this.loadFromCache();
// Watch filters to recompute
// Alpine stores don't have $watch automatically unless inside a component?
// We can manually call compute when filters change.
// Start health check monitoring
this.startHealthCheck();
},
loadFromCache() {
try {
const cached = localStorage.getItem('ag_data_cache');
if (cached) {
const data = JSON.parse(cached);
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
// Check TTL
if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
console.log('Cache expired, skipping restoration');
localStorage.removeItem('ag_data_cache');
return;
}
// Basic validity check
if (data.accounts && data.models) {
this.accounts = data.accounts;
this.models = data.models;
this.modelConfig = data.modelConfig || {};
this.usageHistory = data.usageHistory || {};
// Don't show loading on initial load if we have cache
this.initialLoad = false;
this.computeQuotaRows();
console.log('Restored data from cache');
}
}
} catch (e) {
console.warn('Failed to load cache', e);
}
},
saveToCache() {
try {
const cacheData = {
accounts: this.accounts,
models: this.models,
modelConfig: this.modelConfig,
usageHistory: this.usageHistory,
timestamp: Date.now()
};
localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
} catch (e) {
console.warn('Failed to save cache', e);
}
},
async fetchData() {
// Only show skeleton on initial load, not on refresh
// Only show skeleton on initial load if we didn't restore from cache
if (this.initialLoad) {
this.loading = true;
}
@@ -65,6 +121,7 @@ document.addEventListener('alpine:init', () => {
this.usageHistory = data.history;
}
this.saveToCache(); // Save fresh data
this.computeQuotaRows();
this.lastUpdated = new Date().toLocaleTimeString();
@@ -236,20 +293,52 @@ document.addEventListener('alpine:init', () => {
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
quotaInfo,
pinned: !!config.pinned,
hidden: !!isHidden // Use computed visibility
hidden: !!isHidden, // Use computed visibility
activeCount: quotaInfo.filter(q => q.pct > 0).length
});
});
// Sort: Pinned first, then by avgQuota (descending)
// Sort: Pinned first, then by selected column
const sortCol = this.filters.sortCol;
const sortAsc = this.filters.sortAsc;
this.quotaRows = rows.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.avgQuota - a.avgQuota;
let valA = a[sortCol];
let valB = b[sortCol];
// Handle nulls (always push to bottom)
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
if (typeof valA === 'string' && typeof valB === 'string') {
return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
return sortAsc ? valA - valB : valB - valA;
});
// Trigger Dashboard Update if active
// Ideally dashboard watches this store.
},
setSort(col) {
if (this.filters.sortCol === col) {
this.filters.sortAsc = !this.filters.sortAsc;
} else {
this.filters.sortCol = col;
// Default sort direction: Descending for numbers/stats, Ascending for text/time
if (['avgQuota', 'activeCount'].includes(col)) {
this.filters.sortAsc = false;
} else {
this.filters.sortAsc = true;
}
}
this.computeQuotaRows();
},
getModelFamily(modelId) {
const lower = modelId.toLowerCase();
if (lower.includes('claude')) return 'claude';
@@ -286,8 +375,7 @@ document.addEventListener('alpine:init', () => {
quotaInfo.push({ pct });
});
if (quotaInfo.length === 0) return;
// treat missing quotaInfo as 0%/unknown; still include row
rows.push({ modelId, family, quotaInfo });
});

View File

@@ -5,6 +5,33 @@
document.addEventListener('alpine:init', () => {
Alpine.store('global', {
init() {
// Hash-based routing
const validTabs = ['dashboard', 'models', 'accounts', 'logs', 'settings'];
const getHash = () => window.location.hash.substring(1);
// 1. Initial load from hash
const initialHash = getHash();
if (validTabs.includes(initialHash)) {
this.activeTab = initialHash;
}
// 2. Sync State -> URL
Alpine.effect(() => {
if (validTabs.includes(this.activeTab) && getHash() !== this.activeTab) {
window.location.hash = this.activeTab;
}
});
// 3. Sync URL -> State (Back/Forward buttons)
window.addEventListener('hashchange', () => {
const hash = getHash();
if (validTabs.includes(hash) && this.activeTab !== hash) {
this.activeTab = hash;
}
});
},
// App State
version: '1.0.0',
activeTab: 'dashboard',
@@ -25,6 +52,9 @@ document.addEventListener('alpine:init', () => {
active: "ACTIVE",
operational: "Operational",
rateLimited: "RATE LIMITED",
quotasDepleted: "{count}/{total} Quotas Depleted",
quotasDepletedTitle: "QUOTAS DEPLETED",
outOfTracked: "Out of {total} Tracked",
cooldown: "Cooldown",
searchPlaceholder: "Search models...",
allAccounts: "All Accounts",
@@ -279,6 +309,9 @@ document.addEventListener('alpine:init', () => {
active: "活跃状态",
operational: "运行中",
rateLimited: "受限状态",
quotasDepleted: "{count}/{total} 配额耗尽",
quotasDepletedTitle: "配额耗尽数",
outOfTracked: "共追踪 {total} 个",
cooldown: "冷却中",
searchPlaceholder: "搜索模型...",
allAccounts: "所有账号",