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`.
|
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
|
||||||
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
||||||
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
|
- **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.
|
Refer to `config.example.json` for a complete list of fields and documentation.
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
"persistTokenCache": false,
|
"persistTokenCache": false,
|
||||||
|
|
||||||
"requestTimeoutMs": 300000,
|
"requestTimeoutMs": 300000,
|
||||||
|
|
||||||
"maxAccounts": 10,
|
"maxAccounts": 10,
|
||||||
|
"_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.",
|
||||||
|
|
||||||
"_profiles": {
|
"_profiles": {
|
||||||
"development": {
|
"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));
|
(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) {
|
toggleRateLimitDedupWindowMs(value) {
|
||||||
const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
|
const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
|
||||||
this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',
|
this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ window.AppConstants.VALIDATION = {
|
|||||||
MAX_WAIT_MIN: 60000,
|
MAX_WAIT_MIN: 60000,
|
||||||
MAX_WAIT_MAX: 1800000,
|
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 window (1 - 30 seconds)
|
||||||
RATE_LIMIT_DEDUP_MIN: 1000,
|
RATE_LIMIT_DEDUP_MIN: 1000,
|
||||||
RATE_LIMIT_DEDUP_MAX: 30000,
|
RATE_LIMIT_DEDUP_MAX: 30000,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||||
quotaRows: [], // Filtered view
|
quotaRows: [], // Filtered view
|
||||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||||
|
maxAccounts: 10, // Maximum number of accounts allowed (from config)
|
||||||
loading: false,
|
loading: false,
|
||||||
initialLoad: true, // Track first load for skeleton screen
|
initialLoad: true, // Track first load for skeleton screen
|
||||||
connectionStatus: 'connecting',
|
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() {
|
async performHealthCheck() {
|
||||||
try {
|
try {
|
||||||
// Get password from global store
|
// Get password from global store
|
||||||
|
|||||||
@@ -39,8 +39,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span x-text="$store.global.t('reload')">Reload</span>
|
<span x-text="$store.global.t('reload')">Reload</span>
|
||||||
</button>
|
</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"
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -934,6 +934,28 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 🔀 Account Selection Strategy -->
|
<!-- 🔀 Account Selection Strategy -->
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
|
|||||||
persistTokenCache: false,
|
persistTokenCache: false,
|
||||||
defaultCooldownMs: 10000, // 10 seconds
|
defaultCooldownMs: 10000, // 10 seconds
|
||||||
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
||||||
|
maxAccounts: 10, // Maximum number of accounts allowed
|
||||||
modelMapping: {},
|
modelMapping: {},
|
||||||
// Account selection strategy configuration
|
// Account selection strategy configuration
|
||||||
accountSelection: {
|
accountSelection: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { readFileSync } from 'fs';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getPublicConfig, saveConfig, config } from '../config.js';
|
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 { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
||||||
@@ -77,6 +77,7 @@ async function removeAccount(email) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add new account to config
|
* Add new account to config
|
||||||
|
* @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only)
|
||||||
*/
|
*/
|
||||||
async function addAccount(accountData) {
|
async function addAccount(accountData) {
|
||||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||||
@@ -95,6 +96,10 @@ async function addAccount(accountData) {
|
|||||||
};
|
};
|
||||||
logger.info(`[WebUI] Account ${accountData.email} updated`);
|
logger.info(`[WebUI] Account ${accountData.email} updated`);
|
||||||
} else {
|
} 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
|
// Add new account
|
||||||
accounts.push({
|
accounts.push({
|
||||||
...accountData,
|
...accountData,
|
||||||
@@ -282,7 +287,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
*/
|
*/
|
||||||
app.post('/api/config', (req, res) => {
|
app.post('/api/config', (req, res) => {
|
||||||
try {
|
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)
|
// Only allow updating specific fields (security)
|
||||||
const updates = {};
|
const updates = {};
|
||||||
@@ -308,6 +313,9 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
|
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
|
||||||
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
|
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
|
||||||
}
|
}
|
||||||
|
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
||||||
|
updates.maxAccounts = maxAccounts;
|
||||||
|
}
|
||||||
// Account selection strategy validation
|
// Account selection strategy validation
|
||||||
if (accountSelection && typeof accountSelection === 'object') {
|
if (accountSelection && typeof accountSelection === 'object') {
|
||||||
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
||||||
|
|||||||
Reference in New Issue
Block a user