Files
wownerogue/html/admin.html
T
jwinterm 6e0f3e9012 fix: wallet display bug, add test email endpoint
- Fix wallet balance always showing 'Disconnected' in admin dashboard
- Always attempt balance fetch even if isHealthy=false
- Show error state with message instead of generic 'Disconnected'
- Add POST /api/admin/alerts/test-email endpoint to verify Resend config
- Move alertService to module scope for endpoint access
2026-02-01 15:59:30 -05:00

710 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Wowngeon</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
}
/* Header */
.header {
background: #1a1a2e;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #2a2a4e;
}
.header h1 { color: #4ade80; font-size: 1.5em; }
.header-right { display: flex; align-items: center; gap: 20px; }
.wallet-status {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 15px;
background: #0f0f1a;
border-radius: 6px;
font-size: 0.9em;
}
.wallet-status.warning { border: 1px solid #f59e0b; }
.wallet-status.healthy { border: 1px solid #22c55e; }
.wallet-status.disconnected { border: 1px solid #ef4444; }
.logout-btn {
background: #ef4444;
color: white;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
}
.logout-btn:hover { background: #dc2626; }
/* Login Modal */
.login-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.login-box {
background: #1a1a2e;
padding: 30px;
border-radius: 8px;
width: 100%;
max-width: 400px;
}
.login-box h2 { margin-bottom: 20px; color: #4ade80; }
.login-box input {
width: 100%;
padding: 12px;
margin-bottom: 15px;
background: #0f0f1a;
border: 1px solid #2a2a4e;
border-radius: 4px;
color: #e0e0e0;
font-size: 1em;
}
.login-box button {
width: 100%;
padding: 12px;
background: #4ade80;
color: #0a0a0a;
border: none;
border-radius: 4px;
font-size: 1em;
font-weight: bold;
cursor: pointer;
}
.login-box button:hover { background: #22c55e; }
.login-error { color: #ef4444; margin-bottom: 15px; display: none; }
/* Main content */
.main-content { padding: 20px; max-width: 1400px; margin: 0 auto; }
/* Stats cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: #1a1a2e;
padding: 20px;
border-radius: 8px;
border: 1px solid #2a2a4e;
}
.stat-card h3 { color: #888; font-size: 0.9em; margin-bottom: 8px; }
.stat-card .value { font-size: 2em; font-weight: bold; color: #4ade80; }
.stat-card .value.warning { color: #f59e0b; }
.stat-card .value.danger { color: #ef4444; }
.stat-card .subtext { color: #666; font-size: 0.85em; margin-top: 5px; }
/* Tabs */
.tabs {
display: flex;
gap: 5px;
margin-bottom: 20px;
border-bottom: 1px solid #2a2a4e;
padding-bottom: 10px;
}
.tab-btn {
background: transparent;
border: none;
color: #888;
padding: 10px 20px;
cursor: pointer;
font-size: 1em;
border-radius: 4px 4px 0 0;
}
.tab-btn:hover { color: #e0e0e0; background: #1a1a2e; }
.tab-btn.active { color: #4ade80; background: #1a1a2e; border-bottom: 2px solid #4ade80; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Tables */
.table-container {
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
}
.table-header {
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #2a2a4e;
}
.table-header h3 { color: #4ade80; }
.table-header select {
background: #0f0f1a;
color: #e0e0e0;
border: 1px solid #2a2a4e;
padding: 8px;
border-radius: 4px;
}
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #2a2a4e; }
th { background: #0f0f1a; color: #888; font-weight: 600; font-size: 0.85em; text-transform: uppercase; }
tr:hover { background: #0f0f1a; }
.status-badge { padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.status-pending { background: #f59e0b20; color: #f59e0b; }
.status-failed { background: #ef444420; color: #ef4444; }
.status-completed { background: #22c55e20; color: #22c55e; }
.status-permanently_failed { background: #7f1d1d; color: #fca5a5; }
.status-won { background: #22c55e20; color: #22c55e; }
.status-lost { background: #ef444420; color: #ef4444; }
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
/* Charts */
.chart-container {
background: #1a1a2e;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.chart-container h3 { color: #4ade80; margin-bottom: 15px; }
/* Search */
.search-box { display: flex; gap: 10px; margin-bottom: 15px; }
.search-box input {
flex: 1;
padding: 10px;
background: #0f0f1a;
border: 1px solid #2a2a4e;
border-radius: 4px;
color: #e0e0e0;
}
.search-box button {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination { display: flex; justify-content: center; gap: 10px; padding: 15px; }
.pagination button {
padding: 8px 15px;
background: #2a2a4e;
color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.loading { text-align: center; padding: 40px; color: #888; }
.hidden { display: none !important; }
.tx-hash { font-family: monospace; font-size: 0.8em; color: #888; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.table-container { overflow-x: auto; }
table { min-width: 600px; }
}
</style>
</head>
<body>
<!-- Login Modal -->
<div id="loginModal" class="login-modal">
<div class="login-box">
<h2>Admin Login</h2>
<div id="loginError" class="login-error">Invalid API key</div>
<input type="password" id="apiKeyInput" placeholder="Enter Admin API Key">
<button onclick="attemptLogin()">Login</button>
</div>
</div>
<!-- Main Dashboard -->
<div id="dashboard" class="hidden">
<header class="header">
<h1>Wowngeon Admin</h1>
<div class="header-right">
<div id="walletStatus" class="wallet-status">
<span id="walletIcon">-</span>
<span id="walletBalance">Loading...</span>
</div>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</header>
<main class="main-content">
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<h3>Total Users</h3>
<div id="statUsers" class="value">-</div>
<div id="statNewUsers" class="subtext">+0 last 24h</div>
</div>
<div class="stat-card">
<h3>Games (24h)</h3>
<div id="statGames" class="value">-</div>
<div id="statWinRate" class="subtext">0% win rate</div>
</div>
<div class="stat-card">
<h3>Pending Payouts</h3>
<div id="statPending" class="value">-</div>
<div id="statFailed" class="subtext">0 failed</div>
</div>
<div class="stat-card">
<h3>Wallet Balance</h3>
<div id="statBalance" class="value">-</div>
<div id="statUnlocked" class="subtext">Unlocked: -</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="payouts">Payouts</button>
<button class="tab-btn" data-tab="games">Games</button>
<button class="tab-btn" data-tab="users">Users</button>
</div>
<!-- Tab: Overview -->
<div id="tab-overview" class="tab-content active">
<div class="chart-container">
<h3>Game Outcomes (Last 24h)</h3>
<canvas id="outcomesChart" height="100"></canvas>
</div>
</div>
<!-- Tab: Payouts -->
<div id="tab-payouts" class="tab-content">
<div class="table-container">
<div class="table-header">
<h3>Payouts</h3>
<select id="payoutStatusFilter">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="completed">Completed</option>
<option value="permanently_failed">Permanently Failed</option>
</select>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Amount</th>
<th>Address</th>
<th>Status</th>
<th>TX Hash</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="payoutsTable">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: Games -->
<div id="tab-games" class="tab-content">
<div class="table-container">
<div class="table-header">
<h3>Recent Games</h3>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Mode</th>
<th>Status</th>
<th>Outcome</th>
<th>Duration</th>
<th>Payout</th>
<th>Created</th>
</tr>
</thead>
<tbody id="gamesTable">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: Users -->
<div id="tab-users" class="tab-content">
<div class="search-box">
<input type="text" id="userSearch" placeholder="Search by socket ID or address (min 3 chars)...">
<button onclick="searchUsers()">Search</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Socket ID</th>
<th>Address</th>
<th>Credits</th>
<th>Games</th>
<th>Won</th>
<th>Total Won</th>
<th>Created</th>
</tr>
</thead>
<tbody id="usersTable">
<tr><td colspan="8" class="loading">Loading...</td></tr>
</tbody>
</table>
<div class="pagination" id="usersPagination"></div>
</div>
</div>
</main>
</div>
<script>
// State
let apiKey = localStorage.getItem('adminApiKey') || '';
let currentTab = 'overview';
let usersOffset = 0;
let outcomesChart = null;
// Check if already logged in
if (apiKey) {
verifyAndShowDashboard();
}
// Enter key on login
document.getElementById('apiKeyInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') attemptLogin();
});
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// Payout filter
document.getElementById('payoutStatusFilter').addEventListener('change', loadPayouts);
// User search on enter
document.getElementById('userSearch').addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchUsers();
});
async function attemptLogin() {
const key = document.getElementById('apiKeyInput').value.trim();
if (!key) return;
try {
const res = await fetch('/api/admin/stats/overview', {
headers: { 'X-Admin-Key': key }
});
if (res.ok) {
apiKey = key;
localStorage.setItem('adminApiKey', key);
verifyAndShowDashboard();
} else {
document.getElementById('loginError').style.display = 'block';
}
} catch (e) {
document.getElementById('loginError').textContent = 'Connection error';
document.getElementById('loginError').style.display = 'block';
}
}
async function verifyAndShowDashboard() {
try {
const res = await fetch('/api/admin/stats/overview', {
headers: { 'X-Admin-Key': apiKey }
});
if (res.ok) {
document.getElementById('loginModal').classList.add('hidden');
document.getElementById('dashboard').classList.remove('hidden');
loadDashboard();
} else {
logout();
}
} catch (e) {
logout();
}
}
function logout() {
apiKey = '';
localStorage.removeItem('adminApiKey');
document.getElementById('loginModal').classList.remove('hidden');
document.getElementById('dashboard').classList.add('hidden');
document.getElementById('apiKeyInput').value = '';
document.getElementById('loginError').style.display = 'none';
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
document.getElementById(`tab-${tab}`).classList.add('active');
// Load data for tab
if (tab === 'payouts') loadPayouts();
else if (tab === 'games') loadGames();
else if (tab === 'users') loadUsers();
}
async function loadDashboard() {
try {
// Load overview stats
const overviewRes = await fetch('/api/admin/stats/overview', {
headers: { 'X-Admin-Key': apiKey }
});
const overview = await overviewRes.json();
document.getElementById('statUsers').textContent = overview.totalUsers || 0;
document.getElementById('statNewUsers').textContent = `+${overview.last24h?.newUsers || 0} last 24h`;
const games24h = overview.last24h?.games || 0;
const wins24h = overview.last24h?.wins || 0;
const winRate = games24h > 0 ? Math.round((wins24h / games24h) * 100) : 0;
document.getElementById('statGames').textContent = games24h;
document.getElementById('statWinRate').textContent = `${winRate}% win rate`;
const pending = overview.pendingPayouts || 0;
const failed = overview.failedPayouts || 0;
document.getElementById('statPending').textContent = pending;
document.getElementById('statPending').className = 'value' + (pending > 5 ? ' warning' : '');
document.getElementById('statFailed').textContent = `${failed} failed`;
document.getElementById('statFailed').className = 'subtext' + (failed > 0 ? ' danger' : '');
// Wallet balance
if (overview.walletBalance) {
const walletEl = document.getElementById('walletStatus');
const cryptoType = window.location.hostname.includes('monerogue') ? 'XMR' : 'WOW';
if (overview.walletBalance.error) {
// Wallet RPC error - show error state
document.getElementById('statBalance').textContent = 'Error';
document.getElementById('statUnlocked').textContent = overview.walletBalance.error;
document.getElementById('statUnlocked').title = overview.walletBalance.error;
walletEl.className = 'wallet-status disconnected';
document.getElementById('walletBalance').textContent = 'RPC Error';
} else {
// Wallet connected - show balance
const bal = overview.walletBalance.unlocked_balance || 0;
const formatted = (bal / 1e12).toFixed(4);
document.getElementById('statBalance').textContent = formatted;
document.getElementById('statUnlocked').textContent = `Unlocked: ${formatted}`;
walletEl.className = 'wallet-status' + (bal < 100000000000 ? ' warning' : ' healthy');
document.getElementById('walletBalance').textContent = `${formatted} ${cryptoType}`;
}
} else {
document.getElementById('statBalance').textContent = '-';
document.getElementById('walletBalance').textContent = 'Not configured';
document.getElementById('walletStatus').className = 'wallet-status disconnected';
}
// Load games data for chart
const gamesRes = await fetch('/api/admin/stats/games?period=24h', {
headers: { 'X-Admin-Key': apiKey }
});
const gamesData = await gamesRes.json();
renderOutcomesChart(gamesData.summary);
} catch (e) {
console.error('Failed to load dashboard:', e);
}
}
function renderOutcomesChart(summary) {
const ctx = document.getElementById('outcomesChart').getContext('2d');
if (outcomesChart) outcomesChart.destroy();
outcomesChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Won/Escaped', 'Caught', 'Expired', 'Other'],
datasets: [{
data: [
(summary.won || 0) + (summary.escaped || 0),
summary.caught || 0,
summary.expired || 0,
Math.max(0, (summary.total || 0) - (summary.won || 0) - (summary.escaped || 0) - (summary.caught || 0) - (summary.expired || 0))
],
backgroundColor: ['#22c55e', '#ef4444', '#f59e0b', '#6b7280']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right', labels: { color: '#888' } }
}
}
});
}
async function loadPayouts() {
const status = document.getElementById('payoutStatusFilter').value;
const url = `/api/admin/stats/payouts?limit=50${status ? `&status=${status}` : ''}`;
try {
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
const data = await res.json();
const tbody = document.getElementById('payoutsTable');
if (!data.payouts?.length) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No payouts found</td></tr>';
return;
}
tbody.innerHTML = data.payouts.map(p => `
<tr>
<td>${p.id}</td>
<td>${(p.amount / 1e12).toFixed(6)}</td>
<td title="${p.address || ''}">${p.address ? p.address.substring(0, 12) + '...' : '-'}</td>
<td><span class="status-badge status-${p.status}">${p.status}</span></td>
<td class="tx-hash">${p.txHash ? p.txHash.substring(0, 16) + '...' : '-'}</td>
<td>${new Date(p.createdAt).toLocaleString()}</td>
<td>
${p.status === 'failed' || p.status === 'permanently_failed' ?
`<button class="btn btn-primary" onclick="retryPayout(${p.id})">Retry</button>` : '-'}
</td>
</tr>
`).join('');
} catch (e) {
console.error('Failed to load payouts:', e);
}
}
async function retryPayout(id) {
if (!confirm('Queue this payout for retry?')) return;
try {
const res = await fetch(`/api/admin/payouts/${id}/retry`, {
method: 'POST',
headers: { 'X-Admin-Key': apiKey }
});
if (res.ok) {
alert('Payout queued for retry');
loadPayouts();
} else {
const err = await res.json();
alert('Error: ' + (err.message || 'Unknown error'));
}
} catch (e) {
alert('Failed to retry payout');
}
}
async function loadGames() {
try {
const res = await fetch('/api/admin/stats/games?period=24h&limit=50', {
headers: { 'X-Admin-Key': apiKey }
});
const data = await res.json();
const tbody = document.getElementById('gamesTable');
if (!data.recentGames?.length) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No games found</td></tr>';
return;
}
tbody.innerHTML = data.recentGames.map(g => `
<tr>
<td>${g.id}</td>
<td>${g.mode || '-'}</td>
<td><span class="status-badge status-${g.status}">${g.status}</span></td>
<td>${g.outcome || '-'}</td>
<td>${g.duration ? g.duration + 's' : '-'}</td>
<td>${g.payoutAmount ? (g.payoutAmount / 1e12).toFixed(6) : '-'}</td>
<td>${new Date(g.createdAt).toLocaleString()}</td>
</tr>
`).join('');
} catch (e) {
console.error('Failed to load games:', e);
}
}
async function loadUsers(offset = 0) {
usersOffset = offset;
const search = document.getElementById('userSearch').value.trim();
try {
const url = `/api/admin/users?limit=50&offset=${offset}${search.length >= 3 ? `&search=${encodeURIComponent(search)}` : ''}`;
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
const data = await res.json();
const tbody = document.getElementById('usersTable');
if (!data.users?.length) {
tbody.innerHTML = '<tr><td colspan="8" class="loading">No users found</td></tr>';
return;
}
tbody.innerHTML = data.users.map(u => `
<tr>
<td>${u.id}</td>
<td title="${u.socketId}">${u.socketId ? u.socketId.substring(0, 15) + '...' : '-'}</td>
<td>${u.payoutAddress || '-'}</td>
<td>${u.credits || 0}</td>
<td>${u.gamesPlayed || 0}</td>
<td>${u.gamesWon || 0}</td>
<td>${u.totalWon ? (parseInt(u.totalWon) / 1e12).toFixed(4) : '0'}</td>
<td>${new Date(u.createdAt).toLocaleDateString()}</td>
</tr>
`).join('');
// Pagination
const pagination = document.getElementById('usersPagination');
const total = data.total || 0;
const pages = Math.ceil(total / 50);
const currentPage = Math.floor(offset / 50);
if (pages > 1) {
pagination.innerHTML = `
<button ${currentPage === 0 ? 'disabled' : ''} onclick="loadUsers(${(currentPage - 1) * 50})">Prev</button>
<span>Page ${currentPage + 1} of ${pages}</span>
<button ${currentPage >= pages - 1 ? 'disabled' : ''} onclick="loadUsers(${(currentPage + 1) * 50})">Next</button>
`;
} else {
pagination.innerHTML = '';
}
} catch (e) {
console.error('Failed to load users:', e);
}
}
function searchUsers() {
loadUsers(0);
}
// Auto-refresh every 30 seconds
setInterval(() => {
if (apiKey && currentTab === 'overview') {
loadDashboard();
}
}, 30000);
</script>
</body>
</html>