feat: per-account quota threshold protection Resolves #135 - Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global) - New global Minimum Quota Level slider in Settings - Per-account threshold settings via Account Settings modal - Draggable per-account threshold markers on model quota bars - Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config - i18n: quota protection keys for all 5 languages
454 lines
17 KiB
JavaScript
454 lines
17 KiB
JavaScript
/**
|
|
* Account Manager Component
|
|
* Registers itself to window.Components for Alpine.js to consume
|
|
*/
|
|
window.Components = window.Components || {};
|
|
|
|
window.Components.accountManager = () => ({
|
|
searchQuery: '',
|
|
deleteTarget: '',
|
|
refreshing: false,
|
|
toggling: false,
|
|
deleting: false,
|
|
reloading: false,
|
|
selectedAccountEmail: '',
|
|
selectedAccountLimits: {},
|
|
|
|
get filteredAccounts() {
|
|
const accounts = Alpine.store('data').accounts || [];
|
|
if (!this.searchQuery || this.searchQuery.trim() === '') {
|
|
return accounts;
|
|
}
|
|
|
|
const query = this.searchQuery.toLowerCase().trim();
|
|
return accounts.filter(acc => {
|
|
return acc.email.toLowerCase().includes(query) ||
|
|
(acc.projectId && acc.projectId.toLowerCase().includes(query)) ||
|
|
(acc.source && acc.source.toLowerCase().includes(query));
|
|
});
|
|
},
|
|
|
|
formatEmail(email) {
|
|
if (!email || email.length <= 40) return email;
|
|
|
|
const [user, domain] = email.split('@');
|
|
if (!domain) return email;
|
|
|
|
// Preserve domain integrity, truncate username if needed
|
|
if (user.length > 20) {
|
|
return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`;
|
|
}
|
|
return email;
|
|
},
|
|
|
|
async refreshAccount(email) {
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
|
{ method: 'POST' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('refreshedAccount', { email }), 'success');
|
|
Alpine.store('data').fetchData();
|
|
} else {
|
|
throw new Error(data.error || store.t('refreshFailed'));
|
|
}
|
|
}, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
|
|
},
|
|
|
|
async toggleAccount(email, enabled) {
|
|
const store = Alpine.store('global');
|
|
const password = store.webuiPassword;
|
|
|
|
// Optimistic update: immediately update UI
|
|
const dataStore = Alpine.store('data');
|
|
const account = dataStore.accounts.find(a => a.email === email);
|
|
if (account) {
|
|
account.enabled = enabled;
|
|
}
|
|
|
|
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) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
|
|
store.showToast(store.t('accountToggled', { email, status }), 'success');
|
|
// Refresh to confirm server state
|
|
await dataStore.fetchData();
|
|
} else {
|
|
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
|
// Rollback optimistic update on error
|
|
if (account) {
|
|
account.enabled = !enabled;
|
|
}
|
|
await dataStore.fetchData();
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
|
|
// Rollback optimistic update on error
|
|
if (account) {
|
|
account.enabled = !enabled;
|
|
}
|
|
await dataStore.fetchData();
|
|
}
|
|
},
|
|
|
|
async fixAccount(email) {
|
|
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) 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 {
|
|
store.showToast(data.error || store.t('authUrlFailed'), 'error');
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
confirmDeleteAccount(email) {
|
|
this.deleteTarget = email;
|
|
document.getElementById('delete_account_modal').showModal();
|
|
},
|
|
|
|
async executeDelete() {
|
|
const email = this.deleteTarget;
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
`/api/accounts/${encodeURIComponent(email)}`,
|
|
{ method: 'DELETE' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('deletedAccount', { email }), 'success');
|
|
Alpine.store('data').fetchData();
|
|
document.getElementById('delete_account_modal').close();
|
|
this.deleteTarget = '';
|
|
} else {
|
|
throw new Error(data.error || store.t('deleteFailed'));
|
|
}
|
|
}, this, 'deleting', { errorMessage: 'Failed to delete account' });
|
|
},
|
|
|
|
async reloadAccounts() {
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
'/api/accounts/reload',
|
|
{ method: 'POST' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('accountsReloaded'), 'success');
|
|
Alpine.store('data').fetchData();
|
|
} else {
|
|
throw new Error(data.error || store.t('reloadFailed'));
|
|
}
|
|
}, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
|
|
},
|
|
|
|
openQuotaModal(account) {
|
|
this.selectedAccountEmail = account.email;
|
|
this.selectedAccountLimits = account.limits || {};
|
|
document.getElementById('quota_modal').showModal();
|
|
},
|
|
|
|
// Threshold settings
|
|
thresholdDialog: {
|
|
email: '',
|
|
quotaThreshold: null, // null means use global
|
|
modelQuotaThresholds: {},
|
|
saving: false,
|
|
addingModel: false,
|
|
newModelId: '',
|
|
newModelThreshold: 10
|
|
},
|
|
|
|
openThresholdModal(account) {
|
|
this.thresholdDialog = {
|
|
email: account.email,
|
|
// Convert from fraction (0-1) to percentage (0-99) for display
|
|
quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null,
|
|
modelQuotaThresholds: Object.fromEntries(
|
|
Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)])
|
|
),
|
|
saving: false,
|
|
addingModel: false,
|
|
newModelId: '',
|
|
newModelThreshold: 10
|
|
};
|
|
document.getElementById('threshold_modal').showModal();
|
|
},
|
|
|
|
async saveAccountThreshold() {
|
|
const store = Alpine.store('global');
|
|
this.thresholdDialog.saving = true;
|
|
|
|
try {
|
|
// Convert percentage back to fraction
|
|
const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== ''
|
|
? parseFloat(this.thresholdDialog.quotaThreshold) / 100
|
|
: null;
|
|
|
|
// Convert model thresholds from percentage to fraction
|
|
const modelQuotaThresholds = {};
|
|
for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) {
|
|
modelQuotaThresholds[modelId] = parseFloat(pct) / 100;
|
|
}
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
`/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds })
|
|
},
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast('Settings saved', 'success');
|
|
Alpine.store('data').fetchData();
|
|
document.getElementById('threshold_modal').close();
|
|
} else {
|
|
throw new Error(data.error || 'Failed to save settings');
|
|
}
|
|
} catch (e) {
|
|
store.showToast('Failed to save settings: ' + e.message, 'error');
|
|
} finally {
|
|
this.thresholdDialog.saving = false;
|
|
}
|
|
},
|
|
|
|
clearAccountThreshold() {
|
|
this.thresholdDialog.quotaThreshold = null;
|
|
},
|
|
|
|
// Per-model threshold methods
|
|
addModelThreshold() {
|
|
this.thresholdDialog.addingModel = true;
|
|
this.thresholdDialog.newModelId = '';
|
|
this.thresholdDialog.newModelThreshold = 10;
|
|
},
|
|
|
|
updateModelThreshold(modelId, value) {
|
|
const numValue = parseInt(value);
|
|
if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) {
|
|
this.thresholdDialog.modelQuotaThresholds[modelId] = numValue;
|
|
}
|
|
},
|
|
|
|
removeModelThreshold(modelId) {
|
|
delete this.thresholdDialog.modelQuotaThresholds[modelId];
|
|
},
|
|
|
|
confirmAddModelThreshold() {
|
|
const modelId = this.thresholdDialog.newModelId;
|
|
const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10;
|
|
|
|
if (modelId && threshold >= 0 && threshold <= 99) {
|
|
this.thresholdDialog.modelQuotaThresholds[modelId] = threshold;
|
|
this.thresholdDialog.addingModel = false;
|
|
this.thresholdDialog.newModelId = '';
|
|
this.thresholdDialog.newModelThreshold = 10;
|
|
}
|
|
},
|
|
|
|
getAvailableModelsForThreshold() {
|
|
// Get models from data store, exclude already configured ones
|
|
const allModels = Alpine.store('data').models || [];
|
|
const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds);
|
|
return allModels.filter(m => !configured.includes(m));
|
|
},
|
|
|
|
getEffectiveThreshold(account) {
|
|
// Return display string for effective threshold
|
|
if (account.quotaThreshold !== undefined) {
|
|
return Math.round(account.quotaThreshold * 100) + '%';
|
|
}
|
|
// If no per-account threshold, show global value
|
|
const globalThreshold = Alpine.store('data').globalQuotaThreshold;
|
|
if (globalThreshold > 0) {
|
|
return Math.round(globalThreshold * 100) + '% (global)';
|
|
}
|
|
return 'Global';
|
|
},
|
|
|
|
/**
|
|
* 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 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
|
|
};
|
|
|
|
const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0);
|
|
|
|
if (validIds.length === 0) return { percent: null, 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 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;
|
|
};
|
|
|
|
// Sort by priority desc
|
|
validIds.sort((a, b) => getPriority(b) - getPriority(a));
|
|
|
|
const bestModel = validIds[0];
|
|
const val = getQuotaVal(bestModel);
|
|
|
|
return {
|
|
percent: Math.round(val * 100),
|
|
model: bestModel
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Export accounts to JSON file
|
|
*/
|
|
async exportAccounts() {
|
|
const store = Alpine.store('global');
|
|
try {
|
|
const { response, newPassword } = await window.utils.request(
|
|
'/api/accounts/export',
|
|
{},
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
// API returns plain array directly
|
|
if (Array.isArray(data)) {
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `antigravity-accounts-${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
store.showToast(store.t('exportSuccess', { count: data.length }), 'success');
|
|
} else if (data.error) {
|
|
throw new Error(data.error);
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('exportFailed') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Import accounts from JSON file
|
|
* @param {Event} event - file input change event
|
|
*/
|
|
async importAccounts(event) {
|
|
const store = Alpine.store('global');
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const importData = JSON.parse(text);
|
|
|
|
// Support both plain array and wrapped format
|
|
const accounts = Array.isArray(importData) ? importData : (importData.accounts || []);
|
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
throw new Error('Invalid file format: expected accounts array');
|
|
}
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
'/api/accounts/import',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(accounts)
|
|
},
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
const { added, updated, failed } = data.results;
|
|
let msg = store.t('importSuccess') + ` ${added.length} added, ${updated.length} updated`;
|
|
if (failed.length > 0) {
|
|
msg += `, ${failed.length} failed`;
|
|
}
|
|
store.showToast(msg, failed.length > 0 ? 'info' : 'success');
|
|
Alpine.store('data').fetchData();
|
|
} else {
|
|
throw new Error(data.error || 'Import failed');
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('importFailed') + ': ' + e.message, 'error');
|
|
} finally {
|
|
// Reset file input
|
|
event.target.value = '';
|
|
}
|
|
}
|
|
});
|