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
387 lines
15 KiB
JavaScript
387 lines
15 KiB
JavaScript
/**
|
|
* Data Store
|
|
* Holds Accounts, Models, and Computed Quota Rows
|
|
* Shared between Dashboard and AccountManager
|
|
*/
|
|
|
|
// utils is loaded globally as window.utils in utils.js
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.store('data', {
|
|
accounts: [],
|
|
models: [], // Source of truth
|
|
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',
|
|
lastUpdated: '-',
|
|
healthCheckTimer: null,
|
|
|
|
// Filters state
|
|
filters: {
|
|
account: 'all',
|
|
family: 'all',
|
|
search: '',
|
|
sortCol: 'avgQuota',
|
|
sortAsc: true
|
|
},
|
|
|
|
// Settings for calculation
|
|
// We need to access global settings? Or duplicate?
|
|
// Let's assume settings are passed or in another store.
|
|
// For simplicity, let's keep relevant filters here.
|
|
|
|
init() {
|
|
// Restore from cache first for instant render
|
|
this.loadFromCache();
|
|
|
|
// Watch filters to recompute
|
|
// Alpine stores don't have $watch automatically unless inside a component?
|
|
// We can manually call compute when filters change.
|
|
|
|
// Start health check monitoring
|
|
this.startHealthCheck();
|
|
},
|
|
|
|
loadFromCache() {
|
|
try {
|
|
const cached = localStorage.getItem('ag_data_cache');
|
|
if (cached) {
|
|
const data = JSON.parse(cached);
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
// Check TTL
|
|
if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
|
|
console.log('Cache expired, skipping restoration');
|
|
localStorage.removeItem('ag_data_cache');
|
|
return;
|
|
}
|
|
|
|
// Basic validity check
|
|
if (data.accounts && data.models) {
|
|
this.accounts = data.accounts;
|
|
this.models = data.models;
|
|
this.modelConfig = data.modelConfig || {};
|
|
this.usageHistory = data.usageHistory || {};
|
|
|
|
// Don't show loading on initial load if we have cache
|
|
this.initialLoad = false;
|
|
this.computeQuotaRows();
|
|
console.log('Restored data from cache');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load cache', e);
|
|
}
|
|
},
|
|
|
|
saveToCache() {
|
|
try {
|
|
const cacheData = {
|
|
accounts: this.accounts,
|
|
models: this.models,
|
|
modelConfig: this.modelConfig,
|
|
usageHistory: this.usageHistory,
|
|
timestamp: Date.now()
|
|
};
|
|
localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
|
|
} catch (e) {
|
|
console.warn('Failed to save cache', e);
|
|
}
|
|
},
|
|
|
|
async fetchData() {
|
|
// Only show skeleton on initial load if we didn't restore from cache
|
|
if (this.initialLoad) {
|
|
this.loading = true;
|
|
}
|
|
try {
|
|
// Get password from global store
|
|
const password = Alpine.store('global').webuiPassword;
|
|
|
|
// Include history for dashboard (single API call optimization)
|
|
const url = '/account-limits?includeHistory=true';
|
|
const { response, newPassword } = await window.utils.request(url, {}, password);
|
|
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
const data = await response.json();
|
|
this.accounts = data.accounts || [];
|
|
if (data.models && data.models.length > 0) {
|
|
this.models = data.models;
|
|
}
|
|
this.modelConfig = data.modelConfig || {};
|
|
|
|
// Store usage history if included (for dashboard)
|
|
if (data.history) {
|
|
this.usageHistory = data.history;
|
|
}
|
|
|
|
this.saveToCache(); // Save fresh data
|
|
this.computeQuotaRows();
|
|
|
|
this.lastUpdated = new Date().toLocaleTimeString();
|
|
} catch (error) {
|
|
console.error('Fetch error:', error);
|
|
const store = Alpine.store('global');
|
|
store.showToast(store.t('connectionLost'), 'error');
|
|
} finally {
|
|
this.loading = false;
|
|
this.initialLoad = false; // Mark initial load as complete
|
|
}
|
|
},
|
|
|
|
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
|
|
const password = Alpine.store('global').webuiPassword;
|
|
|
|
// Use lightweight endpoint (no quota fetching)
|
|
const { response, newPassword } = await window.utils.request('/api/config', {}, password);
|
|
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (response.ok) {
|
|
this.connectionStatus = 'connected';
|
|
} else {
|
|
this.connectionStatus = 'disconnected';
|
|
}
|
|
} catch (error) {
|
|
console.error('Health check error:', error);
|
|
this.connectionStatus = 'disconnected';
|
|
}
|
|
},
|
|
|
|
startHealthCheck() {
|
|
// Clear existing timer
|
|
if (this.healthCheckTimer) {
|
|
clearInterval(this.healthCheckTimer);
|
|
}
|
|
|
|
// Setup visibility change listener (only once)
|
|
if (!this._healthVisibilitySetup) {
|
|
this._healthVisibilitySetup = true;
|
|
this._visibilityHandler = () => {
|
|
if (document.hidden) {
|
|
// Tab hidden - stop health checks
|
|
this.stopHealthCheck();
|
|
} else {
|
|
// Tab visible - restart health checks
|
|
this.startHealthCheck();
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', this._visibilityHandler);
|
|
}
|
|
|
|
// Perform immediate health check
|
|
this.performHealthCheck();
|
|
|
|
// Schedule regular health checks every 15 seconds
|
|
this.healthCheckTimer = setInterval(() => {
|
|
// Only perform health check if tab is visible
|
|
if (!document.hidden) {
|
|
this.performHealthCheck();
|
|
}
|
|
}, 15000);
|
|
},
|
|
|
|
stopHealthCheck() {
|
|
if (this.healthCheckTimer) {
|
|
clearInterval(this.healthCheckTimer);
|
|
this.healthCheckTimer = null;
|
|
}
|
|
},
|
|
|
|
computeQuotaRows() {
|
|
const models = this.models || [];
|
|
const rows = [];
|
|
const showExhausted = Alpine.store('settings')?.showExhausted ?? true;
|
|
|
|
models.forEach(modelId => {
|
|
// Config
|
|
const config = this.modelConfig[modelId] || {};
|
|
const family = this.getModelFamily(modelId);
|
|
|
|
// Visibility Logic for Models Page (quotaRows):
|
|
// 1. If explicitly hidden via config, ALWAYS hide (clean interface)
|
|
// 2. If no config, default 'unknown' families to HIDDEN
|
|
// 3. Known families (Claude/Gemini) default to VISIBLE
|
|
// Note: To manage hidden models, use Settings → Models tab
|
|
let isHidden = config.hidden;
|
|
if (isHidden === undefined) {
|
|
isHidden = (family === 'other' || family === 'unknown');
|
|
}
|
|
|
|
// Models Page: Check settings for visibility
|
|
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
|
if (isHidden && !showHidden) return;
|
|
|
|
// Filters
|
|
if (this.filters.family !== 'all' && this.filters.family !== family) return;
|
|
if (this.filters.search) {
|
|
const searchLower = this.filters.search.toLowerCase();
|
|
const idMatch = modelId.toLowerCase().includes(searchLower);
|
|
if (!idMatch) return;
|
|
}
|
|
|
|
// Data Collection
|
|
const quotaInfo = [];
|
|
let minQuota = 100;
|
|
let totalQuotaSum = 0;
|
|
let validAccountCount = 0;
|
|
let minResetTime = null;
|
|
|
|
this.accounts.forEach(acc => {
|
|
if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
|
|
|
|
const limit = acc.limits?.[modelId];
|
|
if (!limit) return;
|
|
|
|
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
|
minQuota = Math.min(minQuota, pct);
|
|
|
|
// Accumulate for average
|
|
totalQuotaSum += pct;
|
|
validAccountCount++;
|
|
|
|
if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
|
|
minResetTime = limit.resetTime;
|
|
}
|
|
|
|
quotaInfo.push({
|
|
email: acc.email.split('@')[0],
|
|
fullEmail: acc.email,
|
|
pct: pct,
|
|
resetTime: limit.resetTime
|
|
});
|
|
});
|
|
|
|
if (quotaInfo.length === 0) return;
|
|
const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
|
|
|
|
if (!showExhausted && minQuota === 0) return;
|
|
|
|
rows.push({
|
|
modelId,
|
|
displayName: modelId, // Simplified: no longer using alias
|
|
family,
|
|
minQuota,
|
|
avgQuota, // Added Average Quota
|
|
minResetTime,
|
|
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
|
quotaInfo,
|
|
pinned: !!config.pinned,
|
|
hidden: !!isHidden, // Use computed visibility
|
|
activeCount: quotaInfo.filter(q => q.pct > 0).length
|
|
});
|
|
});
|
|
|
|
// Sort: Pinned first, then by selected column
|
|
const sortCol = this.filters.sortCol;
|
|
const sortAsc = this.filters.sortAsc;
|
|
|
|
this.quotaRows = rows.sort((a, b) => {
|
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
|
|
let valA = a[sortCol];
|
|
let valB = b[sortCol];
|
|
|
|
// Handle nulls (always push to bottom)
|
|
if (valA === valB) return 0;
|
|
if (valA === null || valA === undefined) return 1;
|
|
if (valB === null || valB === undefined) return -1;
|
|
|
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
}
|
|
|
|
return sortAsc ? valA - valB : valB - valA;
|
|
});
|
|
|
|
// Trigger Dashboard Update if active
|
|
// Ideally dashboard watches this store.
|
|
},
|
|
|
|
setSort(col) {
|
|
if (this.filters.sortCol === col) {
|
|
this.filters.sortAsc = !this.filters.sortAsc;
|
|
} else {
|
|
this.filters.sortCol = col;
|
|
// Default sort direction: Descending for numbers/stats, Ascending for text/time
|
|
if (['avgQuota', 'activeCount'].includes(col)) {
|
|
this.filters.sortAsc = false;
|
|
} else {
|
|
this.filters.sortAsc = true;
|
|
}
|
|
}
|
|
this.computeQuotaRows();
|
|
},
|
|
|
|
getModelFamily(modelId) {
|
|
const lower = modelId.toLowerCase();
|
|
if (lower.includes('claude')) return 'claude';
|
|
if (lower.includes('gemini')) return 'gemini';
|
|
return 'other';
|
|
},
|
|
|
|
/**
|
|
* Get quota data without filters applied (for Dashboard global charts)
|
|
* Returns array of { modelId, family, quotaInfo: [{pct}] }
|
|
*/
|
|
getUnfilteredQuotaData() {
|
|
const models = this.models || [];
|
|
const rows = [];
|
|
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
|
|
|
models.forEach(modelId => {
|
|
const config = this.modelConfig[modelId] || {};
|
|
const family = this.getModelFamily(modelId);
|
|
|
|
// Smart visibility (same logic as computeQuotaRows)
|
|
let isHidden = config.hidden;
|
|
if (isHidden === undefined) {
|
|
isHidden = (family === 'other' || family === 'unknown');
|
|
}
|
|
if (isHidden && !showHidden) return;
|
|
|
|
const quotaInfo = [];
|
|
// Use ALL accounts (no account filter)
|
|
this.accounts.forEach(acc => {
|
|
const limit = acc.limits?.[modelId];
|
|
if (!limit) return;
|
|
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
|
quotaInfo.push({ pct });
|
|
});
|
|
|
|
// treat missing quotaInfo as 0%/unknown; still include row
|
|
rows.push({ modelId, family, quotaInfo });
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
});
|
|
});
|