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
390 lines
16 KiB
JavaScript
390 lines
16 KiB
JavaScript
/**
|
|
* Server Config Component
|
|
* Registers itself to window.Components for Alpine.js to consume
|
|
*/
|
|
window.Components = window.Components || {};
|
|
|
|
window.Components.serverConfig = () => ({
|
|
serverConfig: {},
|
|
loading: false,
|
|
advancedExpanded: false,
|
|
debounceTimers: {}, // Store debounce timers for each config field
|
|
|
|
init() {
|
|
// Initial fetch if this is the active sub-tab
|
|
if (this.activeTab === 'server') {
|
|
this.fetchServerConfig();
|
|
}
|
|
|
|
// Watch local activeTab (from parent settings scope, skip initial trigger)
|
|
this.$watch('activeTab', (tab, oldTab) => {
|
|
if (tab === 'server' && oldTab !== undefined) {
|
|
this.fetchServerConfig();
|
|
}
|
|
});
|
|
},
|
|
|
|
async fetchServerConfig() {
|
|
const password = Alpine.store('global').webuiPassword;
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/config', {}, password);
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error('Failed to fetch config');
|
|
const data = await response.json();
|
|
this.serverConfig = data.config || {};
|
|
} catch (e) {
|
|
console.error('Failed to fetch server config:', e);
|
|
}
|
|
},
|
|
|
|
|
|
|
|
// Password management
|
|
passwordDialog: {
|
|
show: false,
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
},
|
|
|
|
showPasswordDialog() {
|
|
this.passwordDialog = {
|
|
show: true,
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
};
|
|
},
|
|
|
|
hidePasswordDialog() {
|
|
this.passwordDialog = {
|
|
show: false,
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
};
|
|
},
|
|
|
|
async changePassword() {
|
|
const store = Alpine.store('global');
|
|
const { oldPassword, newPassword, confirmPassword } = this.passwordDialog;
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
store.showToast(store.t('passwordsNotMatch'), 'error');
|
|
return;
|
|
}
|
|
if (newPassword.length < 6) {
|
|
store.showToast(store.t('passwordTooShort'), 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { response } = await window.utils.request('/api/config/password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldPassword, newPassword })
|
|
}, store.webuiPassword);
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || store.t('failedToChangePassword'));
|
|
}
|
|
|
|
// Update stored password
|
|
store.webuiPassword = newPassword;
|
|
store.showToast(store.t('passwordChangedSuccess'), 'success');
|
|
this.hidePasswordDialog();
|
|
} catch (e) {
|
|
store.showToast(store.t('failedToChangePassword') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Toggle Debug Mode with instant save
|
|
async toggleDebug(enabled) {
|
|
const store = Alpine.store('global');
|
|
|
|
// Optimistic update
|
|
const previousValue = this.serverConfig.debug;
|
|
this.serverConfig.debug = enabled;
|
|
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ debug: enabled })
|
|
}, store.webuiPassword);
|
|
|
|
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('debugModeToggled', { status }), 'success');
|
|
await this.fetchServerConfig(); // Confirm server state
|
|
} else {
|
|
throw new Error(data.error || store.t('failedToUpdateDebugMode'));
|
|
}
|
|
} catch (e) {
|
|
// Rollback on error
|
|
this.serverConfig.debug = previousValue;
|
|
store.showToast(store.t('failedToUpdateDebugMode') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Toggle Token Cache with instant save
|
|
async toggleTokenCache(enabled) {
|
|
const store = Alpine.store('global');
|
|
|
|
// Optimistic update
|
|
const previousValue = this.serverConfig.persistTokenCache;
|
|
this.serverConfig.persistTokenCache = enabled;
|
|
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ persistTokenCache: enabled })
|
|
}, store.webuiPassword);
|
|
|
|
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('tokenCacheToggled', { status }), 'success');
|
|
await this.fetchServerConfig(); // Confirm server state
|
|
} else {
|
|
throw new Error(data.error || store.t('failedToUpdateTokenCache'));
|
|
}
|
|
} catch (e) {
|
|
// Rollback on error
|
|
this.serverConfig.persistTokenCache = previousValue;
|
|
store.showToast(store.t('failedToUpdateTokenCache') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Generic debounced save method for numeric configs with validation
|
|
async saveConfigField(fieldName, value, displayName, validator = null) {
|
|
const store = Alpine.store('global');
|
|
|
|
// Validate input if validator provided
|
|
if (validator) {
|
|
const validation = window.Validators.validate(value, validator, true);
|
|
if (!validation.isValid) {
|
|
// Rollback to previous value
|
|
this.serverConfig[fieldName] = this.serverConfig[fieldName];
|
|
return;
|
|
}
|
|
value = validation.value;
|
|
} else {
|
|
value = parseInt(value);
|
|
}
|
|
|
|
// Clear existing timer for this field
|
|
if (this.debounceTimers[fieldName]) {
|
|
clearTimeout(this.debounceTimers[fieldName]);
|
|
}
|
|
|
|
// Optimistic update
|
|
const previousValue = this.serverConfig[fieldName];
|
|
this.serverConfig[fieldName] = value;
|
|
|
|
// Set new timer
|
|
this.debounceTimers[fieldName] = setTimeout(async () => {
|
|
try {
|
|
const payload = {};
|
|
payload[fieldName] = value;
|
|
|
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}, store.webuiPassword);
|
|
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('fieldUpdated', { displayName, value }), 'success');
|
|
await this.fetchServerConfig(); // Confirm server state
|
|
} else {
|
|
throw new Error(data.error || store.t('failedToUpdateField', { displayName }));
|
|
}
|
|
} catch (e) {
|
|
// Rollback on error
|
|
this.serverConfig[fieldName] = previousValue;
|
|
store.showToast(store.t('failedToUpdateField', { displayName }) + ': ' + e.message, 'error');
|
|
}
|
|
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
|
|
},
|
|
|
|
// Individual toggle methods for each Advanced Tuning field with validation
|
|
toggleMaxRetries(value) {
|
|
const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('maxRetries', value, 'Max Retries',
|
|
(v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries'));
|
|
},
|
|
|
|
toggleRetryBaseMs(value) {
|
|
const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('retryBaseMs', value, 'Retry Base Delay',
|
|
(v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay'));
|
|
},
|
|
|
|
toggleRetryMaxMs(value) {
|
|
const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('retryMaxMs', value, 'Retry Max Delay',
|
|
(v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay'));
|
|
},
|
|
|
|
toggleDefaultCooldownMs(value) {
|
|
const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown',
|
|
(v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX));
|
|
},
|
|
|
|
toggleMaxWaitBeforeErrorMs(value) {
|
|
const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold',
|
|
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
|
|
},
|
|
|
|
toggleGlobalQuotaThreshold(value) {
|
|
const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
|
|
const store = Alpine.store('global');
|
|
const pct = parseInt(value);
|
|
if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
|
|
|
|
// Store as percentage in UI, convert to fraction for backend
|
|
const fraction = pct / 100;
|
|
|
|
if (this.debounceTimers['globalQuotaThreshold']) {
|
|
clearTimeout(this.debounceTimers['globalQuotaThreshold']);
|
|
}
|
|
|
|
const previousValue = this.serverConfig.globalQuotaThreshold;
|
|
this.serverConfig.globalQuotaThreshold = fraction;
|
|
|
|
this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ globalQuotaThreshold: fraction })
|
|
}, store.webuiPassword);
|
|
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
|
|
await this.fetchServerConfig();
|
|
} else {
|
|
throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
|
|
}
|
|
} catch (e) {
|
|
this.serverConfig.globalQuotaThreshold = previousValue;
|
|
store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
|
|
}
|
|
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
|
|
},
|
|
|
|
toggleMaxAccounts(value) {
|
|
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('maxAccounts', value, 'Max Accounts',
|
|
(v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));
|
|
},
|
|
|
|
toggleRateLimitDedupWindowMs(value) {
|
|
const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',
|
|
(v) => window.Validators.validateTimeout(v, RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX));
|
|
},
|
|
|
|
toggleMaxConsecutiveFailures(value) {
|
|
const { MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('maxConsecutiveFailures', value, 'Max Consecutive Failures',
|
|
(v) => window.Validators.validateRange(v, MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX, 'Max Consecutive Failures'));
|
|
},
|
|
|
|
toggleExtendedCooldownMs(value) {
|
|
const { EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('extendedCooldownMs', value, 'Extended Cooldown',
|
|
(v) => window.Validators.validateTimeout(v, EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX));
|
|
},
|
|
|
|
toggleMaxCapacityRetries(value) {
|
|
const { MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX } = window.AppConstants.VALIDATION;
|
|
this.saveConfigField('maxCapacityRetries', value, 'Max Capacity Retries',
|
|
(v) => window.Validators.validateRange(v, MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX, 'Max Capacity Retries'));
|
|
},
|
|
|
|
// Toggle Account Selection Strategy
|
|
async toggleStrategy(strategy) {
|
|
const store = Alpine.store('global');
|
|
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
|
|
|
if (!validStrategies.includes(strategy)) {
|
|
store.showToast(store.t('invalidStrategy'), 'error');
|
|
return;
|
|
}
|
|
|
|
// Optimistic update
|
|
const previousValue = this.serverConfig.accountSelection?.strategy || 'hybrid';
|
|
if (!this.serverConfig.accountSelection) {
|
|
this.serverConfig.accountSelection = {};
|
|
}
|
|
this.serverConfig.accountSelection.strategy = strategy;
|
|
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ accountSelection: { strategy } })
|
|
}, store.webuiPassword);
|
|
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
const strategyLabel = this.getStrategyLabel(strategy);
|
|
store.showToast(store.t('strategyUpdated', { strategy: strategyLabel }), 'success');
|
|
await this.fetchServerConfig(); // Confirm server state
|
|
} else {
|
|
throw new Error(data.error || store.t('failedToUpdateStrategy'));
|
|
}
|
|
} catch (e) {
|
|
// Rollback on error
|
|
if (!this.serverConfig.accountSelection) {
|
|
this.serverConfig.accountSelection = {};
|
|
}
|
|
this.serverConfig.accountSelection.strategy = previousValue;
|
|
store.showToast(store.t('failedToUpdateStrategy') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Get display label for a strategy
|
|
getStrategyLabel(strategy) {
|
|
const store = Alpine.store('global');
|
|
const labels = {
|
|
'sticky': store.t('strategyStickyLabel'),
|
|
'round-robin': store.t('strategyRoundRobinLabel'),
|
|
'hybrid': store.t('strategyHybridLabel')
|
|
};
|
|
return labels[strategy] || strategy;
|
|
},
|
|
|
|
// Get description for current strategy
|
|
currentStrategyDescription() {
|
|
const store = Alpine.store('global');
|
|
const strategy = this.serverConfig.accountSelection?.strategy || 'hybrid';
|
|
const descriptions = {
|
|
'sticky': store.t('strategyStickyDesc'),
|
|
'round-robin': store.t('strategyRoundRobinDesc'),
|
|
'hybrid': store.t('strategyHybridDesc')
|
|
};
|
|
return descriptions[strategy] || '';
|
|
}
|
|
});
|