/** * 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) globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99) maxAccounts: 10, // Maximum number of accounts allowed (from config) devMode: false, // Developer mode flag (from server config) placeholderMode: false, // Inject placeholder account data for UI testing placeholderIncludeReal: true, // Include real accounts alongside placeholder data _realAccounts: null, // Stash for real accounts when placeholder mode is on _realModels: null, // Stash for real models when placeholder mode is on 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(); // Restore placeholder mode from persisted settings // Read localStorage directly since settings store may not be initialized yet try { const saved = JSON.parse(localStorage.getItem('antigravity_settings') || '{}'); if (saved.placeholderMode) { this.setPlaceholderMode(true, saved.placeholderIncludeReal !== false); } } catch (e) { /* ignore parse errors */ } // 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)) { if (window.UILogger) window.UILogger.debug('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(); if (window.UILogger) window.UILogger.debug('Restored data from cache'); } } } catch (e) { if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message); } }, 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) { if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message); } }, 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 || {}; this.globalQuotaThreshold = data.globalQuotaThreshold || 0; // Store usage history if included (for dashboard) if (data.history) { this.usageHistory = data.history; } this.saveToCache(); // Save fresh data // Re-inject placeholder data if active if (this.placeholderMode) { this._realAccounts = [...this.accounts]; this._realModels = [...this.models]; const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData(); if (this.placeholderIncludeReal) { this.accounts = [...this._realAccounts, ...fakeAccounts]; const modelSet = new Set([...this._realModels, ...fakeModels]); this.models = Array.from(modelSet).sort(); } else { this.accounts = fakeAccounts; this.models = fakeModels; } } this.computeQuotaRows(); this.lastUpdated = new Date().toLocaleTimeString(); } catch (error) { // Keep error logging for actual fetch failures 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 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'; // Update devMode from server config try { const data = await response.json(); if (data.config) { this.devMode = !!data.config.devMode; } } catch (e) { /* ignore parse errors */ } } 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; let maxEffectiveThreshold = 0; const globalThreshold = this.globalQuotaThreshold || 0; this.accounts.forEach(acc => { if (acc.enabled === false) return; 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; } // Resolve effective threshold: per-model > per-account > global const accModelThreshold = acc.modelQuotaThresholds?.[modelId]; const accThreshold = acc.quotaThreshold; const effective = accModelThreshold ?? accThreshold ?? globalThreshold; if (effective > maxEffectiveThreshold) { maxEffectiveThreshold = effective; } // Determine threshold source for display let thresholdSource = 'global'; if (accModelThreshold !== undefined) thresholdSource = 'model'; else if (accThreshold !== undefined) thresholdSource = 'account'; quotaInfo.push({ email: acc.email.split('@')[0], fullEmail: acc.email, pct: pct, resetTime: limit.resetTime, thresholdPct: Math.round(effective * 100), thresholdSource }); }); if (quotaInfo.length === 0) return; const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0; if (!showExhausted && minQuota === 0) return; // Check if thresholds vary across accounts const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct)); const hasVariedThresholds = uniqueThresholds.size > 1; 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, effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100), hasVariedThresholds }); }); // 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 => { if (acc.enabled === false) return; 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; }, /** * Generate placeholder account and model data for UI testing */ _generatePlaceholderData() { const models = [ 'claude-opus-4-6-thinking', 'claude-sonnet-4-6', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low', 'gemini-3-flash', 'gemini-3.1-flash-lite' ]; const tiers = ['ultra', 'pro', 'pro', 'free']; const names = ['alice', 'bob', 'charlie', 'diana']; const domains = ['workspace.dev', 'company.io', 'example.org', 'test.net']; const accounts = names.map((name, i) => { const email = `${name}@${domains[i]}`; const tier = tiers[i]; // Generate varied quota per model per account const limits = {}; models.forEach((modelId, mi) => { // Create a deterministic but varied fraction const seed = ((i * 7 + mi * 13) % 100); const fraction = seed < 10 ? 0 : seed / 100; const resetTime = fraction === 0 ? new Date(Date.now() + (30 + i * 15) * 60000).toISOString() : null; limits[modelId] = { remaining: Math.round(fraction * 100) + '%', remainingFraction: fraction, resetTime }; }); return { email, status: i === 3 ? 'invalid' : 'ok', error: i === 3 ? 'Token expired' : null, source: i === 0 ? 'database' : 'oauth', enabled: i !== 2 ? true : false, projectId: `proj-${name}-${1000 + i}`, isInvalid: i === 3, invalidReason: i === 3 ? 'Token expired' : null, lastUsed: new Date(Date.now() - i * 3600000).toISOString(), modelRateLimits: {}, quotaThreshold: i === 1 ? 0.15 : undefined, modelQuotaThresholds: i === 0 ? { 'claude-opus-4-6-thinking': 0.25 } : {}, subscription: { tier, projectId: `proj-${name}-${1000 + i}`, detectedAt: Date.now() }, limits }; }); return { accounts, models }; }, /** * Enable or disable placeholder data injection */ setPlaceholderMode(enabled, includeReal) { this.placeholderMode = enabled; this.placeholderIncludeReal = includeReal; // Persist to settings store const settings = Alpine.store('settings'); if (settings) { settings.placeholderMode = enabled; settings.placeholderIncludeReal = includeReal; settings.saveSettings(true); } if (enabled) { // Stash real data this._realAccounts = [...this.accounts]; this._realModels = [...this.models]; const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData(); if (includeReal && this._realAccounts.length > 0) { // Merge: real accounts first, then placeholders this.accounts = [...this._realAccounts, ...fakeAccounts]; // Union of models const modelSet = new Set([...this._realModels, ...fakeModels]); this.models = Array.from(modelSet).sort(); } else { this.accounts = fakeAccounts; this.models = fakeModels; } } else { // Restore real data if (this._realAccounts !== null) { this.accounts = this._realAccounts; this._realAccounts = null; } if (this._realModels !== null) { this.models = this._realModels; this._realModels = null; } } this.computeQuotaRows(); } }); });