/** * 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 // Server presets state serverPresets: [], selectedServerPreset: '', loadingPreset: false, savingServerPreset: false, deletingServerPreset: false, newServerPresetName: '', newServerPresetDescription: '', editingPresetMode: false, editingPresetOriginalName: '', presetPreviewExpanded: false, editingPresetConfig: false, editingConfigDraft: { accountSelection: { strategy: 'hybrid' } }, editingConfigErrors: {}, savingPresetConfig: false, configEditMode: 'ui', editingJsonText: '', jsonParseError: null, init() { // Initial fetch if this is the active sub-tab if (this.$store.global.settingsTab === 'server') { this.fetchServerConfig(); this.fetchServerPresets(); } // Watch settings sub-tab (skip initial trigger) this.$watch('$store.global.settingsTab', (tab, oldTab) => { if (tab === 'server' && oldTab !== undefined) { this.fetchServerConfig(); this.fetchServerPresets(); } }); // Cancel config editing when switching presets this.$watch('selectedServerPreset', () => { if (this.editingPresetConfig) { this.cancelPresetConfigEdit(); } }); }, 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 Developer Mode with instant save async toggleDevMode(enabled) { const store = Alpine.store('global'); // Optimistic update const previousDevMode = this.serverConfig.devMode; const previousDebug = this.serverConfig.debug; this.serverConfig.devMode = enabled; this.serverConfig.debug = enabled; try { const { response, newPassword } = await window.utils.request('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ devMode: 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('devModeToggled', { status }), 'success'); // Update data store Alpine.store('data').devMode = enabled; await this.fetchServerConfig(); // Confirm server state } else { throw new Error(data.error || store.t('failedToUpdateDevMode')); } } catch (e) { // Rollback on error this.serverConfig.devMode = previousDevMode; this.serverConfig.debug = previousDebug; store.showToast(store.t('failedToUpdateDevMode') + ': ' + 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 clamped value this.serverConfig[fieldName] = validation.value; 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); }, async toggleRequestThrottling(enabled) { const store = Alpine.store('global'); const previousValue = this.serverConfig.requestThrottlingEnabled; this.serverConfig.requestThrottlingEnabled = enabled; try { const { response, newPassword } = await window.utils.request('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestThrottlingEnabled: enabled }) }, store.webuiPassword); if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { store.showToast(`Request Throttling ${enabled ? 'enabled' : 'disabled'}`, 'success'); } else { throw new Error(data.error || 'Failed to update'); } } catch (e) { this.serverConfig.requestThrottlingEnabled = previousValue; store.showToast('Failed to update Request Throttling: ' + e.message, 'error'); } }, toggleRequestDelayMs(value) { const { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } = window.AppConstants.VALIDATION; this.saveConfigField('requestDelayMs', value, 'Request Delay', (v) => window.Validators.validateRange(v, REQUEST_DELAY_MIN, REQUEST_DELAY_MAX, 'Request Delay')); }, 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] || ''; }, // ========================================== // Server Configuration Presets // ========================================== async fetchServerPresets() { const password = Alpine.store('global').webuiPassword; try { const { response, newPassword } = await window.utils.request('/api/server/presets', {}, password); if (newPassword) Alpine.store('global').webuiPassword = newPassword; if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (data.status === 'ok') { this.serverPresets = data.presets || []; if (this.serverPresets.length > 0 && !this.selectedServerPreset) { this.selectedServerPreset = this.serverPresets[0].name; } } } catch (e) { console.error('Failed to fetch server presets:', e); } }, /** * Load a server preset — applies all config values via POST /api/config */ async loadServerPreset(name) { const preset = this.serverPresets.find(p => p.name === name); if (!preset) return; this.loadingPreset = true; const store = Alpine.store('global'); try { const { response, newPassword } = await window.utils.request('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preset.config) }, store.webuiPassword); if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { store.showToast(store.t('serverPresetLoaded', { name }) || `Preset "${name}" applied`, 'success'); await this.fetchServerConfig(); } else { throw new Error(data.error || 'Failed to apply preset'); } } catch (e) { store.showToast((store.t('failedToLoadServerPreset') || 'Failed to apply preset') + ': ' + e.message, 'error'); } finally { this.loadingPreset = false; } }, /** * Save current server config as a new custom preset */ async saveCurrentAsServerPreset() { this.editingPresetMode = false; this.editingPresetOriginalName = ''; this.newServerPresetName = ''; this.newServerPresetDescription = ''; document.getElementById('save_server_preset_modal').showModal(); }, /** * Edit an existing custom preset's name and description */ editServerPreset() { const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); if (!preset || preset.builtIn) return; this.editingPresetMode = true; this.editingPresetOriginalName = preset.name; this.newServerPresetName = preset.name; this.newServerPresetDescription = preset.description || ''; document.getElementById('save_server_preset_modal').showModal(); }, /** * Execute PATCH to update preset metadata */ async executeEditServerPreset() { const name = this.newServerPresetName.trim(); if (!name) { Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error'); return; } this.savingServerPreset = true; const store = Alpine.store('global'); try { const payload = { name, description: this.newServerPresetDescription.trim() || '' }; const { response, newPassword } = await window.utils.request( `/api/server/presets/${encodeURIComponent(this.editingPresetOriginalName)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, store.webuiPassword ); if (newPassword) store.webuiPassword = newPassword; if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || `HTTP ${response.status}`); } const data = await response.json(); if (data.status === 'ok') { this.serverPresets = data.presets || []; this.selectedServerPreset = name; this.editingPresetMode = false; this.editingPresetOriginalName = ''; this.newServerPresetName = ''; this.newServerPresetDescription = ''; store.showToast(store.t('serverPresetUpdated') || 'Preset updated', 'success'); document.getElementById('save_server_preset_modal').close(); } else { throw new Error(data.error || 'Failed to update preset'); } } catch (e) { store.showToast((store.t('failedToEditServerPreset') || 'Failed to update preset') + ': ' + e.message, 'error'); } finally { this.savingServerPreset = false; } }, async executeSaveServerPreset(name) { if (!name || !name.trim()) { Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error'); return; } this.savingServerPreset = true; const store = Alpine.store('global'); const password = store.webuiPassword; try { // Extract relevant config fields (exclude sensitive/non-tunable) const relevantKeys = [ 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs', 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold', 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs', 'maxCapacityRetries', 'switchAccountDelayMs', 'capacityBackoffTiersMs', 'accountSelection' ]; const presetConfig = {}; relevantKeys.forEach(k => { if (this.serverConfig[k] !== undefined) { presetConfig[k] = JSON.parse(JSON.stringify(this.serverConfig[k])); } }); const payload = { name: name.trim(), config: presetConfig }; if (this.newServerPresetDescription.trim()) { payload.description = this.newServerPresetDescription.trim(); } const { response, newPassword } = await window.utils.request('/api/server/presets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, password); if (newPassword) store.webuiPassword = newPassword; if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || `HTTP ${response.status}`); } const data = await response.json(); if (data.status === 'ok') { this.serverPresets = data.presets || []; this.selectedServerPreset = name.trim(); this.newServerPresetName = ''; this.newServerPresetDescription = ''; store.showToast(store.t('serverPresetSaved') || `Preset "${name}" saved`, 'success'); document.getElementById('save_server_preset_modal').close(); } else { throw new Error(data.error || 'Failed to save preset'); } } catch (e) { store.showToast((store.t('failedToSaveServerPreset') || 'Failed to save preset') + ': ' + e.message, 'error'); } finally { this.savingServerPreset = false; } }, async deleteSelectedServerPreset() { if (!this.selectedServerPreset) return; // Check if built-in const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); if (preset?.builtIn) { Alpine.store('global').showToast(Alpine.store('global').t('cannotDeleteBuiltIn') || 'Cannot delete built-in presets', 'warning'); return; } const store = Alpine.store('global'); const translated = store.t('deletePresetConfirm', { name: this.selectedServerPreset }); const confirmMsg = translated === 'deletePresetConfirm' ? `Delete preset "${this.selectedServerPreset}"?` : translated; if (!confirm(confirmMsg)) return; this.deletingServerPreset = true; try { const { response, newPassword } = await window.utils.request( `/api/server/presets/${encodeURIComponent(this.selectedServerPreset)}`, { method: 'DELETE' }, store.webuiPassword ); if (newPassword) store.webuiPassword = newPassword; if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || `HTTP ${response.status}`); } const data = await response.json(); if (data.status === 'ok') { this.serverPresets = data.presets || []; this.selectedServerPreset = this.serverPresets.length > 0 ? this.serverPresets[0].name : ''; store.showToast(store.t('serverPresetDeleted') || 'Preset deleted', 'success'); } else { throw new Error(data.error || 'Failed to delete preset'); } } catch (e) { store.showToast((store.t('failedToDeleteServerPreset') || 'Failed to delete preset') + ': ' + e.message, 'error'); } finally { this.deletingServerPreset = false; } }, isSelectedPresetBuiltIn() { const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); return preset?.builtIn === true; }, /** * Format a millisecond value to a human-readable string. * e.g. 60000 → "1m", 1000 → "1s", 1500 → "1.5s", 90000 → "1m 30s" */ formatMsValue(ms) { if (ms == null) return '—'; if (ms < 1000) return ms + 'ms'; const totalSeconds = ms / 1000; if (totalSeconds < 60) { return Number.isInteger(totalSeconds) ? totalSeconds + 's' : totalSeconds.toFixed(1) + 's'; } const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (seconds === 0) return minutes + 'm'; return minutes + 'm ' + (Number.isInteger(seconds) ? seconds : seconds.toFixed(1)) + 's'; }, /** * Get preview sections for the currently selected preset. * Returns { strategy, strategyLabel, sections } where each section has { label, rows }. * Each row has { label, value, differs } where differs is true when the preset * value doesn't match the current running serverConfig. */ getPresetPreviewSections() { const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); if (!preset?.config) return null; const cfg = preset.config; const cur = this.serverConfig; const store = Alpine.store('global'); const strategy = cfg.accountSelection?.strategy || 'hybrid'; const currentStrategy = cur.accountSelection?.strategy || 'hybrid'; const differs = (presetVal, currentVal) => { if (presetVal == null && currentVal == null) return false; if (presetVal == null || currentVal == null) return true; return JSON.stringify(presetVal) !== JSON.stringify(currentVal); }; const fmtQuota = (val) => { if (!val || val === 0) return store.t('quotaDisabled') || 'Disabled'; return Math.round(val * 100) + '%'; }; const V = window.AppConstants.VALIDATION; const sections = [ { label: store.t('networkRetry') || 'Network Retry Settings', rows: [ { label: store.t('maxRetries') || 'Max Retries', value: cfg.maxRetries ?? '—', differs: differs(cfg.maxRetries, cur.maxRetries), key: 'maxRetries', min: V.MAX_RETRIES_MIN, max: V.MAX_RETRIES_MAX, step: 1 }, { label: store.t('retryBaseDelay') || 'Retry Base Delay', value: this.formatMsValue(cfg.retryBaseMs), differs: differs(cfg.retryBaseMs, cur.retryBaseMs), key: 'retryBaseMs', min: V.RETRY_BASE_MS_MIN, max: V.RETRY_BASE_MS_MAX, step: 100, suffix: 'ms' }, { label: store.t('retryMaxDelay') || 'Retry Max Delay', value: this.formatMsValue(cfg.retryMaxMs), differs: differs(cfg.retryMaxMs, cur.retryMaxMs), key: 'retryMaxMs', min: V.RETRY_MAX_MS_MIN, max: V.RETRY_MAX_MS_MAX, step: 1000, suffix: 'ms' }, ] }, { label: store.t('rateLimiting') || 'Rate Limiting', rows: [ { label: store.t('defaultCooldown') || 'Default Cooldown', value: this.formatMsValue(cfg.defaultCooldownMs), differs: differs(cfg.defaultCooldownMs, cur.defaultCooldownMs), key: 'defaultCooldownMs', min: V.DEFAULT_COOLDOWN_MIN, max: V.DEFAULT_COOLDOWN_MAX, step: 1000, suffix: 'ms' }, { label: store.t('maxWaitThreshold') || 'Max Wait Before Error', value: this.formatMsValue(cfg.maxWaitBeforeErrorMs), differs: differs(cfg.maxWaitBeforeErrorMs, cur.maxWaitBeforeErrorMs), key: 'maxWaitBeforeErrorMs', min: V.MAX_WAIT_MIN, max: V.MAX_WAIT_MAX, step: 1000, suffix: 'ms' }, { label: store.t('maxAccounts') || 'Max Accounts', value: cfg.maxAccounts ?? '—', differs: differs(cfg.maxAccounts, cur.maxAccounts), key: 'maxAccounts', min: V.MAX_ACCOUNTS_MIN, max: V.MAX_ACCOUNTS_MAX, step: 1 }, { label: store.t('switchAccountDelay') || 'Switch Account Delay', value: this.formatMsValue(cfg.switchAccountDelayMs), differs: differs(cfg.switchAccountDelayMs, cur.switchAccountDelayMs), key: 'switchAccountDelayMs', min: V.SWITCH_ACCOUNT_DELAY_MIN, max: V.SWITCH_ACCOUNT_DELAY_MAX, step: 1000, suffix: 'ms' }, ] }, { label: store.t('quotaProtection') || 'Quota Protection', rows: [ { label: store.t('minimumQuotaLevel') || 'Minimum Quota Level', value: fmtQuota(cfg.globalQuotaThreshold), differs: differs(cfg.globalQuotaThreshold, cur.globalQuotaThreshold), key: 'globalQuotaThreshold', min: V.GLOBAL_QUOTA_THRESHOLD_MIN, max: V.GLOBAL_QUOTA_THRESHOLD_MAX, step: 1, suffix: '%' }, ] }, { label: store.t('errorHandlingTuning') || 'Error Handling', rows: [ { label: store.t('rateLimitDedupWindow') || 'Dedup Window', value: this.formatMsValue(cfg.rateLimitDedupWindowMs), differs: differs(cfg.rateLimitDedupWindowMs, cur.rateLimitDedupWindowMs), key: 'rateLimitDedupWindowMs', min: V.RATE_LIMIT_DEDUP_MIN, max: V.RATE_LIMIT_DEDUP_MAX, step: 1000, suffix: 'ms' }, { label: store.t('maxConsecutiveFailures') || 'Max Consecutive Failures', value: cfg.maxConsecutiveFailures ?? '—', differs: differs(cfg.maxConsecutiveFailures, cur.maxConsecutiveFailures), key: 'maxConsecutiveFailures', min: V.MAX_CONSECUTIVE_FAILURES_MIN, max: V.MAX_CONSECUTIVE_FAILURES_MAX, step: 1 }, { label: store.t('extendedCooldown') || 'Extended Cooldown', value: this.formatMsValue(cfg.extendedCooldownMs), differs: differs(cfg.extendedCooldownMs, cur.extendedCooldownMs), key: 'extendedCooldownMs', min: V.EXTENDED_COOLDOWN_MIN, max: V.EXTENDED_COOLDOWN_MAX, step: 1000, suffix: 'ms' }, { label: store.t('maxCapacityRetries') || 'Max Capacity Retries', value: cfg.maxCapacityRetries ?? '—', differs: differs(cfg.maxCapacityRetries, cur.maxCapacityRetries), key: 'maxCapacityRetries', min: V.MAX_CAPACITY_RETRIES_MIN, max: V.MAX_CAPACITY_RETRIES_MAX, step: 1 }, { label: store.t('capacityBackoffTiers') || 'Capacity Backoff Tiers', value: (cfg.capacityBackoffTiersMs || []).map(v => this.formatMsValue(v)).join(', ') || '—', differs: differs(cfg.capacityBackoffTiersMs, cur.capacityBackoffTiersMs), key: 'capacityBackoffTiersMs', type: 'text' }, ] } ]; // Add hybrid-only sections when strategy is hybrid if (strategy === 'hybrid') { const as = cfg.accountSelection || {}; const curAs = cur.accountSelection || {}; // Scoring Weights const w = as.weights || {}; const curW = curAs.weights || {}; sections.push({ label: store.t('scoringWeights') || 'Scoring Weights', hybridOnly: true, rows: [ { label: store.t('weightHealth') || 'Health Weight', value: w.health ?? '—', differs: differs(w.health, curW.health), key: 'w_health', min: V.W_HEALTH_MIN, max: V.W_HEALTH_MAX, step: 0.1 }, { label: store.t('weightTokens') || 'Tokens Weight', value: w.tokens ?? '—', differs: differs(w.tokens, curW.tokens), key: 'w_tokens', min: V.W_TOKENS_MIN, max: V.W_TOKENS_MAX, step: 0.1 }, { label: store.t('weightQuota') || 'Quota Weight', value: w.quota ?? '—', differs: differs(w.quota, curW.quota), key: 'w_quota', min: V.W_QUOTA_MIN, max: V.W_QUOTA_MAX, step: 0.1 }, { label: store.t('weightLru') || 'LRU Weight', value: w.lru ?? '—', differs: differs(w.lru, curW.lru), key: 'w_lru', min: V.W_LRU_MIN, max: V.W_LRU_MAX, step: 0.01 }, ] }); // Health Score const hs = as.healthScore || {}; const curHs = curAs.healthScore || {}; sections.push({ label: store.t('healthScoreSection') || 'Health Score', hybridOnly: true, rows: [ { label: store.t('hsInitial') || 'Initial Score', value: hs.initial ?? '—', differs: differs(hs.initial, curHs.initial), key: 'hs_initial', min: V.HS_INITIAL_MIN, max: V.HS_INITIAL_MAX, step: 1 }, { label: store.t('hsSuccessReward') || 'Success Reward', value: hs.successReward ?? '—', differs: differs(hs.successReward, curHs.successReward), key: 'hs_successReward', min: V.HS_SUCCESS_REWARD_MIN, max: V.HS_SUCCESS_REWARD_MAX, step: 1 }, { label: store.t('hsRateLimitPenalty') || 'Rate Limit Penalty', value: hs.rateLimitPenalty ?? '—', differs: differs(hs.rateLimitPenalty, curHs.rateLimitPenalty), key: 'hs_rateLimitPenalty', min: V.HS_RATE_LIMIT_PENALTY_MIN, max: V.HS_RATE_LIMIT_PENALTY_MAX, step: 1 }, { label: store.t('hsFailurePenalty') || 'Failure Penalty', value: hs.failurePenalty ?? '—', differs: differs(hs.failurePenalty, curHs.failurePenalty), key: 'hs_failurePenalty', min: V.HS_FAILURE_PENALTY_MIN, max: V.HS_FAILURE_PENALTY_MAX, step: 1 }, { label: store.t('hsRecoveryPerHour') || 'Recovery/Hour', value: hs.recoveryPerHour ?? '—', differs: differs(hs.recoveryPerHour, curHs.recoveryPerHour), key: 'hs_recoveryPerHour', min: V.HS_RECOVERY_PER_HOUR_MIN, max: V.HS_RECOVERY_PER_HOUR_MAX, step: 1 }, { label: store.t('hsMinUsable') || 'Min Usable Score', value: hs.minUsable ?? '—', differs: differs(hs.minUsable, curHs.minUsable), key: 'hs_minUsable', min: V.HS_MIN_USABLE_MIN, max: V.HS_MIN_USABLE_MAX, step: 1 }, { label: store.t('hsMaxScore') || 'Max Score', value: hs.maxScore ?? '—', differs: differs(hs.maxScore, curHs.maxScore), key: 'hs_maxScore', min: V.HS_MAX_SCORE_MIN, max: V.HS_MAX_SCORE_MAX, step: 1 }, ] }); // Token Bucket const tb = as.tokenBucket || {}; const curTb = curAs.tokenBucket || {}; sections.push({ label: store.t('tokenBucketSection') || 'Token Bucket', hybridOnly: true, rows: [ { label: store.t('tbMaxTokens') || 'Max Tokens', value: tb.maxTokens ?? '—', differs: differs(tb.maxTokens, curTb.maxTokens), key: 'tb_maxTokens', min: V.TB_MAX_TOKENS_MIN, max: V.TB_MAX_TOKENS_MAX, step: 1 }, { label: store.t('tbTokensPerMinute') || 'Tokens/Minute', value: tb.tokensPerMinute ?? '—', differs: differs(tb.tokensPerMinute, curTb.tokensPerMinute), key: 'tb_tokensPerMinute', min: V.TB_TOKENS_PER_MINUTE_MIN, max: V.TB_TOKENS_PER_MINUTE_MAX, step: 1 }, { label: store.t('tbInitialTokens') || 'Initial Tokens', value: tb.initialTokens ?? '—', differs: differs(tb.initialTokens, curTb.initialTokens), key: 'tb_initialTokens', min: V.TB_INITIAL_TOKENS_MIN, max: V.TB_INITIAL_TOKENS_MAX, step: 1 }, ] }); // Quota Awareness const q = as.quota || {}; const curQ = curAs.quota || {}; sections.push({ label: store.t('quotaAwarenessSection') || 'Quota Awareness', hybridOnly: true, rows: [ { label: store.t('qLowThreshold') || 'Low Threshold', value: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) + '%' : '—', differs: differs(q.lowThreshold, curQ.lowThreshold), key: 'q_lowThreshold', min: V.Q_LOW_THRESHOLD_MIN, max: V.Q_LOW_THRESHOLD_MAX, step: 1, suffix: '%' }, { label: store.t('qCriticalThreshold') || 'Critical Threshold', value: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) + '%' : '—', differs: differs(q.criticalThreshold, curQ.criticalThreshold), key: 'q_criticalThreshold', min: V.Q_CRITICAL_THRESHOLD_MIN, max: V.Q_CRITICAL_THRESHOLD_MAX, step: 1, suffix: '%' }, { label: store.t('qStaleMs') || 'Stale Data Timeout', value: this.formatMsValue(q.staleMs), differs: differs(q.staleMs, curQ.staleMs), key: 'q_staleMs', min: V.Q_STALE_MS_MIN, max: V.Q_STALE_MS_MAX, step: 1000, suffix: 'ms' }, ] }); } return { strategy, strategyLabel: this.getStrategyLabel(strategy), strategyDiffers: differs(strategy, currentStrategy), sections }; }, // ========================================== // Inline Preset Config Editing // ========================================== /** * Flatten a nested config object into a flat draft for UI editing. * Converts fractions to percentages where needed. */ flattenConfigToDraft(cfg) { const as = cfg.accountSelection || {}; const hs = as.healthScore || {}; const tb = as.tokenBucket || {}; const q = as.quota || {}; const w = as.weights || {}; return { maxRetries: cfg.maxRetries, retryBaseMs: cfg.retryBaseMs, retryMaxMs: cfg.retryMaxMs, defaultCooldownMs: cfg.defaultCooldownMs, maxWaitBeforeErrorMs: cfg.maxWaitBeforeErrorMs, maxAccounts: cfg.maxAccounts, globalQuotaThreshold: cfg.globalQuotaThreshold ? Math.round(cfg.globalQuotaThreshold * 100) : 0, rateLimitDedupWindowMs: cfg.rateLimitDedupWindowMs, maxConsecutiveFailures: cfg.maxConsecutiveFailures, extendedCooldownMs: cfg.extendedCooldownMs, maxCapacityRetries: cfg.maxCapacityRetries, switchAccountDelayMs: cfg.switchAccountDelayMs, capacityBackoffTiersMs: Array.isArray(cfg.capacityBackoffTiersMs) ? cfg.capacityBackoffTiersMs.join(', ') : '', accountSelection: { strategy: as.strategy || 'hybrid' }, // Health score (prefixed hs_) hs_initial: hs.initial, hs_successReward: hs.successReward, hs_rateLimitPenalty: hs.rateLimitPenalty, hs_failurePenalty: hs.failurePenalty, hs_recoveryPerHour: hs.recoveryPerHour, hs_minUsable: hs.minUsable, hs_maxScore: hs.maxScore, // Token bucket (prefixed tb_) tb_maxTokens: tb.maxTokens, tb_tokensPerMinute: tb.tokensPerMinute, tb_initialTokens: tb.initialTokens, // Quota (prefixed q_, fractions → percentages) q_lowThreshold: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) : 0, q_criticalThreshold: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) : 0, q_staleMs: q.staleMs, // Weights (prefixed w_) w_health: w.health, w_tokens: w.tokens, w_quota: w.quota, w_lru: w.lru, }; }, /** * Build a nested config object from a flat UI draft. * Converts percentages back to fractions where needed. */ buildConfigFromDraft(draft) { const cfg = { maxRetries: draft.maxRetries, retryBaseMs: draft.retryBaseMs, retryMaxMs: draft.retryMaxMs, defaultCooldownMs: draft.defaultCooldownMs, maxWaitBeforeErrorMs: draft.maxWaitBeforeErrorMs, maxAccounts: draft.maxAccounts, globalQuotaThreshold: (draft.globalQuotaThreshold || 0) / 100, rateLimitDedupWindowMs: draft.rateLimitDedupWindowMs, maxConsecutiveFailures: draft.maxConsecutiveFailures, extendedCooldownMs: draft.extendedCooldownMs, maxCapacityRetries: draft.maxCapacityRetries, switchAccountDelayMs: draft.switchAccountDelayMs, }; // Parse capacityBackoffTiersMs from comma-separated string if (typeof draft.capacityBackoffTiersMs === 'string' && draft.capacityBackoffTiersMs.trim()) { const tiers = draft.capacityBackoffTiersMs.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n)); if (tiers.length > 0) cfg.capacityBackoffTiersMs = tiers; } else if (Array.isArray(draft.capacityBackoffTiersMs)) { cfg.capacityBackoffTiersMs = draft.capacityBackoffTiersMs; } // Build accountSelection with nested objects cfg.accountSelection = { strategy: draft.accountSelection?.strategy || 'hybrid', healthScore: { initial: draft.hs_initial, successReward: draft.hs_successReward, rateLimitPenalty: draft.hs_rateLimitPenalty, failurePenalty: draft.hs_failurePenalty, recoveryPerHour: draft.hs_recoveryPerHour, minUsable: draft.hs_minUsable, maxScore: draft.hs_maxScore, }, tokenBucket: { maxTokens: draft.tb_maxTokens, tokensPerMinute: draft.tb_tokensPerMinute, initialTokens: draft.tb_initialTokens, }, quota: { lowThreshold: (draft.q_lowThreshold || 0) / 100, criticalThreshold: (draft.q_criticalThreshold || 0) / 100, staleMs: draft.q_staleMs, }, weights: { health: draft.w_health, tokens: draft.w_tokens, quota: draft.w_quota, lru: draft.w_lru, } }; // Clean undefined values from nested objects for (const subKey of ['healthScore', 'tokenBucket', 'quota', 'weights']) { const obj = cfg.accountSelection[subKey]; const cleaned = {}; let hasValues = false; for (const [k, v] of Object.entries(obj)) { if (v !== undefined && v !== null) { cleaned[k] = v; hasValues = true; } } if (hasValues) { cfg.accountSelection[subKey] = cleaned; } else { delete cfg.accountSelection[subKey]; } } return cfg; }, enterPresetConfigEdit() { const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); if (!preset?.config || preset.builtIn) return; this.editingConfigDraft = this.flattenConfigToDraft(preset.config); this.editingConfigErrors = {}; this.editingPresetConfig = true; this.presetPreviewExpanded = true; this.configEditMode = 'ui'; this.editingJsonText = ''; this.jsonParseError = null; }, cancelPresetConfigEdit() { this.editingPresetConfig = false; this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } }; this.editingConfigErrors = {}; this.configEditMode = 'ui'; this.editingJsonText = ''; this.jsonParseError = null; }, switchConfigEditMode(mode) { if (mode === this.configEditMode) return; if (mode === 'json') { // UI → JSON: convert flat draft to nested config, then stringify const nested = this.buildConfigFromDraft(this.editingConfigDraft); this.editingJsonText = JSON.stringify(nested, null, 2); this.jsonParseError = null; this.editingConfigErrors = {}; this.configEditMode = 'json'; } else { // JSON → UI: parse nested config and flatten to draft try { const parsed = JSON.parse(this.editingJsonText); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; return; } this.editingConfigDraft = this.flattenConfigToDraft(parsed); this.editingConfigErrors = {}; this.jsonParseError = null; // Re-validate all numeric fields const allKeys = [ 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs', 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold', 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs', 'maxCapacityRetries', 'switchAccountDelayMs', 'hs_initial', 'hs_successReward', 'hs_rateLimitPenalty', 'hs_failurePenalty', 'hs_recoveryPerHour', 'hs_minUsable', 'hs_maxScore', 'tb_maxTokens', 'tb_tokensPerMinute', 'tb_initialTokens', 'q_lowThreshold', 'q_criticalThreshold', 'q_staleMs', 'w_health', 'w_tokens', 'w_quota', 'w_lru' ]; allKeys.forEach(k => { if (this.editingConfigDraft[k] !== undefined) this.validatePresetConfigField(k, this.editingConfigDraft[k]); }); // Validate capacityBackoffTiersMs text this.validatePresetConfigField('capacityBackoffTiersMs', this.editingConfigDraft.capacityBackoffTiersMs); this.configEditMode = 'ui'; } catch (e) { this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; } } }, validateJsonText() { try { const parsed = JSON.parse(this.editingJsonText); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; } else { this.jsonParseError = null; } } catch { this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; } }, validatePresetConfigField(key, value) { const V = window.AppConstants.VALIDATION; // Special text validation for capacityBackoffTiersMs if (key === 'capacityBackoffTiersMs') { const str = String(value || '').trim(); if (!str) { delete this.editingConfigErrors[key]; return; } const parts = str.split(',').map(s => s.trim()); if (parts.length < V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH || parts.length > V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH) { this.editingConfigErrors[key] = `${V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH}–${V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH} values`; return; } const allValid = parts.every(s => { const n = Number(s); return !isNaN(n) && n >= V.CAPACITY_BACKOFF_TIER_MIN && n <= V.CAPACITY_BACKOFF_TIER_MAX; }); if (!allValid) { this.editingConfigErrors[key] = `each ${V.CAPACITY_BACKOFF_TIER_MIN}–${V.CAPACITY_BACKOFF_TIER_MAX}`; } else { delete this.editingConfigErrors[key]; } return; } const numVal = Number(value); const ranges = { maxRetries: [V.MAX_RETRIES_MIN, V.MAX_RETRIES_MAX], retryBaseMs: [V.RETRY_BASE_MS_MIN, V.RETRY_BASE_MS_MAX], retryMaxMs: [V.RETRY_MAX_MS_MIN, V.RETRY_MAX_MS_MAX], defaultCooldownMs: [V.DEFAULT_COOLDOWN_MIN, V.DEFAULT_COOLDOWN_MAX], maxWaitBeforeErrorMs: [V.MAX_WAIT_MIN, V.MAX_WAIT_MAX], maxAccounts: [V.MAX_ACCOUNTS_MIN, V.MAX_ACCOUNTS_MAX], globalQuotaThreshold: [V.GLOBAL_QUOTA_THRESHOLD_MIN, V.GLOBAL_QUOTA_THRESHOLD_MAX], rateLimitDedupWindowMs: [V.RATE_LIMIT_DEDUP_MIN, V.RATE_LIMIT_DEDUP_MAX], maxConsecutiveFailures: [V.MAX_CONSECUTIVE_FAILURES_MIN, V.MAX_CONSECUTIVE_FAILURES_MAX], extendedCooldownMs: [V.EXTENDED_COOLDOWN_MIN, V.EXTENDED_COOLDOWN_MAX], maxCapacityRetries: [V.MAX_CAPACITY_RETRIES_MIN, V.MAX_CAPACITY_RETRIES_MAX], switchAccountDelayMs: [V.SWITCH_ACCOUNT_DELAY_MIN, V.SWITCH_ACCOUNT_DELAY_MAX], // Health score hs_initial: [V.HS_INITIAL_MIN, V.HS_INITIAL_MAX], hs_successReward: [V.HS_SUCCESS_REWARD_MIN, V.HS_SUCCESS_REWARD_MAX], hs_rateLimitPenalty: [V.HS_RATE_LIMIT_PENALTY_MIN, V.HS_RATE_LIMIT_PENALTY_MAX], hs_failurePenalty: [V.HS_FAILURE_PENALTY_MIN, V.HS_FAILURE_PENALTY_MAX], hs_recoveryPerHour: [V.HS_RECOVERY_PER_HOUR_MIN, V.HS_RECOVERY_PER_HOUR_MAX], hs_minUsable: [V.HS_MIN_USABLE_MIN, V.HS_MIN_USABLE_MAX], hs_maxScore: [V.HS_MAX_SCORE_MIN, V.HS_MAX_SCORE_MAX], // Token bucket tb_maxTokens: [V.TB_MAX_TOKENS_MIN, V.TB_MAX_TOKENS_MAX], tb_tokensPerMinute: [V.TB_TOKENS_PER_MINUTE_MIN, V.TB_TOKENS_PER_MINUTE_MAX], tb_initialTokens: [V.TB_INITIAL_TOKENS_MIN, V.TB_INITIAL_TOKENS_MAX], // Quota q_lowThreshold: [V.Q_LOW_THRESHOLD_MIN, V.Q_LOW_THRESHOLD_MAX], q_criticalThreshold: [V.Q_CRITICAL_THRESHOLD_MIN, V.Q_CRITICAL_THRESHOLD_MAX], q_staleMs: [V.Q_STALE_MS_MIN, V.Q_STALE_MS_MAX], // Weights w_health: [V.W_HEALTH_MIN, V.W_HEALTH_MAX], w_tokens: [V.W_TOKENS_MIN, V.W_TOKENS_MAX], w_quota: [V.W_QUOTA_MIN, V.W_QUOTA_MAX], w_lru: [V.W_LRU_MIN, V.W_LRU_MAX], }; const range = ranges[key]; if (!range) return; if (isNaN(numVal) || numVal < range[0] || numVal > range[1]) { this.editingConfigErrors[key] = `${range[0]}–${range[1]}`; } else { delete this.editingConfigErrors[key]; this.editingConfigDraft[key] = numVal; } }, hasPresetConfigErrors() { if (this.configEditMode === 'json') return !!this.jsonParseError; return Object.keys(this.editingConfigErrors).length > 0; }, async savePresetConfig() { if (this.hasPresetConfigErrors() || this.savingPresetConfig) return; const store = Alpine.store('global'); const presetName = this.selectedServerPreset; if (!presetName) return; let configPayload; if (this.configEditMode === 'json') { // Parse JSON textarea directly into config payload try { configPayload = JSON.parse(this.editingJsonText); } catch { this.jsonParseError = store.t('invalidJson') || 'Invalid JSON'; return; } // Must be a plain object (not null, not an array, not a primitive) if (typeof configPayload !== 'object' || configPayload === null || Array.isArray(configPayload)) { this.jsonParseError = store.t('invalidJson') || 'Invalid JSON'; return; } } else { // Build nested config from flat UI draft configPayload = this.buildConfigFromDraft(this.editingConfigDraft); } this.savingPresetConfig = true; try { const { response, newPassword } = await window.utils.request( `/api/server/presets/${encodeURIComponent(presetName)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config: configPayload }) }, store.webuiPassword ); if (newPassword) store.webuiPassword = newPassword; if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || `HTTP ${response.status}`); } const data = await response.json(); if (data.status === 'ok') { this.serverPresets = data.presets || []; this.editingPresetConfig = false; this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } }; this.editingConfigErrors = {}; this.configEditMode = 'ui'; this.editingJsonText = ''; this.jsonParseError = null; store.showToast(store.t('presetConfigSaved') || 'Preset config updated', 'success'); } else { throw new Error(data.error || 'Failed to save preset config'); } } catch (e) { store.showToast((store.t('failedToSavePresetConfig') || 'Failed to update preset config') + ': ' + e.message, 'error'); } finally { this.savingPresetConfig = false; } } });