+
Use CLI Command
diff --git a/public/js/app-init.js b/public/js/app-init.js
index c93a32d..030d86c 100644
--- a/public/js/app-init.js
+++ b/public/js/app-init.js
@@ -25,8 +25,8 @@ document.addEventListener('alpine:init', () => {
// Chart Defaults
if (typeof Chart !== 'undefined') {
- Chart.defaults.color = '#71717a';
- Chart.defaults.borderColor = '#27272a';
+ Chart.defaults.color = window.utils.getThemeColor('--color-text-dim');
+ Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border');
Chart.defaults.font.family = '"JetBrains Mono", monospace';
}
diff --git a/public/js/components/account-manager.js b/public/js/components/account-manager.js
index c5a677a..04501c8 100644
--- a/public/js/components/account-manager.js
+++ b/public/js/components/account-manager.js
@@ -6,99 +6,105 @@ window.Components = window.Components || {};
window.Components.accountManager = () => ({
async refreshAccount(email) {
- Alpine.store('global').showToast(`Refreshing ${email}...`, 'info');
- const password = Alpine.store('global').webuiPassword;
+ const store = Alpine.store('global');
+ store.showToast(store.t('refreshingAccount', { email }), 'info');
+ const password = store.webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
- if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+ if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
- Alpine.store('global').showToast(`Refreshed ${email}`, 'success');
+ store.showToast(store.t('refreshedAccount', { email }), 'success');
Alpine.store('data').fetchData();
} else {
- Alpine.store('global').showToast(data.error || 'Refresh failed', 'error');
+ store.showToast(data.error || store.t('refreshFailed'), 'error');
}
} catch (e) {
- Alpine.store('global').showToast('Refresh failed: ' + e.message, 'error');
+ store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error');
}
},
async toggleAccount(email, enabled) {
- const password = Alpine.store('global').webuiPassword;
+ const store = Alpine.store('global');
+ const password = store.webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}, password);
- if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+ if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
- Alpine.store('global').showToast(`Account ${email} ${enabled ? 'enabled' : 'disabled'}`, 'success');
+ const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
+ store.showToast(store.t('accountToggled', { email, status }), 'success');
Alpine.store('data').fetchData();
} else {
- Alpine.store('global').showToast(data.error || 'Toggle failed', 'error');
+ store.showToast(data.error || store.t('toggleFailed'), 'error');
}
} catch (e) {
- Alpine.store('global').showToast('Toggle failed: ' + e.message, 'error');
+ store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
}
},
async fixAccount(email) {
- Alpine.store('global').showToast(`Re-authenticating ${email}...`, 'info');
- const password = Alpine.store('global').webuiPassword;
+ const store = Alpine.store('global');
+ store.showToast(store.t('reauthenticating', { email }), 'info');
+ const password = store.webuiPassword;
try {
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
- if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+ if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
} else {
- Alpine.store('global').showToast(data.error || 'Failed to get auth URL', 'error');
+ store.showToast(data.error || store.t('authUrlFailed'), 'error');
}
} catch (e) {
- Alpine.store('global').showToast('Failed: ' + e.message, 'error');
+ store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error');
}
},
async deleteAccount(email) {
- if (!confirm(Alpine.store('global').t('confirmDelete'))) return;
- const password = Alpine.store('global').webuiPassword;
+ const store = Alpine.store('global');
+ if (!confirm(store.t('confirmDelete'))) return;
+ const password = store.webuiPassword;
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
- if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+ if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
- Alpine.store('global').showToast(`Deleted ${email}`, 'success');
+ store.showToast(store.t('deletedAccount', { email }), 'success');
Alpine.store('data').fetchData();
} else {
- Alpine.store('global').showToast(data.error || 'Delete failed', 'error');
+ store.showToast(data.error || store.t('deleteFailed'), 'error');
}
} catch (e) {
- Alpine.store('global').showToast('Delete failed: ' + e.message, 'error');
+ store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error');
}
},
async reloadAccounts() {
- const password = Alpine.store('global').webuiPassword;
+ const store = Alpine.store('global');
+ const password = store.webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
- if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+ if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
- Alpine.store('global').showToast('Accounts reloaded', 'success');
+ store.showToast(store.t('accountsReloaded'), 'success');
Alpine.store('data').fetchData();
} else {
- Alpine.store('global').showToast(data.error || 'Reload failed', 'error');
+ store.showToast(data.error || store.t('reloadFailed'), 'error');
}
} catch (e) {
- Alpine.store('global').showToast('Reload failed: ' + e.message, 'error');
+ store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
}
}
});
diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js
index 85c15be..0d1614c 100644
--- a/public/js/components/claude-config.js
+++ b/public/js/components/claude-config.js
@@ -44,9 +44,9 @@ window.Components.claudeConfig = () => ({
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
- Alpine.store('global').showToast('Claude config saved!', 'success');
+ Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success');
} catch (e) {
- Alpine.store('global').showToast('Failed to save config: ' + e.message, 'error');
+ Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error');
} finally {
this.loading = false;
}
diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js
index 361ee38..d41f841 100644
--- a/public/js/components/dashboard.js
+++ b/public/js/components/dashboard.js
@@ -4,17 +4,50 @@
*/
window.Components = window.Components || {};
+// Helper to get CSS variable values (alias to window.utils.getThemeColor)
+const getThemeColor = (name) => window.utils.getThemeColor(name);
+
+// Color palette for different families and models
+const FAMILY_COLORS = {
+ get claude() { return getThemeColor('--color-neon-purple'); },
+ get gemini() { return getThemeColor('--color-neon-green'); },
+ get other() { return getThemeColor('--color-neon-cyan'); }
+};
+
+const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => getThemeColor(`--color-chart-${i + 1}`));
+
window.Components.dashboard = () => ({
- stats: { total: 0, active: 0, limited: 0, overallHealth: 0 },
- charts: { quotaDistribution: null },
+ stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
+ charts: { quotaDistribution: null, usageTrend: null },
+
+ // Usage stats
+ usageStats: { total: 0, today: 0, thisHour: 0 },
+ historyData: {},
+
+ // Hierarchical model tree: { claude: ['opus-4-5', 'sonnet-4-5'], gemini: ['3-flash'] }
+ modelTree: {},
+ families: [], // ['claude', 'gemini']
+
+ // Display mode: 'family' or 'model'
+ displayMode: 'model',
+
+ // Selection state
+ selectedFamilies: [],
+ selectedModels: {}, // { claude: ['opus-4-5'], gemini: ['3-flash'] }
+
+ showModelFilter: false,
init() {
+ // Load saved preferences from localStorage
+ this.loadPreferences();
+
// Update stats when dashboard becomes active
this.$watch('$store.global.activeTab', (val) => {
if (val === 'dashboard') {
this.$nextTick(() => {
this.updateStats();
this.updateCharts();
+ this.fetchHistory();
});
}
});
@@ -32,22 +65,456 @@ window.Components.dashboard = () => ({
this.$nextTick(() => {
this.updateStats();
this.updateCharts();
+ this.fetchHistory();
});
}
+
+ // Refresh history every 5 minutes
+ setInterval(() => {
+ if (this.$store.global.activeTab === 'dashboard') {
+ this.fetchHistory();
+ }
+ }, 300000);
+ },
+
+ loadPreferences() {
+ try {
+ const saved = localStorage.getItem('dashboard_chart_prefs');
+ if (saved) {
+ const prefs = JSON.parse(saved);
+ this.displayMode = prefs.displayMode || 'model';
+ this.selectedFamilies = prefs.selectedFamilies || [];
+ this.selectedModels = prefs.selectedModels || {};
+ }
+ } catch (e) {
+ console.error('Failed to load dashboard preferences:', e);
+ }
+ },
+
+ savePreferences() {
+ try {
+ localStorage.setItem('dashboard_chart_prefs', JSON.stringify({
+ displayMode: this.displayMode,
+ selectedFamilies: this.selectedFamilies,
+ selectedModels: this.selectedModels
+ }));
+ } catch (e) {
+ console.error('Failed to save dashboard preferences:', e);
+ }
+ },
+
+ async fetchHistory() {
+ try {
+ const password = Alpine.store('global').webuiPassword;
+ const { response, newPassword } = await window.utils.request('/api/stats/history', {}, password);
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
+
+ if (response.ok) {
+ const history = await response.json();
+ this.historyData = history;
+ this.processHistory(history);
+ this.stats.hasTrendData = true;
+ }
+ } catch (error) {
+ console.error('Failed to fetch usage history:', error);
+ this.stats.hasTrendData = true;
+ }
+ },
+
+ processHistory(history) {
+ // Build model tree from hierarchical data
+ const tree = {};
+ let total = 0, today = 0, thisHour = 0;
+
+ const now = new Date();
+ const todayStart = new Date(now);
+ todayStart.setHours(0, 0, 0, 0);
+ const currentHour = new Date(now);
+ currentHour.setMinutes(0, 0, 0);
+
+ Object.entries(history).forEach(([iso, hourData]) => {
+ const timestamp = new Date(iso);
+
+ // Process each family in the hour data
+ Object.entries(hourData).forEach(([key, value]) => {
+ // Skip metadata keys
+ if (key === '_total' || key === 'total') return;
+
+ // Handle hierarchical format: { claude: { "opus-4-5": 10, "_subtotal": 10 } }
+ if (typeof value === 'object' && value !== null) {
+ if (!tree[key]) tree[key] = new Set();
+
+ Object.keys(value).forEach(modelName => {
+ if (modelName !== '_subtotal') {
+ tree[key].add(modelName);
+ }
+ });
+ }
+ // Skip old flat format keys (claude, gemini as numbers)
+ });
+
+ // Calculate totals
+ const hourTotal = hourData._total || hourData.total || 0;
+ total += hourTotal;
+
+ if (timestamp >= todayStart) {
+ today += hourTotal;
+ }
+ if (timestamp.getTime() === currentHour.getTime()) {
+ thisHour = hourTotal;
+ }
+ });
+
+ this.usageStats = { total, today, thisHour };
+
+ // Convert Sets to sorted arrays
+ this.modelTree = {};
+ Object.entries(tree).forEach(([family, models]) => {
+ this.modelTree[family] = Array.from(models).sort();
+ });
+ this.families = Object.keys(this.modelTree).sort();
+
+ // Auto-select new families/models that haven't been configured
+ this.autoSelectNew();
+
+ this.updateTrendChart();
+ },
+
+ autoSelectNew() {
+ // If no preferences saved, select all
+ if (this.selectedFamilies.length === 0 && Object.keys(this.selectedModels).length === 0) {
+ this.selectedFamilies = [...this.families];
+ this.families.forEach(family => {
+ this.selectedModels[family] = [...(this.modelTree[family] || [])];
+ });
+ this.savePreferences();
+ return;
+ }
+
+ // Add new families/models that appeared
+ this.families.forEach(family => {
+ if (!this.selectedFamilies.includes(family)) {
+ this.selectedFamilies.push(family);
+ }
+ if (!this.selectedModels[family]) {
+ this.selectedModels[family] = [];
+ }
+ (this.modelTree[family] || []).forEach(model => {
+ if (!this.selectedModels[family].includes(model)) {
+ this.selectedModels[family].push(model);
+ }
+ });
+ });
+ },
+
+ autoSelectTopN(n = 5) {
+ // Calculate usage for each model over past 24 hours
+ const usage = {};
+ const now = Date.now();
+ const dayAgo = now - 24 * 60 * 60 * 1000;
+
+ Object.entries(this.historyData).forEach(([iso, hourData]) => {
+ const timestamp = new Date(iso).getTime();
+ if (timestamp < dayAgo) return;
+
+ Object.entries(hourData).forEach(([family, familyData]) => {
+ if (typeof familyData === 'object' && family !== '_total') {
+ Object.entries(familyData).forEach(([model, count]) => {
+ if (model !== '_subtotal') {
+ const key = `${family}:${model}`;
+ usage[key] = (usage[key] || 0) + count;
+ }
+ });
+ }
+ });
+ });
+
+ // Sort by usage and take top N
+ const sorted = Object.entries(usage)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, n);
+
+ // Clear current selection
+ this.selectedFamilies = [];
+ this.selectedModels = {};
+
+ // Select top N models
+ sorted.forEach(([key]) => {
+ const [family, model] = key.split(':');
+ if (!this.selectedFamilies.includes(family)) {
+ this.selectedFamilies.push(family);
+ }
+ if (!this.selectedModels[family]) {
+ this.selectedModels[family] = [];
+ }
+ this.selectedModels[family].push(model);
+ });
+
+ this.savePreferences();
+ this.refreshChart();
+ },
+
+ // Toggle display mode between family and model level
+ setDisplayMode(mode) {
+ this.displayMode = mode;
+ this.savePreferences();
+ this.updateTrendChart();
+ },
+
+ // Toggle family selection
+ toggleFamily(family) {
+ const index = this.selectedFamilies.indexOf(family);
+ if (index > -1) {
+ this.selectedFamilies.splice(index, 1);
+ } else {
+ this.selectedFamilies.push(family);
+ }
+ this.savePreferences();
+ this.updateTrendChart();
+ },
+
+ // Toggle model selection within a family
+ toggleModel(family, model) {
+ if (!this.selectedModels[family]) {
+ this.selectedModels[family] = [];
+ }
+ const index = this.selectedModels[family].indexOf(model);
+ if (index > -1) {
+ this.selectedModels[family].splice(index, 1);
+ } else {
+ this.selectedModels[family].push(model);
+ }
+ this.savePreferences();
+ this.updateTrendChart();
+ },
+
+ // Check if family is selected
+ isFamilySelected(family) {
+ return this.selectedFamilies.includes(family);
+ },
+
+ // Check if model is selected
+ isModelSelected(family, model) {
+ return this.selectedModels[family]?.includes(model) || false;
+ },
+
+ // Select all families and models
+ selectAll() {
+ this.selectedFamilies = [...this.families];
+ this.families.forEach(family => {
+ this.selectedModels[family] = [...(this.modelTree[family] || [])];
+ });
+ this.savePreferences();
+ this.updateTrendChart();
+ },
+
+ // Deselect all
+ deselectAll() {
+ this.selectedFamilies = [];
+ this.selectedModels = {};
+ this.savePreferences();
+ this.updateTrendChart();
+ },
+
+ // Get color for family
+ getFamilyColor(family) {
+ return FAMILY_COLORS[family] || FAMILY_COLORS.other;
+ },
+
+ // Get color for model (with index for variation within family)
+ getModelColor(family, modelIndex) {
+ const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8);
+ return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length];
+ },
+
+ // Get count of selected items for display
+ getSelectedCount() {
+ if (this.displayMode === 'family') {
+ return `${this.selectedFamilies.length}/${this.families.length}`;
+ }
+ let selected = 0, total = 0;
+ this.families.forEach(family => {
+ const models = this.modelTree[family] || [];
+ total += models.length;
+ selected += (this.selectedModels[family] || []).length;
+ });
+ return `${selected}/${total}`;
+ },
+
+ updateTrendChart() {
+ const ctx = document.getElementById('usageTrendChart');
+ if (!ctx || typeof Chart === 'undefined') return;
+
+ if (this.charts.usageTrend) {
+ this.charts.usageTrend.destroy();
+ }
+
+ const history = this.historyData;
+ const labels = [];
+ const datasets = [];
+
+ if (this.displayMode === 'family') {
+ // Aggregate by family
+ const dataByFamily = {};
+ this.selectedFamilies.forEach(family => {
+ dataByFamily[family] = [];
+ });
+
+ Object.entries(history).forEach(([iso, hourData]) => {
+ const date = new Date(iso);
+ labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
+
+ this.selectedFamilies.forEach(family => {
+ const familyData = hourData[family];
+ const count = familyData?._subtotal || 0;
+ dataByFamily[family].push(count);
+ });
+ });
+
+ // Build datasets for families
+ this.selectedFamilies.forEach(family => {
+ const color = this.getFamilyColor(family);
+ const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1);
+ const label = Alpine.store('global').t(familyKey);
+ datasets.push(this.createDataset(
+ label,
+ dataByFamily[family],
+ color,
+ ctx
+ ));
+ });
+ } else {
+ // Show individual models
+ const dataByModel = {};
+
+ // Initialize data arrays
+ this.families.forEach(family => {
+ (this.selectedModels[family] || []).forEach(model => {
+ const key = `${family}:${model}`;
+ dataByModel[key] = [];
+ });
+ });
+
+ Object.entries(history).forEach(([iso, hourData]) => {
+ const date = new Date(iso);
+ labels.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
+
+ this.families.forEach(family => {
+ const familyData = hourData[family] || {};
+ (this.selectedModels[family] || []).forEach(model => {
+ const key = `${family}:${model}`;
+ dataByModel[key].push(familyData[model] || 0);
+ });
+ });
+ });
+
+ // Build datasets for models
+ this.families.forEach(family => {
+ (this.selectedModels[family] || []).forEach((model, modelIndex) => {
+ const key = `${family}:${model}`;
+ const color = this.getModelColor(family, modelIndex);
+ datasets.push(this.createDataset(model, dataByModel[key], color, ctx));
+ });
+ });
+ }
+
+ this.charts.usageTrend = new Chart(ctx, {
+ type: 'line',
+ data: { labels, datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: getThemeColor('--color-space-950') || 'rgba(24, 24, 27, 0.9)',
+ titleColor: getThemeColor('--color-text-main'),
+ bodyColor: getThemeColor('--color-text-bright'),
+ borderColor: getThemeColor('--color-space-border'),
+ borderWidth: 1,
+ padding: 10,
+ displayColors: true,
+ callbacks: {
+ label: function(context) {
+ return context.dataset.label + ': ' + context.parsed.y;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ display: true,
+ grid: { display: false },
+ ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } }
+ },
+ y: {
+ display: true,
+ beginAtZero: true,
+ grid: { display: true, color: getThemeColor('--color-space-border') + '1a' || 'rgba(255,255,255,0.05)' },
+ ticks: { color: getThemeColor('--color-text-muted'), font: { size: 10 } }
+ }
+ }
+ }
+ });
+ },
+
+ createDataset(label, data, color, ctx) {
+ const gradient = ctx.getContext('2d').createLinearGradient(0, 0, 0, 200);
+ gradient.addColorStop(0, this.hexToRgba(color, 0.3));
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ return {
+ label,
+ data,
+ borderColor: color,
+ backgroundColor: gradient,
+ borderWidth: 2,
+ tension: 0.4,
+ fill: true,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ pointBackgroundColor: color
+ };
+ },
+
+ hexToRgba(hex, alpha) {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ if (result) {
+ return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
+ }
+ return hex;
},
updateStats() {
const accounts = Alpine.store('data').accounts;
let active = 0, limited = 0;
+
+ const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
+
accounts.forEach(acc => {
if (acc.status === 'ok') {
- const hasQuota = Object.values(acc.limits || {}).some(l => l && l.remainingFraction > 0);
- if (hasQuota) active++; else limited++;
+ 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 (hasActiveCore) active++; else limited++;
} else {
limited++;
}
});
- this.stats = { total: accounts.length, active, limited };
+ this.stats.total = accounts.length;
+ this.stats.active = active;
+ this.stats.limited = limited;
},
updateCharts() {
@@ -59,32 +526,70 @@ window.Components.dashboard = () => ({
}
const rows = Alpine.store('data').quotaRows;
- const familyStats = { claude: { sum: 0, count: 0 }, gemini: { sum: 0, count: 0 }, other: { sum: 0, count: 0 } };
-
- // Calculate overall system health
- let totalHealthSum = 0;
- let totalModelCount = 0;
+ // Dynamic family aggregation (supports any model family)
+ const familyStats = {};
rows.forEach(row => {
- const f = familyStats[row.family] ? row.family : 'other';
- // Use avgQuota if available (new logic), fallback to minQuota (old logic compatibility)
- const quota = row.avgQuota !== undefined ? row.avgQuota : row.minQuota;
-
- familyStats[f].sum += quota;
- familyStats[f].count++;
-
- totalHealthSum += quota;
- totalModelCount++;
+ if (!familyStats[row.family]) {
+ familyStats[row.family] = { used: 0, total: 0 };
+ }
+ row.quotaInfo.forEach(info => {
+ familyStats[row.family].used += info.pct;
+ familyStats[row.family].total += 100;
+ });
});
- this.stats.overallHealth = totalModelCount > 0 ? Math.round(totalHealthSum / totalModelCount) : 0;
+ // Calculate global health
+ const globalTotal = Object.values(familyStats).reduce((sum, f) => sum + f.total, 0);
+ const globalUsed = Object.values(familyStats).reduce((sum, f) => sum + f.used, 0);
+ this.stats.overallHealth = globalTotal > 0 ? Math.round((globalUsed / globalTotal) * 100) : 0;
- const labels = ['Claude', 'Gemini', 'Other'];
- const data = [
- familyStats.claude.count ? Math.round(familyStats.claude.sum / familyStats.claude.count) : 0,
- familyStats.gemini.count ? Math.round(familyStats.gemini.sum / familyStats.gemini.count) : 0,
- familyStats.other.count ? Math.round(familyStats.other.sum / familyStats.other.count) : 0,
- ];
+ // Generate chart data dynamically
+ const familyColors = {
+ 'claude': getThemeColor('--color-neon-purple'),
+ 'gemini': getThemeColor('--color-neon-green'),
+ 'other': getThemeColor('--color-neon-cyan'),
+ 'unknown': '#666666'
+ };
+
+ const families = Object.keys(familyStats).sort();
+ const segmentSize = families.length > 0 ? 100 / families.length : 100;
+
+ const data = [];
+ const colors = [];
+ const labels = [];
+
+ families.forEach(family => {
+ const stats = familyStats[family];
+ const health = stats.total > 0 ? Math.round((stats.used / stats.total) * 100) : 0;
+ const activeVal = (health / 100) * segmentSize;
+ const inactiveVal = segmentSize - activeVal;
+
+ const familyColor = familyColors[family] || familyColors['unknown'];
+
+ // Get translation keys if available, otherwise capitalize
+ const familyName = family.charAt(0).toUpperCase() + family.slice(1);
+ const store = Alpine.store('global');
+
+ // Labels using translations if possible
+ const activeLabel = family === 'claude' ? store.t('claudeActive') :
+ family === 'gemini' ? store.t('geminiActive') :
+ `${familyName} Active`;
+
+ const depletedLabel = family === 'claude' ? store.t('claudeEmpty') :
+ family === 'gemini' ? store.t('geminiEmpty') :
+ `${familyName} Depleted`;
+
+ // Active segment
+ data.push(activeVal);
+ colors.push(familyColor);
+ labels.push(activeLabel);
+
+ // Inactive segment
+ data.push(inactiveVal);
+ colors.push(this.hexToRgba(familyColor, 0.1));
+ labels.push(depletedLabel);
+ });
this.charts.quotaDistribution = new Chart(ctx, {
type: 'doughnut',
@@ -92,24 +597,22 @@ window.Components.dashboard = () => ({
labels: labels,
datasets: [{
data: data,
- backgroundColor: ['#a855f7', '#22c55e', '#52525b'],
- borderColor: '#09090b', // Matches bg-space-900 roughly
+ backgroundColor: colors,
+ borderColor: getThemeColor('--color-space-950'),
borderWidth: 2,
hoverOffset: 0,
- borderRadius: 2
+ borderRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
- cutout: '85%', // Thinner ring
+ cutout: '85%',
+ rotation: -90,
+ circumference: 360,
plugins: {
- legend: {
- display: false // Hide default legend
- },
- tooltip: {
- enabled: false // Disable tooltip for cleaner look, or style it? Let's keep it simple.
- },
+ legend: { display: false },
+ tooltip: { enabled: false },
title: { display: false }
},
animation: {
diff --git a/public/js/data-store.js b/public/js/data-store.js
index d084122..867c937 100644
--- a/public/js/data-store.js
+++ b/public/js/data-store.js
@@ -59,7 +59,8 @@ document.addEventListener('alpine:init', () => {
} catch (error) {
console.error('Fetch error:', error);
this.connectionStatus = 'disconnected';
- Alpine.store('global').showToast('Connection Lost', 'error');
+ const store = Alpine.store('global');
+ store.showToast(store.t('connectionLost'), 'error');
} finally {
this.loading = false;
}
diff --git a/public/js/settings-store.js b/public/js/settings-store.js
index 5d895e8..17916ad 100644
--- a/public/js/settings-store.js
+++ b/public/js/settings-store.js
@@ -44,7 +44,8 @@ document.addEventListener('alpine:init', () => {
localStorage.setItem('antigravity_settings', JSON.stringify(toSave));
if (!silent) {
- Alpine.store('global').showToast('Configuration Saved', 'success');
+ const store = Alpine.store('global');
+ store.showToast(store.t('configSaved'), 'success');
}
// Trigger updates
diff --git a/public/js/store.js b/public/js/store.js
index 72b008a..2aa6c99 100644
--- a/public/js/store.js
+++ b/public/js/store.js
@@ -56,7 +56,14 @@ document.addEventListener('alpine:init', () => {
delete: "Delete",
confirmDelete: "Are you sure you want to remove this account?",
connectGoogle: "Connect Google Account",
- manualReload: "Reload from Disk",
+ reauthenticated: "re-authenticated",
+ added: "added",
+ successfully: "successfully",
+ failedToGetAuthUrl: "Failed to get auth URL",
+ failedToStartOAuth: "Failed to start OAuth flow",
+ family: "Family",
+ model: "Model",
+ activeSuffix: "Active",
// Tabs
tabInterface: "Interface",
tabClaude: "Claude CLI",
@@ -104,6 +111,68 @@ document.addEventListener('alpine:init', () => {
authToken: "Auth Token",
saveConfig: "Save to ~/.claude/settings.json",
envVar: "Env",
+ // New Keys
+ systemName: "ANTIGRAVITY",
+ systemDesc: "CLAUDE PROXY SYSTEM",
+ connectGoogleDesc: "Connect a Google Workspace account to increase your API quota limit. The account will be used to proxy Claude requests via Antigravity.",
+ useCliCommand: "Use CLI Command",
+ close: "Close",
+ requestVolume: "Request Volume",
+ filter: "Filter",
+ all: "All",
+ none: "None",
+ noDataTracked: "No data tracked yet",
+ selectFamilies: "Select families to display",
+ selectModels: "Select models to display",
+ noLogsMatch: "No logs match filter",
+ connecting: "CONNECTING",
+ main: "Main",
+ system: "System",
+ refreshData: "Refresh Data",
+ connectionLost: "Connection Lost",
+ lastUpdated: "Last Updated",
+ grepLogs: "grep logs...",
+ noMatchingModels: "No matching models",
+ typeToSearch: "Type to search or select...",
+ or: "OR",
+ refreshingAccount: "Refreshing {email}...",
+ refreshedAccount: "Refreshed {email}",
+ refreshFailed: "Refresh failed",
+ accountToggled: "Account {email} {status}",
+ toggleFailed: "Toggle failed",
+ reauthenticating: "Re-authenticating {email}...",
+ authUrlFailed: "Failed to get auth URL",
+ deletedAccount: "Deleted {email}",
+ deleteFailed: "Delete failed",
+ accountsReloaded: "Accounts reloaded",
+ reloadFailed: "Reload failed",
+ claudeConfigSaved: "Claude configuration saved",
+ saveConfigFailed: "Failed to save configuration",
+ claudeActive: "Claude Active",
+ claudeEmpty: "Claude Empty",
+ geminiActive: "Gemini Active",
+ geminiEmpty: "Gemini Empty",
+ fix: "FIX",
+ synced: "SYNCED",
+ syncing: "SYNCING...",
+ // Additional
+ reloading: "Reloading...",
+ reloaded: "Reloaded",
+ lines: "lines",
+ enabledSeeLogs: "Enabled (See Logs)",
+ production: "Production",
+ configSaved: "Configuration Saved",
+ enterPassword: "Enter Web UI Password:",
+ ready: "READY",
+ familyClaude: "Claude",
+ familyGemini: "Gemini",
+ familyOther: "Other",
+ enabledStatus: "enabled",
+ disabledStatus: "disabled",
+ logLevelInfo: "INFO",
+ logLevelSuccess: "SUCCESS",
+ logLevelWarn: "WARN",
+ logLevelError: "ERR",
},
zh: {
dashboard: "仪表盘",
@@ -148,6 +217,14 @@ document.addEventListener('alpine:init', () => {
delete: "删除",
confirmDelete: "确定要移除此账号吗?",
connectGoogle: "连接 Google 账号",
+ reauthenticated: "已重新认证",
+ added: "已添加",
+ successfully: "成功",
+ failedToGetAuthUrl: "获取认证链接失败",
+ failedToStartOAuth: "启动 OAuth 流程失败",
+ family: "系列",
+ model: "模型",
+ activeSuffix: "活跃",
manualReload: "重新加载配置",
// Tabs
tabInterface: "界面设置",
@@ -196,14 +273,82 @@ document.addEventListener('alpine:init', () => {
authToken: "认证令牌",
saveConfig: "保存到 ~/.claude/settings.json",
envVar: "环境变量",
+ // New Keys
+ systemName: "ANTIGRAVITY",
+ systemDesc: "CLAUDE 代理系统",
+ connectGoogleDesc: "连接 Google Workspace 账号以增加 API 配额。该账号将用于通过 Antigravity 代理 Claude 请求。",
+ useCliCommand: "使用命令行",
+ close: "关闭",
+ requestVolume: "请求量",
+ filter: "筛选",
+ all: "全选",
+ none: "清空",
+ noDataTracked: "暂无追踪数据",
+ selectFamilies: "选择要显示的系列",
+ selectModels: "选择要显示的模型",
+ noLogsMatch: "没有符合过滤条件的日志",
+ connecting: "正在连接",
+ main: "主菜单",
+ system: "系统",
+ refreshData: "刷新数据",
+ connectionLost: "连接已断开",
+ lastUpdated: "最后更新",
+ grepLogs: "过滤日志...",
+ noMatchingModels: "没有匹配的模型",
+ typeToSearch: "输入以搜索或选择...",
+ or: "或",
+ refreshingAccount: "正在刷新 {email}...",
+ refreshedAccount: "已完成刷新 {email}",
+ refreshFailed: "刷新失败",
+ accountToggled: "账号 {email} 已{status}",
+ toggleFailed: "切换失败",
+ reauthenticating: "正在重新认证 {email}...",
+ authUrlFailed: "获取认证链接失败",
+ deletedAccount: "已删除 {email}",
+ deleteFailed: "删除失败",
+ accountsReloaded: "账号配置已重载",
+ reloadFailed: "重载失败",
+ claudeConfigSaved: "Claude 配置已保存",
+ saveConfigFailed: "保存配置失败",
+ claudeActive: "Claude 活跃",
+ claudeEmpty: "Claude 耗尽",
+ geminiActive: "Gemini 活跃",
+ geminiEmpty: "Gemini 耗尽",
+ fix: "修复",
+ synced: "已同步",
+ syncing: "正在同步...",
+ // Additional
+ reloading: "正在重载...",
+ reloaded: "已重载",
+ lines: "行",
+ enabledSeeLogs: "已启用 (见日志)",
+ production: "生产环境",
+ configSaved: "配置已保存",
+ enterPassword: "请输入 Web UI 密码:",
+ ready: "就绪",
+ familyClaude: "Claude 系列",
+ familyGemini: "Gemini 系列",
+ familyOther: "其他系列",
+ enabledStatus: "已启用",
+ disabledStatus: "已禁用",
+ logLevelInfo: "信息",
+ logLevelSuccess: "成功",
+ logLevelWarn: "警告",
+ logLevelError: "错误",
}
},
// Toast Messages
toast: null,
- t(key) {
- return this.translations[this.lang][key] || key;
+ t(key, params = {}) {
+ let str = this.translations[this.lang][key] || key;
+ if (typeof str === 'string') {
+ Object.keys(params).forEach(p => {
+ str = str.replace(`{${p}}`, params[p]);
+ });
+ }
+ return str;
},
setLang(l) {
diff --git a/public/js/utils.js b/public/js/utils.js
index e3098e7..2926d0b 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -13,7 +13,8 @@ window.utils = {
let response = await fetch(url, options);
if (response.status === 401) {
- const password = prompt('Enter Web UI Password:');
+ const store = Alpine.store('global');
+ const password = prompt(store ? store.t('enterPassword') : 'Enter Web UI Password:');
if (password) {
// Return new password so caller can update state
// This implies we need a way to propagate the new password back
@@ -31,11 +32,16 @@ window.utils = {
},
formatTimeUntil(isoTime) {
+ const store = Alpine.store('global');
const diff = new Date(isoTime) - new Date();
- if (diff <= 0) return 'READY';
+ if (diff <= 0) return store ? store.t('ready') : 'READY';
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}H ${mins % 60}M`;
return `${mins}M`;
+ },
+
+ getThemeColor(name) {
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
};
diff --git a/public/views/accounts.html b/public/views/accounts.html
index 2743554..f64eaf0 100644
--- a/public/views/accounts.html
+++ b/public/views/accounts.html
@@ -1,14 +1,14 @@
-
-
+
+
-
-
+
+
| Enabled |
Identity (Email) |
@@ -28,17 +28,14 @@
Operations |
-
+
-
+
|
-
+
|
|
|
@@ -52,11 +49,12 @@