mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
feat: admin dashboard, alert service, and docs reorganization
- Add alertService.js with Resend email alerts for low balance, disconnects, failed payouts - Update admin.html with full dashboard UI (stats, charts, tables, payout retry) - Add wallet balance to /health endpoint - Add getBalance() and checkTransactionStatus() to walletRPCService - Fix payoutRetryService query and add alert integration - Update .env.example with alert config (RESEND_API_KEY, ADMIN_EMAIL, etc) - Reorganize docs: move TODO.md, LOGS_AND_BACKUP.md to docs/ - Extract deployment guide from README to docs/DEPLOY.md - Consolidate TEMP_TODO.md into docs/TODO.md
This commit is contained in:
+649
-300
@@ -3,347 +3,696 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wownerogue - Admin Dashboard</title>
|
||||
<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: 'Courier New', monospace;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #0f0;
|
||||
padding: 20px;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
h1 { color: #0ff; margin-bottom: 20px; text-align: center; }
|
||||
h2 { color: #ff0; margin: 15px 0 10px; font-size: 14px; }
|
||||
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; max-width: 1400px; margin: 0 auto; }
|
||||
.card {
|
||||
background: #001a00;
|
||||
border: 1px solid #0f0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: #1a1a2e;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
.card.warning { border-color: #ff0; }
|
||||
.card.error { border-color: #f00; }
|
||||
.metric { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #002200; }
|
||||
.metric:last-child { border-bottom: none; }
|
||||
.metric-label { color: #888; }
|
||||
.metric-value { color: #0f0; font-weight: bold; }
|
||||
.metric-value.warning { color: #ff0; }
|
||||
.metric-value.error { color: #f00; }
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; }
|
||||
.status-dot.green { background: #0f0; }
|
||||
.status-dot.yellow { background: #ff0; }
|
||||
.status-dot.red { background: #f00; }
|
||||
.refresh-bar { text-align: center; margin-bottom: 20px; color: #888; }
|
||||
.refresh-bar button {
|
||||
background: #0a5c0a;
|
||||
color: #ff0;
|
||||
border: 2px solid #0f0;
|
||||
padding: 8px 16px;
|
||||
.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;
|
||||
font-family: inherit;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.refresh-bar button:hover { background: #0f0; color: #000; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #002200; }
|
||||
th { color: #0ff; }
|
||||
.log-entry { font-size: 11px; color: #888; padding: 3px 0; }
|
||||
.log-entry.error { color: #f00; }
|
||||
.log-entry.warn { color: #ff0; }
|
||||
#error-banner { display: none; background: #300; border: 1px solid #f00; color: #f88; padding: 10px; margin-bottom: 15px; border-radius: 5px; text-align: center; }
|
||||
.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>
|
||||
<h1>🎮 Wownerogue Admin Dashboard</h1>
|
||||
|
||||
<div id="error-banner"></div>
|
||||
|
||||
<div class="refresh-bar">
|
||||
Last updated: <span id="lastUpdate">-</span>
|
||||
<button onclick="refreshAll()">🔄 Refresh Now</button>
|
||||
Auto-refresh: <select id="autoRefresh" onchange="setAutoRefresh()">
|
||||
<option value="0">Off</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="30000">30s</option>
|
||||
</select>
|
||||
<!-- 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>
|
||||
|
||||
<div class="dashboard">
|
||||
<!-- Server Status -->
|
||||
<div class="card" id="server-card">
|
||||
<h2>🖥️ Server Status</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Status</span>
|
||||
<span class="metric-value" id="serverStatus"><span class="status-dot"></span>Loading...</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Uptime</span>
|
||||
<span class="metric-value" id="uptime">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Node Version</span>
|
||||
<span class="metric-value" id="nodeVersion">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Environment</span>
|
||||
<span class="metric-value" id="nodeEnv">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="card" id="memory-card">
|
||||
<h2>💾 Memory Usage</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Heap Used</span>
|
||||
<span class="metric-value" id="heapUsed">-</span>
|
||||
<!-- 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>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Heap Total</span>
|
||||
<span class="metric-value" id="heapTotal">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">RSS</span>
|
||||
<span class="metric-value" id="rss">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">External</span>
|
||||
<span class="metric-value" id="external">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Game Stats -->
|
||||
<div class="card" id="games-card">
|
||||
<h2>🎮 Game Statistics</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Active Games</span>
|
||||
<span class="metric-value" id="activeGames">-</span>
|
||||
<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>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Queue Length</span>
|
||||
<span class="metric-value" id="queueLength">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Connected Players</span>
|
||||
<span class="metric-value" id="connectedPlayers">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Game Mode</span>
|
||||
<span class="metric-value" id="gameMode">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blockchain -->
|
||||
<div class="card" id="blockchain-card">
|
||||
<h2>⛏️ Blockchain</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Current Block</span>
|
||||
<span class="metric-value" id="currentBlock">-</span>
|
||||
<!-- 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>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Block Source</span>
|
||||
<span class="metric-value" id="blockSource">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Network</span>
|
||||
<span class="metric-value" id="network">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Status -->
|
||||
<div class="card" id="wallet-card">
|
||||
<h2>💰 Wallet RPC</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Status</span>
|
||||
<span class="metric-value" id="walletStatus"><span class="status-dot"></span>-</span>
|
||||
<!-- 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>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Endpoint</span>
|
||||
<span class="metric-value" id="walletEndpoint">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Pending Payouts</span>
|
||||
<span class="metric-value" id="pendingPayouts">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limiting -->
|
||||
<div class="card" id="ratelimit-card">
|
||||
<h2>🛡️ Rate Limiting</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Tracked IPs</span>
|
||||
<span class="metric-value" id="trackedIPs">-</span>
|
||||
<!-- 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>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Blocked Requests (1h)</span>
|
||||
<span class="metric-value" id="blockedRequests">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Max Games/Hour</span>
|
||||
<span class="metric-value" id="maxGamesPerHour">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Modes -->
|
||||
<div class="card" id="payment-card">
|
||||
<h2>💵 Payment Configuration</h2>
|
||||
<div id="paymentConfig">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events Log -->
|
||||
<div class="card" style="grid-column: span 2;">
|
||||
<h2>📋 System Status</h2>
|
||||
<div id="systemLog">
|
||||
<div class="log-entry">Dashboard loaded...</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>
|
||||
</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>
|
||||
let refreshInterval = null;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
// State
|
||||
let apiKey = localStorage.getItem('adminApiKey') || '';
|
||||
let currentTab = 'overview';
|
||||
let usersOffset = 0;
|
||||
let outcomesChart = null;
|
||||
|
||||
// Check if already logged in
|
||||
if (apiKey) {
|
||||
verifyAndShowDashboard();
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function setStatusDot(elementId, status) {
|
||||
const el = document.getElementById(elementId);
|
||||
const dot = el.querySelector('.status-dot');
|
||||
if (dot) {
|
||||
dot.className = 'status-dot ' + status;
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(message, level = 'info') {
|
||||
const log = document.getElementById('systemLog');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + level;
|
||||
entry.textContent = new Date().toLocaleTimeString() + ' - ' + message;
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
if (log.children.length > 20) {
|
||||
log.removeChild(log.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
|
||||
// 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('/health');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('error-banner').style.display = 'none';
|
||||
|
||||
// Server status
|
||||
document.getElementById('serverStatus').innerHTML = '<span class="status-dot green"></span>Online';
|
||||
document.getElementById('uptime').textContent = formatUptime(data.uptime || 0);
|
||||
document.getElementById('nodeVersion').textContent = data.nodeVersion || '-';
|
||||
document.getElementById('nodeEnv').textContent = data.environment || '-';
|
||||
|
||||
// Memory
|
||||
if (data.memory) {
|
||||
document.getElementById('heapUsed').textContent = formatBytes(data.memory.heapUsed || 0);
|
||||
document.getElementById('heapTotal').textContent = formatBytes(data.memory.heapTotal || 0);
|
||||
document.getElementById('rss').textContent = formatBytes(data.memory.rss || 0);
|
||||
document.getElementById('external').textContent = formatBytes(data.memory.external || 0);
|
||||
|
||||
// Warn if heap > 80%
|
||||
const heapPercent = data.memory.heapUsed / data.memory.heapTotal;
|
||||
const heapEl = document.getElementById('heapUsed');
|
||||
heapEl.className = 'metric-value' + (heapPercent > 0.8 ? ' warning' : '');
|
||||
}
|
||||
|
||||
// Games
|
||||
if (data.games) {
|
||||
document.getElementById('activeGames').textContent = data.games.active || 0;
|
||||
document.getElementById('queueLength').textContent = data.games.queued || 0;
|
||||
document.getElementById('connectedPlayers').textContent = data.games.connected || 0;
|
||||
document.getElementById('gameMode').textContent = data.games.mode || '-';
|
||||
}
|
||||
|
||||
// Blockchain
|
||||
document.getElementById('currentBlock').textContent = data.blockHeight || '-';
|
||||
document.getElementById('blockSource').textContent = data.blockSource || '-';
|
||||
document.getElementById('network').textContent = data.network || 'mainnet';
|
||||
|
||||
// Wallet
|
||||
if (data.wallet) {
|
||||
const walletOk = data.wallet.status === 'connected' || data.wallet.available;
|
||||
document.getElementById('walletStatus').innerHTML =
|
||||
`<span class="status-dot ${walletOk ? 'green' : 'red'}"></span>${walletOk ? 'Connected' : 'Disconnected'}`;
|
||||
document.getElementById('walletEndpoint').textContent = data.wallet.endpoint ? '****' + data.wallet.endpoint.slice(-10) : '-';
|
||||
document.getElementById('pendingPayouts').textContent = data.wallet.pendingPayouts || 0;
|
||||
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('walletStatus').innerHTML = '<span class="status-dot yellow"></span>N/A (Free mode)';
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (data.rateLimiter) {
|
||||
document.getElementById('trackedIPs').textContent = data.rateLimiter.trackedEntries || 0;
|
||||
document.getElementById('blockedRequests').textContent = data.rateLimiter.blockedCount || 0;
|
||||
}
|
||||
document.getElementById('maxGamesPerHour').textContent = data.limits?.maxGamesPerHour || 60;
|
||||
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('error-banner').textContent = 'Failed to connect to server: ' + err.message;
|
||||
document.getElementById('error-banner').style.display = 'block';
|
||||
document.getElementById('serverStatus').innerHTML = '<span class="status-dot red"></span>Offline';
|
||||
addLog('Health check failed: ' + err.message, 'error');
|
||||
} catch (e) {
|
||||
document.getElementById('loginError').textContent = 'Connection error';
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
}
|
||||
|
||||
// Also fetch game modes
|
||||
}
|
||||
|
||||
async function verifyAndShowDashboard() {
|
||||
try {
|
||||
const modesRes = await fetch('/api/game-modes');
|
||||
const modes = await modesRes.json();
|
||||
|
||||
let html = '';
|
||||
for (const [key, mode] of Object.entries(modes)) {
|
||||
if (mode && typeof mode === 'object') {
|
||||
html += `<div class="metric"><span class="metric-label">${mode.name || key}</span>`;
|
||||
html += `<span class="metric-value ${mode.enabled ? '' : 'warning'}">${mode.enabled ? '✓ Enabled' : '✗ Disabled'}`;
|
||||
if (mode.cost) html += ` (${mode.cost})`;
|
||||
html += `</span></div>`;
|
||||
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 bal = overview.walletBalance.unlocked_balance || 0;
|
||||
const formatted = (bal / 1e12).toFixed(4);
|
||||
document.getElementById('statBalance').textContent = formatted;
|
||||
document.getElementById('statUnlocked').textContent = `Unlocked: ${formatted}`;
|
||||
|
||||
const walletEl = document.getElementById('walletStatus');
|
||||
walletEl.className = 'wallet-status' + (bal < 100000000000 ? ' warning' : ' healthy');
|
||||
document.getElementById('walletBalance').textContent = `${formatted} ${window.location.hostname.includes('monerogue') ? 'XMR' : 'WOW'}`;
|
||||
} else {
|
||||
document.getElementById('statBalance').textContent = '-';
|
||||
document.getElementById('walletBalance').textContent = 'Disconnected';
|
||||
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' } }
|
||||
}
|
||||
}
|
||||
document.getElementById('paymentConfig').innerHTML = html || '<em>Free mode</em>';
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
document.getElementById('paymentConfig').innerHTML = '<em>Unable to load</em>';
|
||||
console.error('Failed to load payouts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoRefresh() {
|
||||
const interval = parseInt(document.getElementById('autoRefresh').value);
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
if (interval > 0) {
|
||||
refreshInterval = setInterval(refreshAll, interval);
|
||||
addLog(`Auto-refresh set to ${interval/1000}s`);
|
||||
} else {
|
||||
addLog('Auto-refresh disabled');
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
refreshAll();
|
||||
setAutoRefresh();
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user