feat(config): add configurable max accounts limit (#156)
Adds `maxAccounts` configuration parameter to control the maximum number of Google accounts. **Changes:** - New config field `maxAccounts` (default: 10, range: 1-100) - Settings page: slider control for adjusting limit - Accounts page: counter badge (e.g., "8/10") with visual feedback - Add button disabled when limit reached - Server-side validation on account creation **Breaking Changes:** None
This commit is contained in:
@@ -362,6 +362,7 @@ While most users can use the default settings, you can tune the proxy behavior v
|
||||
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
|
||||
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
||||
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
|
||||
- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
|
||||
|
||||
Refer to `config.example.json` for a complete list of fields and documentation.
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"persistTokenCache": false,
|
||||
|
||||
"requestTimeoutMs": 300000,
|
||||
|
||||
"maxAccounts": 10,
|
||||
"_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.",
|
||||
|
||||
"_profiles": {
|
||||
"development": {
|
||||
|
||||
2
public/css/style.css
generated
2
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -250,6 +250,12 @@ window.Components.serverConfig = () => ({
|
||||
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
|
||||
},
|
||||
|
||||
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',
|
||||
|
||||
@@ -69,6 +69,10 @@ window.AppConstants.VALIDATION = {
|
||||
MAX_WAIT_MIN: 60000,
|
||||
MAX_WAIT_MAX: 1800000,
|
||||
|
||||
// Max accounts range (1 - 100)
|
||||
MAX_ACCOUNTS_MIN: 1,
|
||||
MAX_ACCOUNTS_MAX: 100,
|
||||
|
||||
// Rate limit dedup window (1 - 30 seconds)
|
||||
RATE_LIMIT_DEDUP_MIN: 1000,
|
||||
RATE_LIMIT_DEDUP_MAX: 30000,
|
||||
|
||||
@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
|
||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||
quotaRows: [], // Filtered view
|
||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||
maxAccounts: 10, // Maximum number of accounts allowed (from config)
|
||||
loading: false,
|
||||
initialLoad: true, // Track first load for skeleton screen
|
||||
connectionStatus: 'connecting',
|
||||
@@ -135,6 +136,24 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchVersion(password) {
|
||||
try {
|
||||
const { response } = await window.utils.request('/api/config', {}, password);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.version) {
|
||||
Alpine.store('global').version = data.version;
|
||||
}
|
||||
// Store maxAccounts from config
|
||||
if (data.config && typeof data.config.maxAccounts === 'number') {
|
||||
this.maxAccounts = data.config.maxAccounts;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch version:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async performHealthCheck() {
|
||||
try {
|
||||
// Get password from global store
|
||||
|
||||
@@ -39,8 +39,17 @@
|
||||
</svg>
|
||||
<span x-text="$store.global.t('reload')">Reload</span>
|
||||
</button>
|
||||
<!-- Account Count Indicator -->
|
||||
<div class="flex items-center h-6 px-2 rounded bg-space-800/80 border border-space-border/50"
|
||||
:class="{ 'border-yellow-500/50': $store.data.accounts.length >= $store.data.maxAccounts }">
|
||||
<span class="text-[11px] font-mono"
|
||||
:class="$store.data.accounts.length >= $store.data.maxAccounts ? 'text-yellow-400' : 'text-gray-400'"
|
||||
x-text="$store.data.accounts.length + '/' + $store.data.maxAccounts"></span>
|
||||
</div>
|
||||
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-xs gap-2 shadow-lg shadow-neon-purple/20 h-8"
|
||||
onclick="document.getElementById('add_account_modal').showModal()">
|
||||
:class="{ 'opacity-50 cursor-not-allowed': $store.data.accounts.length >= $store.data.maxAccounts }"
|
||||
:disabled="$store.data.accounts.length >= $store.data.maxAccounts"
|
||||
@click="$store.data.accounts.length < $store.data.maxAccounts && document.getElementById('add_account_modal').showModal()">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
|
||||
@@ -934,6 +934,28 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max Accounts -->
|
||||
<div class="form-control view-card border-space-border/50 hover:border-neon-cyan/50">
|
||||
<label class="label pt-0">
|
||||
<span class="label-text text-gray-400 text-xs">Max Accounts</span>
|
||||
<span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
|
||||
x-text="serverConfig.maxAccounts || 10"></span>
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="range" min="1" max="100" class="custom-range custom-range-cyan flex-1"
|
||||
:value="serverConfig.maxAccounts || 10"
|
||||
:style="`background-size: ${((serverConfig.maxAccounts || 10) - 1) / 99 * 100}% 100%`"
|
||||
@input="toggleMaxAccounts($event.target.value)"
|
||||
aria-label="Max accounts slider">
|
||||
<input type="number" min="1" max="100"
|
||||
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
:value="serverConfig.maxAccounts || 10"
|
||||
@change="toggleMaxAccounts($event.target.value)"
|
||||
aria-label="Max accounts value">
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-500 mt-1">Maximum number of Google accounts allowed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🔀 Account Selection Strategy -->
|
||||
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
|
||||
persistTokenCache: false,
|
||||
defaultCooldownMs: 10000, // 10 seconds
|
||||
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
||||
maxAccounts: 10, // Maximum number of accounts allowed
|
||||
modelMapping: {},
|
||||
// Account selection strategy configuration
|
||||
accountSelection: {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import express from 'express';
|
||||
import { getPublicConfig, saveConfig, config } from '../config.js';
|
||||
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS } from '../constants.js';
|
||||
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
||||
@@ -77,6 +77,7 @@ async function removeAccount(email) {
|
||||
|
||||
/**
|
||||
* Add new account to config
|
||||
* @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only)
|
||||
*/
|
||||
async function addAccount(accountData) {
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
@@ -95,6 +96,10 @@ async function addAccount(accountData) {
|
||||
};
|
||||
logger.info(`[WebUI] Account ${accountData.email} updated`);
|
||||
} else {
|
||||
// Check MAX_ACCOUNTS limit before adding new account
|
||||
if (accounts.length >= MAX_ACCOUNTS) {
|
||||
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`);
|
||||
}
|
||||
// Add new account
|
||||
accounts.push({
|
||||
...accountData,
|
||||
@@ -282,7 +287,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
*/
|
||||
app.post('/api/config', (req, res) => {
|
||||
try {
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, accountSelection } = req.body;
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection } = req.body;
|
||||
|
||||
// Only allow updating specific fields (security)
|
||||
const updates = {};
|
||||
@@ -308,6 +313,9 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
|
||||
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
|
||||
}
|
||||
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
||||
updates.maxAccounts = maxAccounts;
|
||||
}
|
||||
// Account selection strategy validation
|
||||
if (accountSelection && typeof accountSelection === 'object') {
|
||||
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
||||
|
||||
Reference in New Issue
Block a user