librechat-balances/public/js/app.js
DJP e94cf8f503 Add low balance users view with threshold filters
Shows users below 1M/2M/3M/4M/5M tokens with filter buttons.
Sorted lowest first so admins can quickly find and top up users
running low.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:11:02 -04:00

368 lines
14 KiB
JavaScript

const app = {
apiKey: localStorage.getItem('bm_api_key') || '',
currentView: 'home',
currentPage: 1,
allData: null,
searchTimeout: null,
init() {
if (!this.apiKey) {
this.promptApiKey();
} else {
this.loadHome();
}
this.bindNav();
this.bindSearch();
this.bindModalClose();
},
promptApiKey() {
const key = prompt('Enter your API key:');
if (key) {
this.apiKey = key;
localStorage.setItem('bm_api_key', key);
this.loadHome();
}
},
async api(path, options = {}) {
const base = document.baseURI.replace(/\/$/, '');
const url = `${base}/api${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
...options.headers,
},
});
if (res.status === 401) {
localStorage.removeItem('bm_api_key');
this.apiKey = '';
this.promptApiKey();
return null;
}
return res.json();
},
bindNav() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => this.navigate(item.dataset.view));
});
document.querySelectorAll('.home-card[data-nav]').forEach(card => {
card.addEventListener('click', () => this.navigate(card.dataset.nav));
});
},
bindSearch() {
const input = document.getElementById('search-input');
input.addEventListener('input', () => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.doSearch(input.value), 300);
});
},
bindModalClose() {
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') this.closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.closeModal();
});
},
navigate(view) {
this.currentView = view;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById(`view-${view}`).style.display = 'block';
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-view="${view}"]`).classList.add('active');
if (view === 'home') this.loadHome();
if (view === 'all') this.loadAll();
if (view === 'low') this.loadLow(1000000);
if (view === 'search') document.getElementById('search-input').focus();
},
async loadHome() {
const stats = await this.api('/stats');
if (!stats) return;
document.getElementById('home-stats').innerHTML = `
<div class="stat-card">
<div class="stat-label">Total Users</div>
<div class="stat-value">${stats.totalUsers.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">With Balance</div>
<div class="stat-value green">${stats.usersWithBalance.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Zero Balance</div>
<div class="stat-value red">${stats.zeroBalance.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Balance</div>
<div class="stat-value purple">${stats.avgCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Credits</div>
<div class="stat-value">${stats.totalCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Min Balance</div>
<div class="stat-value red">${stats.minCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Max Balance</div>
<div class="stat-value green">${stats.maxCredits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">No Balance Record</div>
<div class="stat-value red">${stats.usersWithoutBalance.toLocaleString()}</div>
</div>
`;
},
async loadAll(page = 1) {
this.currentPage = page;
const data = await this.api(`/balances?page=${page}&limit=50`);
if (!data) return;
this.allData = data;
document.getElementById('all-count').textContent = `${data.total} Users`;
document.getElementById('all-tbody').innerHTML = data.users.map(u => this.userRow(u)).join('');
this.renderPagination(data);
},
async loadLow(threshold) {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.threshold) === threshold);
});
const data = await this.api(`/balances/low?threshold=${threshold}`);
if (!data) return;
const tbody = document.getElementById('low-tbody');
document.getElementById('low-count').textContent = `${data.users.length} User${data.users.length !== 1 ? 's' : ''} under ${(threshold / 1000000)}M`;
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users below this threshold</td></tr>';
} else {
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
}
},
userRow(u) {
const credits = u.tokenCredits || 0;
const balanceClass = credits > 0 ? 'balance-positive' : 'balance-zero';
const name = u.name || u.username || '-';
const email = u.email || '-';
return `
<tr>
<td class="email-cell">${this.esc(email)}</td>
<td class="name-cell">${this.esc(name)}</td>
<td class="balance-cell ${balanceClass}">${credits.toLocaleString()}</td>
<td>
<div class="actions-cell">
<button class="btn btn-sm btn-green" onclick="app.showEditModal('${u.userId}', '${this.esc(email)}', ${credits}, 'add')">Top Up</button>
<button class="btn btn-sm btn-secondary" onclick="app.showEditModal('${u.userId}', '${this.esc(email)}', ${credits}, 'set')">Set</button>
</div>
</td>
</tr>
`;
},
renderPagination(data) {
const el = document.getElementById('pagination');
if (data.totalPages <= 1) {
el.innerHTML = '';
return;
}
el.innerHTML = `
<button ${data.page <= 1 ? 'disabled' : ''} onclick="app.loadAll(${data.page - 1})">Prev</button>
<span class="page-info">Page ${data.page} of ${data.totalPages}</span>
<button ${data.page >= data.totalPages ? 'disabled' : ''} onclick="app.loadAll(${data.page + 1})">Next</button>
`;
},
async doSearch(query) {
const container = document.getElementById('search-results-container');
const tbody = document.getElementById('search-tbody');
if (!query || query.length < 2) {
container.style.display = 'none';
return;
}
const data = await this.api(`/balances/search?q=${encodeURIComponent(query)}`);
if (!data) return;
container.style.display = 'block';
document.getElementById('search-count').textContent = `${data.users.length} Result${data.users.length !== 1 ? 's' : ''}`;
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users found</td></tr>';
} else {
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
}
},
showEditModal(userId, email, currentBalance, mode) {
const isAdd = mode === 'add';
const modal = document.getElementById('modal');
modal.innerHTML = `
<h3>${isAdd ? 'Top Up Balance' : 'Set Balance'}</h3>
<div class="modal-subtitle">${email}</div>
<div class="modal-field">
<label>Current Balance</label>
<div class="current-balance">${currentBalance.toLocaleString()}</div>
</div>
<div class="modal-field">
<label>${isAdd ? 'Amount to Add' : 'New Balance'}</label>
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
<div class="bulk-presets" style="margin-top:10px">
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="app.submitEdit('${userId}', '${mode}')">
${isAdd ? 'Add Tokens' : 'Set Balance'}
</button>
</div>
`;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
},
async submitEdit(userId, mode) {
const amount = parseInt(document.getElementById('modal-amount').value);
if (isNaN(amount)) {
this.toast('Please enter a valid number', 'error');
return;
}
if (mode === 'set' && amount < 0) {
this.toast('Balance cannot be negative', 'error');
return;
}
const endpoint = mode === 'add' ? 'add' : 'set';
const result = await this.api(`/balances/${userId}/${endpoint}`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (result && !result.error) {
this.toast(`Balance updated to ${result.tokenCredits.toLocaleString()}`, 'success');
this.closeModal();
if (this.currentView === 'all') this.loadAll(this.currentPage);
if (this.currentView === 'search') {
const q = document.getElementById('search-input').value;
if (q) this.doSearch(q);
}
if (this.currentView === 'home') this.loadHome();
} else {
this.toast(result?.error || 'Failed to update', 'error');
}
},
showBulkModal(mode) {
const isAdd = mode === 'add';
const modal = document.getElementById('modal');
modal.innerHTML = `
<h3>${isAdd ? 'Add Tokens to All Users' : 'Set All Users Balance'}</h3>
<div class="modal-subtitle">${isAdd
? 'This will increment every user\'s balance by the specified amount.'
: 'This will set every user\'s balance to the exact amount. Existing balances will be overwritten.'
}</div>
<div class="modal-field">
<label>${isAdd ? 'Amount to Add' : 'New Balance for All'}</label>
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
<div class="bulk-presets" style="margin-top:10px">
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn ${isAdd ? 'btn-green' : 'btn-red'}" onclick="app.submitBulk('${mode}')">
${isAdd ? 'Add to All' : 'Set All Balances'}
</button>
</div>
`;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
},
async submitBulk(mode) {
const amount = parseInt(document.getElementById('modal-amount').value);
if (isNaN(amount)) {
this.toast('Please enter a valid number', 'error');
return;
}
if (mode === 'set' && amount < 0) {
this.toast('Balance cannot be negative', 'error');
return;
}
const confirmed = confirm(`Are you sure you want to ${mode === 'add' ? 'add ' + amount.toLocaleString() + ' tokens to' : 'set'} ALL users${mode === 'set' ? ' to ' + amount.toLocaleString() : ''}?`);
if (!confirmed) return;
const result = await this.api(`/balances/bulk/${mode}`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (result && !result.error) {
this.toast(`Updated ${result.modifiedCount} users`, 'success');
this.closeModal();
} else {
this.toast(result?.error || 'Bulk operation failed', 'error');
}
},
exportCSV() {
if (!this.allData || !this.allData.users.length) return;
const rows = [['Email', 'Name', 'Balance']];
for (const u of this.allData.users) {
rows.push([u.email || '', u.name || u.username || '', u.tokenCredits || 0]);
}
const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `balances-page${this.currentPage}.csv`;
a.click();
URL.revokeObjectURL(url);
},
closeModal() {
document.getElementById('modal-overlay').classList.remove('active');
},
toast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 3000);
},
esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
};
document.addEventListener('DOMContentLoaded', () => app.init());