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:
jwinterm
2026-01-31 20:21:28 -05:00
parent 7573c300cd
commit a81c19c936
11 changed files with 1846 additions and 548 deletions
+11 -231
View File
@@ -187,240 +187,20 @@ Amounts use atomic units: 1 WOW = 10^11 atomic units.
## Production Deployment
### Checklist
See [docs/DEPLOY.md](docs/DEPLOY.md) for detailed production deployment instructions including:
- systemd service configuration
- Nginx/reverse proxy setup with WebSocket support
- Database permissions and security hardening
- Multi-instance deployment (running Wownero and Monero on the same server)
- [ ] Set `NODE_ENV=production`
- [ ] Configure TLS via reverse proxy (Nginx/Caddy)
- [ ] Set secure database credentials
- [ ] Configure wallet-rpc with authentication
- [ ] Set `ADMIN_API_KEY` for admin endpoints
- [ ] Firewall: expose only 80/443
- [ ] Protect `/admin.html` with basic auth
- [ ] Set up database backups
- [ ] Configure log rotation
---
### Create Dedicated User
## Operations
Create an isolated system user for security:
```bash
# Create system user with no login shell and dedicated home
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --create-home wownerogue
# Create .ssh directory for deploy key
sudo mkdir -p /var/www/wownerogue/.ssh
sudo chmod 700 /var/www/wownerogue/.ssh
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh
# Create log directory
sudo mkdir -p /var/log/wownerogue
sudo chown wownerogue:wownerogue /var/log/wownerogue
```
### Setup Deploy Key and Clone Repository
Generate an SSH deploy key for the wownerogue user:
```bash
# Generate deploy key (as root, since user has no shell)
sudo ssh-keygen -t ed25519 -C "wownerogue-deploy" -f /var/www/wownerogue/.ssh/id_ed25519 -N ""
# Set correct ownership and permissions
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/id_ed25519*
sudo chmod 600 /var/www/wownerogue/.ssh/id_ed25519
sudo chmod 644 /var/www/wownerogue/.ssh/id_ed25519.pub
# Display the public key
sudo cat /var/www/wownerogue/.ssh/id_ed25519.pub
```
Add the deploy key to GitHub:
1. Go to your repository → **Settings** → **Deploy keys**
2. Click **Add deploy key**
3. Paste the public key from the command above
4. Leave "Allow write access" unchecked (read-only is sufficient)
5. Click **Add key**
Configure SSH for GitHub:
```bash
# Create SSH config for the wownerogue user
sudo tee /var/www/wownerogue/.ssh/config << 'EOF'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
EOF
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/config
sudo chmod 600 /var/www/wownerogue/.ssh/config
```
Clone the repository:
```bash
# Clone as the wownerogue user (replace YOUR_USERNAME with your GitHub username)
sudo -u wownerogue git clone git@github.com:YOUR_USERNAME/wownerogue.git /var/www/wownerogue/app
```
### Install Node.js
Install Node.js LTS using NodeSource (run as root):
```bash
# Install Node.js 22.x LTS (or check https://nodejs.org for current LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
# Verify installation
node --version
npm --version
```
Install dependencies and audit for vulnerabilities:
```bash
cd /var/www/wownerogue/app/src
sudo -u wownerogue npm install
# Check for known vulnerabilities
sudo -u wownerogue npm audit
# Auto-fix vulnerabilities where possible
sudo -u wownerogue npm audit fix
```
To update the deployment later:
```bash
cd /var/www/wownerogue/app
sudo -u wownerogue git pull
cd src && sudo -u wownerogue npm install
sudo -u wownerogue npm audit fix
sudo systemctl restart wownerogue
```
### Set File Permissions
```bash
# Set ownership of application files
sudo chown -R wownerogue:wownerogue /var/www/wownerogue
# Restrict permissions (owner only, no world access)
sudo chmod 750 /var/www/wownerogue
sudo chmod 640 /var/www/wownerogue/app/src/.env
```
**Database permissions** - create a restricted PostgreSQL role:
```sql
-- Create role with minimal privileges
CREATE USER wownerogue WITH PASSWORD 'secure-password-here';
CREATE DATABASE wownerogue OWNER wownerogue;
-- Revoke dangerous permissions
REVOKE CREATE ON SCHEMA public FROM wownerogue;
-- Grant only what's needed
GRANT CONNECT ON DATABASE wownerogue TO wownerogue;
GRANT USAGE ON SCHEMA public TO wownerogue;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wownerogue;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO wownerogue;
```
**Filesystem isolation** (optional, for extra hardening):
```bash
# Prevent the user from accessing other users' home directories
sudo chmod 700 /home/*
# Mount application directory with noexec for uploads (if applicable)
# Add to /etc/fstab if using separate partition
```
### systemd Service
Save to `/etc/systemd/system/wownerogue.service`:
```ini
[Unit]
Description=Wownerogue Game Server
After=network.target postgresql.service
[Service]
Type=simple
User=wownerogue
Group=wownerogue
# Adjust paths if you cloned to a different location
WorkingDirectory=/var/www/wownerogue/app/src
EnvironmentFile=/var/www/wownerogue/app/src/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/www/wownerogue /var/log/wownerogue
[Install]
WantedBy=multi-user.target
```
Then enable and start:
```bash
# Create .env from example first!
sudo -u wownerogue cp /var/www/wownerogue/app/src/.env.example /var/www/wownerogue/app/src/.env
sudo vim /var/www/wownerogue/app/src/.env # Edit with your settings
# Enable and start service
sudo systemctl daemon-reload
sudo systemctl enable wownerogue
sudo systemctl start wownerogue
sudo systemctl status wownerogue
```
The systemd hardening options:
- `NoNewPrivileges` - prevents privilege escalation
- `ProtectSystem=strict` - mounts filesystem read-only except allowed paths
- `ProtectHome=yes` - hides /home, /root, /run/user
- `PrivateTmp=yes` - isolates /tmp
- `ReadWritePaths` - whitelists writable directories
### Reverse Proxy (Nginx Proxy Manager)
If using Nginx Proxy Manager:
1. **Add Proxy Host**
- Domain Names: `yourdomain.com`
- Scheme: `http`
- Forward Hostname/IP: Your server's LAN IP (e.g., `192.168.1.100`)
- Forward Port: `3000`
- Enable "Websockets Support" toggle
2. **SSL Tab**
- Request a new SSL certificate
- Enable "Force SSL"
3. **Advanced Tab** - Paste this config for WebSocket support:
```nginx
location /socket.io/ {
proxy_pass http://192.168.1.100:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
```
See [docs/LOGS_AND_BACKUP.md](docs/LOGS_AND_BACKUP.md) for:
- Log management with systemd/journald
- PostgreSQL automated backups
- Backup verification procedures
---
+402
View File
@@ -0,0 +1,402 @@
# Production Deployment Guide
This guide covers deploying Wownerogue to a production environment.
---
## Prerequisites
- Node.js 22.x LTS or later
- PostgreSQL 12+
- Wownero or Monero wallet-rpc running and synced
- A domain with SSL certificate (via reverse proxy)
---
## Deployment Checklist
- [ ] Set `NODE_ENV=production`
- [ ] Configure TLS via reverse proxy (Nginx/Caddy)
- [ ] Set secure database credentials
- [ ] Configure wallet-rpc with authentication
- [ ] Set `ADMIN_API_KEY` for admin endpoints
- [ ] Firewall: expose only 80/443
- [ ] Protect `/admin.html` with basic auth
- [ ] Set up database backups (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md))
- [ ] Configure log rotation
- [ ] Run database migrations
---
## Create Dedicated User
Create an isolated system user for security:
```bash
# Create system user with no login shell and dedicated home
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --create-home wownerogue
# Create .ssh directory for deploy key
sudo mkdir -p /var/www/wownerogue/.ssh
sudo chmod 700 /var/www/wownerogue/.ssh
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh
# Create log directory
sudo mkdir -p /var/log/wownerogue
sudo chown wownerogue:wownerogue /var/log/wownerogue
```
---
## Setup Deploy Key and Clone Repository
Generate an SSH deploy key for the wownerogue user:
```bash
# Generate deploy key (as root, since user has no shell)
sudo ssh-keygen -t ed25519 -C "wownerogue-deploy" -f /var/www/wownerogue/.ssh/id_ed25519 -N ""
# Set correct ownership and permissions
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/id_ed25519*
sudo chmod 600 /var/www/wownerogue/.ssh/id_ed25519
sudo chmod 644 /var/www/wownerogue/.ssh/id_ed25519.pub
# Display the public key
sudo cat /var/www/wownerogue/.ssh/id_ed25519.pub
```
Add the deploy key to GitHub:
1. Go to your repository → **Settings** → **Deploy keys**
2. Click **Add deploy key**
3. Paste the public key from the command above
4. Leave "Allow write access" unchecked (read-only is sufficient)
5. Click **Add key**
Configure SSH for GitHub:
```bash
# Create SSH config for the wownerogue user
sudo tee /var/www/wownerogue/.ssh/config << 'EOF'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
EOF
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/config
sudo chmod 600 /var/www/wownerogue/.ssh/config
```
Clone the repository:
```bash
# Clone as the wownerogue user (replace YOUR_USERNAME with your GitHub username)
sudo -u wownerogue git clone git@github.com:YOUR_USERNAME/wownerogue.git /var/www/wownerogue/app
```
---
## Install Node.js
Install Node.js LTS using NodeSource (run as root):
```bash
# Install Node.js 22.x LTS (or check https://nodejs.org for current LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
# Verify installation
node --version
npm --version
```
Install dependencies and audit for vulnerabilities:
```bash
cd /var/www/wownerogue/app/src
sudo -u wownerogue npm install
# Check for known vulnerabilities
sudo -u wownerogue npm audit
# Auto-fix vulnerabilities where possible
sudo -u wownerogue npm audit fix
```
---
## Set File Permissions
```bash
# Set ownership of application files
sudo chown -R wownerogue:wownerogue /var/www/wownerogue
# Restrict permissions (owner only, no world access)
sudo chmod 750 /var/www/wownerogue
sudo chmod 640 /var/www/wownerogue/app/src/.env
```
**Database permissions** - create a restricted PostgreSQL role:
```sql
-- Create role with minimal privileges
CREATE USER wownerogue WITH PASSWORD 'secure-password-here';
CREATE DATABASE wownerogue OWNER wownerogue;
-- Revoke dangerous permissions
REVOKE CREATE ON SCHEMA public FROM wownerogue;
-- Grant only what's needed
GRANT CONNECT ON DATABASE wownerogue TO wownerogue;
GRANT USAGE ON SCHEMA public TO wownerogue;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wownerogue;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO wownerogue;
```
**Filesystem isolation** (optional, for extra hardening):
```bash
# Prevent the user from accessing other users' home directories
sudo chmod 700 /home/*
# Mount application directory with noexec for uploads (if applicable)
# Add to /etc/fstab if using separate partition
```
---
## Database Setup
### Run Migrations
Database migrations run automatically on server start (via `databaseManager.runMigrations()`), but you can also run them manually:
```bash
cd /var/www/wownerogue/app/src
# Source environment variables
source .env
# Run migrations manually if needed
psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f migrations/001_initial_schema.sql
# ... repeat for each migration file in order
```
### Database Reset (Development Only)
```bash
npm run db:drop # Drop all tables
npm run db:create # Recreate schema
```
---
## systemd Service
Save to `/etc/systemd/system/wownerogue.service`:
```ini
[Unit]
Description=Wownerogue Game Server
After=network.target postgresql.service
[Service]
Type=simple
User=wownerogue
Group=wownerogue
# Adjust paths if you cloned to a different location
WorkingDirectory=/var/www/wownerogue/app/src
EnvironmentFile=/var/www/wownerogue/app/src/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/www/wownerogue /var/log/wownerogue
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
# Create .env from example first!
sudo -u wownerogue cp /var/www/wownerogue/app/src/.env.example /var/www/wownerogue/app/src/.env
sudo vim /var/www/wownerogue/app/src/.env # Edit with your settings
# Enable and start service
sudo systemctl daemon-reload
sudo systemctl enable wownerogue
sudo systemctl start wownerogue
sudo systemctl status wownerogue
```
The systemd hardening options:
- `NoNewPrivileges` - prevents privilege escalation
- `ProtectSystem=strict` - mounts filesystem read-only except allowed paths
- `ProtectHome=yes` - hides /home, /root, /run/user
- `PrivateTmp=yes` - isolates /tmp
- `ReadWritePaths` - whitelists writable directories
---
## Reverse Proxy (Nginx Proxy Manager)
If using Nginx Proxy Manager:
1. **Add Proxy Host**
- Domain Names: `yourdomain.com`
- Scheme: `http`
- Forward Hostname/IP: Your server's LAN IP (e.g., `192.168.1.100`)
- Forward Port: `3000`
- Enable "Websockets Support" toggle
2. **SSL Tab**
- Request a new SSL certificate
- Enable "Force SSL"
3. **Advanced Tab** - Paste this config for WebSocket support:
```nginx
location /socket.io/ {
proxy_pass http://192.168.1.100:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
```
---
## Updating the Deployment
```bash
cd /var/www/wownerogue/app
sudo -u wownerogue git pull
cd src && sudo -u wownerogue npm install
sudo -u wownerogue npm audit fix
sudo systemctl restart wownerogue
```
---
## Multi-Instance Deployment
To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on port 3001):
### Directory Structure
```
/var/www/
├── wownerogue/ # Wownero instance (port 3000)
│ └── app/src/.env
└── monerogue/ # Monero instance (port 3001)
└── app/src/.env
```
### Configuration Differences
| Setting | Wownero Instance | Monero Instance |
|---------|------------------|-----------------|
| `PORT` | 3000 | 3001 |
| `CRYPTO_TYPE` | WOW | XMR |
| `MONERO_NETWORK` | mainnet | stagenet |
| `DB_NAME` | wownerogue | monerogue_stagenet |
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38070 |
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
### Separate systemd Service
Create `/etc/systemd/system/monerogue.service`:
```ini
[Unit]
Description=Monerogue Game Server (Stagenet)
After=network.target postgresql.service
[Service]
Type=simple
User=monerogue
WorkingDirectory=/var/www/monerogue/app/src
EnvironmentFile=/var/www/monerogue/app/src/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
---
## Monitoring
### Check Service Status
```bash
systemctl status wownerogue
journalctl -u wownerogue -f # Follow logs
```
### Health Endpoint
```bash
curl http://localhost:3000/health
```
Returns server uptime, memory usage, wallet status, and balance.
### Admin Dashboard
Access `/admin.html` with your `ADMIN_API_KEY` for:
- Wallet balance and connection status
- Pending/failed payouts
- Game statistics
- User search
---
## Troubleshooting
### Server Won't Start
```bash
# Check logs for errors
journalctl -u wownerogue -n 50
# Verify environment file
sudo -u wownerogue cat /var/www/wownerogue/app/src/.env
# Test database connection
psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;"
```
### Wallet RPC Issues
```bash
# Test wallet RPC directly
curl -u user:password -X POST http://127.0.0.1:34570/json_rpc \
-d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' \
-H 'Content-Type: application/json'
```
### Database Migrations
If migrations fail, check the `migrations` table:
```sql
SELECT * FROM migrations ORDER BY id;
```
Migrations are tracked by name to prevent duplicate execution.
+49 -3
View File
@@ -66,13 +66,59 @@
- [x] **Rate Limit Tuning**: Current defaults (60 games/hour, 100 payouts/day) suitable for launch ✅
- [x] **Log Rotation**: Using systemd journald with configurable retention (see `LOGS_AND_BACKUP.md`) ✅
## Security Audit Fixes (Jan 2026)
- [x] **XSS Vulnerability**: Complete HTML entity escaping in chat handler (`&`, `<`, `>`, `"`, `'`)
- [x] **tx_hash Idempotency**: UNIQUE partial indexes on payments/payouts tables (migration 008)
- [x] **Timing-Safe Auth**: Admin API uses `crypto.timingSafeEqual()` for key comparison
- [x] **Transactional Payouts**: Payout flow creates pending record first, updates atomically
- [x] **Payout Retry Service**: Auto-retries failed payouts (max 3 attempts, 5-min interval)
- [x] **Address Validation**: `validateAddress()` called before every payout attempt
- [x] **Transaction Status Check**: `checkTransactionStatus()` verifies blockchain before retry
## Smirk Wallet Integration (Jan 2026)
- [x] **Database Schema**: `smirk_public_key` column, `smirk_challenges` table (migration 009)
- [x] **Auth Endpoints**: `/api/auth/smirk/challenge`, `/verify`, `/status`
- [x] **Frontend Module**: `smirkAuth.js` with extension detection and login flow
- [x] **Address Modal**: Shows Smirk connect button or install link
## Admin Dashboard (Jan 2026)
- [x] **Alert Service**: Email alerts via Resend for low balance, disconnects, failed payouts
- [x] **Stats Endpoints**: `/api/admin/stats/overview`, `/payouts`, `/games`, `/users`
- [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables
- [x] **Payout Retry**: Manual retry button in admin dashboard
## Pre-Production Testing Checklist
### Security Verification
- [ ] Chat XSS: `<script>` and `"onclick=` render as plain text
- [ ] Duplicate tx_hash insertion fails with constraint error
- [ ] Payout creates pending record before RPC call
- [ ] Failed payouts have records for retry
- [ ] Retry service processes failed payouts (check logs)
### Smirk Integration Verification
- [ ] "Connect Smirk" button appears when extension installed
- [ ] "Get Smirk Wallet" link appears when extension NOT installed
- [ ] Challenge generated and stored in DB
- [ ] Signature verification links public key to user
- [ ] Payout address auto-populated from wallet
- [ ] Anonymous sessions still work (backwards compatible)
### End-to-End Testing
- [ ] Play full game with Smirk login
- [ ] Win and receive payout to Smirk address
- [ ] Verify payout recorded in transaction history
- [ ] Manual integration test with real wallet-rpc
---
## Known Issues / Future Improvements
### Backend
- [ ] **Database Transactions**: Wrap multi-step financial operations in transactions (credit deductions, payout processing)
- [x] **Database Transactions**: `withTransaction()` helper for atomic financial operations ✅
- [ ] **Payment Idempotency**: Replace in-memory Set with database-backed idempotency keys for payment confirmation
- [ ] **Payout Retry Queue**: Implement automatic retry mechanism for failed payouts
- [ ] **Address Checksum Validation**: Add cryptographic checksum validation for XMR/WOW addresses
- [x] **Payout Retry Queue**: Automatic retry mechanism via `payoutRetryService.js` ✅
- [x] **Address Validation**: Wallet RPC `validate_address` method ✅
- [ ] **Redis Rate Limiting**: Replace in-memory rate limiter with Redis for persistence across restarts
### Frontend
+649 -300
View File
@@ -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>
+28 -1
View File
@@ -295,7 +295,34 @@ RPC_POLL_INTERVAL=2000
# curl -H "X-Admin-Key: your-key" http://localhost:3000/api/admin/...
# =============================================================================
# SECTION 12: DEVELOPMENT/DEBUG OPTIONS
# SECTION 12: SERVER & MULTI-INSTANCE
# =============================================================================
# Port for HTTP server (default 3000)
# Use different ports for multiple instances (e.g., 3000 for WOW, 3001 for XMR)
PORT=3000
# =============================================================================
# SECTION 13: ADMIN ALERTS (Resend)
# =============================================================================
# Email alerts for low wallet balance and critical issues.
# Sign up at https://resend.com to get an API key.
# Resend API key
# RESEND_API_KEY=re_xxxxxxxxxxxxx
# Admin email to receive alerts
# ADMIN_EMAIL=admin@example.com
# Low balance threshold in atomic units (triggers email alert)
# Default: 100000000000 (0.1 XMR/WOW)
LOW_BALANCE_THRESHOLD=100000000000
# Alert cooldown in milliseconds (avoid spam)
# Default: 3600000 (1 hour)
ALERT_COOLDOWN_MS=3600000
# =============================================================================
# SECTION 14: DEVELOPMENT/DEBUG OPTIONS
# =============================================================================
# These are for testing only. Don't use in production with real money!
+390 -12
View File
@@ -12,6 +12,7 @@ require('dotenv').config();
const DatabaseManager = require('./db/databaseManager');
const WalletRPCService = require('./payments/walletRPCService');
const PayoutRetryService = require('./payments/payoutRetryService');
const AlertService = require('./services/alertService');
const GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
@@ -197,15 +198,30 @@ app.post('/api/user/:socketId/address', asyncHandler(async (req, res) => {
}));
// Health check endpoint - enhanced for monitoring dashboard
app.get('/health', (req, res) => {
app.get('/health', async (req, res) => {
const memUsage = process.memoryUsage();
// Get wallet balance if healthy
let walletBalance = null;
let lowBalanceWarning = false;
const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
if (walletRPCService.isHealthy) {
try {
walletBalance = await walletRPCService.getBalance();
lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold;
} catch (e) {
// Ignore balance fetch errors
}
}
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
// Memory details
memory: {
heapUsed: memUsage.heapUsed,
@@ -213,7 +229,7 @@ app.get('/health', (req, res) => {
rss: memUsage.rss,
external: memUsage.external || 0
},
// Game statistics
games: {
active: activeGames.size,
@@ -221,32 +237,34 @@ app.get('/health', (req, res) => {
connected: io.sockets.sockets.size || 0,
mode: gameModeManager.gameMode
},
// Blockchain
blockHeight: debugManager.getCurrentBlockHeight(),
blockSource: process.env.BLOCK_SOURCE || 'daemon',
network: process.env.MONERO_NETWORK || 'mainnet',
// Wallet status
wallet: {
available: gameModeManager.paymentsEnabled,
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
pendingPayouts: 0 // Could track this in gameModeManager
pendingPayouts: 0,
balance: walletBalance,
lowBalanceWarning
},
// Rate limiter stats (if available)
rateLimiter: socketHandlers?.rateLimiter ? {
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
} : null,
// Limits
limits: {
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
},
// Legacy fields for compatibility
gameMode: gameModeManager.gameMode,
paymentsEnabled: gameModeManager.paymentsEnabled,
@@ -841,6 +859,354 @@ app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => {
});
}));
// =============================================================================
// Admin Stats Endpoints
// =============================================================================
// GET /api/admin/stats/overview - Server-wide statistics
app.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) => {
// Get user counts
const userStats = await db.query(`
SELECT
COUNT(*) as total_users,
SUM(total_games_played) as total_games,
SUM(total_games_won) as total_wins,
SUM(total_amount_won) as total_payout_volume,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as new_users_24h
FROM users
`);
// Get payout counts
const payoutStats = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_payouts,
COUNT(*) FILTER (WHERE status = 'failed') as failed_payouts,
COUNT(*) FILTER (WHERE status = 'completed') as completed_payouts,
COUNT(*) FILTER (WHERE status = 'completed' AND processed_at > NOW() - INTERVAL '24 hours') as payouts_24h
FROM payouts
`);
// Get game counts for last 24h
const gameStats = await db.query(`
SELECT
COUNT(*) as games_24h,
COUNT(*) FILTER (WHERE status = 'won') as wins_24h,
COUNT(*) FILTER (WHERE status = 'lost') as losses_24h
FROM games
WHERE created_at > NOW() - INTERVAL '24 hours'
`);
// Get wallet balance
let walletBalance = null;
if (walletRPCService.isHealthy) {
try {
walletBalance = await walletRPCService.getBalance();
} catch (e) {
// Ignore
}
}
const user = userStats.rows[0];
const payout = payoutStats.rows[0];
const game = gameStats.rows[0];
res.json({
totalUsers: parseInt(user.total_users) || 0,
totalGamesPlayed: parseInt(user.total_games) || 0,
totalGamesWon: parseInt(user.total_wins) || 0,
totalPayoutVolume: user.total_payout_volume || '0',
pendingPayouts: parseInt(payout.pending_payouts) || 0,
failedPayouts: parseInt(payout.failed_payouts) || 0,
completedPayouts: parseInt(payout.completed_payouts) || 0,
walletBalance: walletBalance,
last24h: {
games: parseInt(game.games_24h) || 0,
wins: parseInt(game.wins_24h) || 0,
losses: parseInt(game.losses_24h) || 0,
payouts: parseInt(payout.payouts_24h) || 0,
newUsers: parseInt(user.new_users_24h) || 0
}
});
}));
// GET /api/admin/stats/payouts - Payout details
app.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) => {
const { status, limit = 50, offset = 0 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
let whereClause = '';
const params = [limitNum, offsetNum];
if (status && ['pending', 'failed', 'completed', 'permanently_failed'].includes(status)) {
whereClause = 'WHERE status = $3';
params.push(status);
}
const result = await db.query(`
SELECT p.id, p.game_id, p.user_id, p.amount, p.payout_address, p.status,
p.tx_hash, p.fee, p.retry_count, p.last_error, p.created_at, p.processed_at
FROM payouts p
${whereClause}
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2
`, params);
// Get totals
const totals = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_volume
FROM payouts
`);
res.json({
payouts: result.rows.map(p => ({
id: p.id,
gameId: p.game_id,
userId: p.user_id,
amount: p.amount,
address: p.payout_address,
status: p.status,
txHash: p.tx_hash,
fee: p.fee,
retryCount: p.retry_count,
lastError: p.last_error,
createdAt: p.created_at,
processedAt: p.processed_at
})),
totals: {
pending: parseInt(totals.rows[0].pending_count) || 0,
failed: parseInt(totals.rows[0].failed_count) || 0,
completed: parseInt(totals.rows[0].completed_count) || 0,
permanentlyFailed: parseInt(totals.rows[0].permanently_failed_count) || 0,
totalVolume: totals.rows[0].total_volume || '0'
}
});
}));
// GET /api/admin/stats/games - Game statistics
app.get('/api/admin/stats/games', adminAuth, asyncHandler(async (req, res) => {
const { period = '24h', limit = 50 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
// Parse period
let interval = '24 hours';
if (period === '7d') interval = '7 days';
else if (period === '30d') interval = '30 days';
// Summary stats
const summary = await db.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'won') as won,
COUNT(*) FILTER (WHERE status = 'lost') as lost,
COUNT(*) FILTER (WHERE outcome = 'escaped') as escaped,
COUNT(*) FILTER (WHERE outcome = 'caught_by_monster') as caught,
COUNT(*) FILTER (WHERE outcome = 'expired') as expired,
AVG(duration_seconds) as avg_duration
FROM games
WHERE created_at > NOW() - INTERVAL '${interval}'
`);
// By game mode
const byMode = await db.query(`
SELECT game_mode, COUNT(*) as count
FROM games
WHERE created_at > NOW() - INTERVAL '${interval}'
GROUP BY game_mode
`);
// Top winners
const topWinners = await db.query(`
SELECT u.socket_id, u.total_games_won as wins, u.total_amount_won as total_won
FROM users u
WHERE u.total_games_won > 0
ORDER BY u.total_amount_won DESC
LIMIT 10
`);
// Recent games
const recentGames = await db.query(`
SELECT g.id, g.game_mode, g.status, g.outcome, g.duration_seconds, g.created_at,
p.amount as payout_amount
FROM games g
LEFT JOIN payouts p ON p.game_id = g.id AND p.status = 'completed'
ORDER BY g.created_at DESC
LIMIT $1
`, [limitNum]);
const s = summary.rows[0];
const modeMap = {};
byMode.rows.forEach(r => { modeMap[r.game_mode] = parseInt(r.count) || 0; });
res.json({
summary: {
total: parseInt(s.total) || 0,
won: parseInt(s.won) || 0,
lost: parseInt(s.lost) || 0,
escaped: parseInt(s.escaped) || 0,
caught: parseInt(s.caught) || 0,
expired: parseInt(s.expired) || 0
},
avgDuration: parseFloat(s.avg_duration) || 0,
byMode: modeMap,
topWinners: topWinners.rows.map(w => ({
socketId: w.socket_id?.substring(0, 20) + '...',
wins: parseInt(w.wins) || 0,
totalWon: w.total_won || '0'
})),
recentGames: recentGames.rows.map(g => ({
id: g.id,
mode: g.game_mode,
status: g.status,
outcome: g.outcome,
duration: g.duration_seconds,
payoutAmount: g.payout_amount,
createdAt: g.created_at
}))
});
}));
// GET /api/admin/users - Paginated user list
app.get('/api/admin/users', adminAuth, asyncHandler(async (req, res) => {
const { search, limit = 50, offset = 0 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
let whereClause = '';
const params = [limitNum, offsetNum];
if (search && search.length >= 3) {
whereClause = 'WHERE socket_id ILIKE $3 OR payout_address ILIKE $3';
params.push(`%${search}%`);
}
const result = await db.query(`
SELECT id, socket_id, payout_address, credits, total_games_played, total_games_won,
total_amount_paid, total_amount_won, total_credits_purchased, created_at
FROM users
${whereClause}
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, params);
const countResult = await db.query(`
SELECT COUNT(*) as total FROM users ${whereClause}
`, search && search.length >= 3 ? [`%${search}%`] : []);
res.json({
users: result.rows.map(u => ({
id: u.id,
socketId: u.socket_id,
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 15)}...` : null,
credits: u.credits || 0,
gamesPlayed: parseInt(u.total_games_played) || 0,
gamesWon: parseInt(u.total_games_won) || 0,
totalPaid: u.total_amount_paid || '0',
totalWon: u.total_amount_won || '0',
creditsPurchased: parseInt(u.total_credits_purchased) || 0,
createdAt: u.created_at
})),
total: parseInt(countResult.rows[0]?.total) || 0,
limit: limitNum,
offset: offsetNum
});
}));
// GET /api/admin/users/:id - Detailed user info
app.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => {
const { id } = req.params;
const userResult = await db.query(`
SELECT * FROM users WHERE id = $1
`, [id]);
if (!userResult.rows.length) {
throw new NotFoundError('User not found');
}
const user = userResult.rows[0];
// Get user's games
const gamesResult = await db.query(`
SELECT id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, created_at
FROM games
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
// Get user's payments
const paymentsResult = await db.query(`
SELECT id, amount, status, tx_hash, credits_purchased, created_at
FROM payments
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
// Get user's payouts
const payoutsResult = await db.query(`
SELECT id, amount, status, tx_hash, fee, payout_address, created_at, processed_at
FROM payouts
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
res.json({
user: {
id: user.id,
socketId: user.socket_id,
payoutAddress: user.payout_address,
credits: user.credits || 0,
gamesPlayed: parseInt(user.total_games_played) || 0,
gamesWon: parseInt(user.total_games_won) || 0,
totalPaid: user.total_amount_paid || '0',
totalWon: user.total_amount_won || '0',
creditsPurchased: parseInt(user.total_credits_purchased) || 0,
createdAt: user.created_at
},
games: gamesResult.rows,
payments: paymentsResult.rows,
payouts: payoutsResult.rows
});
}));
// POST /api/admin/payouts/:id/retry - Manually retry a failed payout
app.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => {
const { id } = req.params;
const payoutResult = await db.query(`
SELECT * FROM payouts WHERE id = $1
`, [id]);
if (!payoutResult.rows.length) {
throw new NotFoundError('Payout not found');
}
const payout = payoutResult.rows[0];
if (payout.status === 'completed') {
throw new ValidationError('Payout already completed');
}
// Reset status to pending for retry
await db.query(`
UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1
`, [id]);
res.json({
success: true,
message: 'Payout queued for retry',
payoutId: id
});
}));
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
@@ -1110,8 +1476,9 @@ async function startServer() {
});
// Start HTTP server
http.listen(3000, function() {
console.log('🚀 Wownerogue server listening on *:3000');
const PORT = process.env.PORT || 3000;
http.listen(PORT, function() {
console.log(`🚀 Wownerogue server listening on *:${PORT}`);
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
const summary = paymentConfigManager.summarize();
@@ -1147,14 +1514,25 @@ async function startServer() {
// Start payout retry service for failed/stuck payouts
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
// Initialize alert service for email notifications
const alertService = new AlertService({
walletService: walletRPCService,
db: databaseManager,
debugManager
});
const payoutRetryService = new PayoutRetryService({
db: databaseManager,
walletService: walletRPCService,
debugManager,
maxRetries,
retryIntervalMs
retryIntervalMs,
alertService // Pass alert service for failure notifications
});
payoutRetryService.start();
// Start periodic alert checks (every 5 minutes)
alertService.startPeriodicChecks(300000);
}
} catch (error) {
+1
View File
@@ -38,6 +38,7 @@
"socket.io-client": "^4.8.1",
"uuid": "^9.0.0"
,"qrcode": "^1.5.3",
"resend": "^4.0.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
+18 -1
View File
@@ -8,12 +8,13 @@
const { AppError } = require('../utils/errors');
class PayoutRetryService {
constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000 }) {
constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000, alertService = null }) {
this.db = db;
this.walletService = walletService;
this.debugManager = debugManager;
this.maxRetries = maxRetries;
this.retryIntervalMs = retryIntervalMs; // Default: 5 minutes
this.alertService = alertService; // For sending failure notifications
this.isProcessing = false;
this.retryTimer = null;
}
@@ -208,6 +209,22 @@ class PayoutRetryService {
if (newStatus === 'permanently_failed') {
console.error(`❌ Payout ${id} permanently failed after ${newRetryCount} attempts: ${error.message}`);
// Send email alert for permanent failure
if (this.alertService) {
try {
await this.alertService.alertPayoutFailed({
id,
game_id,
amount,
payout_address,
retry_count: newRetryCount,
last_error: error.message
});
} catch (alertError) {
console.error('Failed to send payout failure alert:', alertError.message);
}
}
} else {
console.warn(`⚠️ Payout ${id} retry failed (attempt ${newRetryCount}/${this.maxRetries}): ${error.message}`);
}
+24
View File
@@ -318,6 +318,30 @@ class WalletRPCService {
return truncated;
}
/**
* Get wallet balance for admin monitoring
* @returns {Object} - { balance: bigint, unlocked_balance: bigint } in atomic units
*/
async getBalance() {
try {
const response = await this.rpcCall('get_balance', {
account_index: this.accountIndex
});
return {
balance: response.result?.balance || 0,
unlocked_balance: response.result?.unlocked_balance || 0
};
} catch (error) {
console.error('❌ Failed to get wallet balance:', error.message);
return {
balance: 0,
unlocked_balance: 0,
error: error.message
};
}
}
/**
* Validate a payout address using wallet RPC
* @param {string} address - The address to validate
+274
View File
@@ -0,0 +1,274 @@
/**
* AlertService - Email alerts for admin monitoring
*
* Sends alerts via Resend for:
* - Low wallet balance
* - Failed payouts
* - Wallet disconnection
* - High pending payout queue
*/
class AlertService {
constructor({ walletService, db, debugManager }) {
this.walletService = walletService;
this.db = db;
this.debugManager = debugManager;
// Resend configuration
this.resendApiKey = process.env.RESEND_API_KEY;
this.adminEmail = process.env.ADMIN_EMAIL;
this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@monerogue.app';
// Thresholds
this.lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR
this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10;
// Cooldowns (prevent alert spam)
this.alertCooldown = parseInt(process.env.ALERT_COOLDOWN_MS) || 3600000; // 1 hour
this.lastAlertSent = new Map();
// Track wallet disconnect duration
this.walletDisconnectedSince = null;
this.walletDisconnectAlertThreshold = 300000; // 5 minutes
this.enabled = !!(this.resendApiKey && this.adminEmail);
if (this.enabled) {
console.log('📧 Alert service enabled - alerts will be sent to', this.adminEmail);
} else {
console.log('📧 Alert service disabled - set RESEND_API_KEY and ADMIN_EMAIL to enable');
}
}
/**
* Check if an alert type is on cooldown
*/
isOnCooldown(alertType) {
const lastSent = this.lastAlertSent.get(alertType) || 0;
return Date.now() - lastSent < this.alertCooldown;
}
/**
* Send an email alert via Resend
*/
async sendAlert(type, { subject, html }) {
if (!this.enabled) {
return { sent: false, reason: 'Alert service not configured' };
}
if (this.isOnCooldown(type)) {
return { sent: false, reason: 'On cooldown' };
}
try {
// Dynamic import to avoid requiring resend if not configured
const { Resend } = require('resend');
const resend = new Resend(this.resendApiKey);
const result = await resend.emails.send({
from: this.fromEmail,
to: this.adminEmail,
subject: subject,
html: html
});
this.lastAlertSent.set(type, Date.now());
console.log(`📧 Alert sent: ${type} - ${subject}`);
return { sent: true, id: result.data?.id };
} catch (error) {
console.error(`❌ Failed to send alert: ${error.message}`);
return { sent: false, error: error.message };
}
}
/**
* Check wallet balance and alert if low
*/
async checkWalletBalance() {
if (!this.walletService?.isHealthy) {
return;
}
try {
const balance = await this.walletService.getBalance();
if (balance.unlocked_balance < this.lowBalanceThreshold) {
const balanceFormatted = (balance.unlocked_balance / 1e12).toFixed(6);
const thresholdFormatted = (this.lowBalanceThreshold / 1e12).toFixed(6);
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
await this.sendAlert('low_balance', {
subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`,
html: `
<h2>Low Wallet Balance Warning</h2>
<p>The payout wallet balance is running low:</p>
<ul>
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
<li><strong>Unlocked:</strong> ${balanceFormatted} ${cryptoType}</li>
<li><strong>Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
</ul>
<p>Please top up the wallet to ensure payouts can continue.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Server: ${process.env.NODE_ENV || 'development'}<br>
Time: ${new Date().toISOString()}
</p>
`
});
}
} catch (error) {
console.error('Failed to check wallet balance for alerts:', error.message);
}
}
/**
* Check for wallet disconnection
*/
async checkWalletConnection() {
if (this.walletService?.isHealthy) {
this.walletDisconnectedSince = null;
return;
}
if (!this.walletDisconnectedSince) {
this.walletDisconnectedSince = Date.now();
return;
}
const disconnectedFor = Date.now() - this.walletDisconnectedSince;
if (disconnectedFor > this.walletDisconnectAlertThreshold) {
const minutes = Math.floor(disconnectedFor / 60000);
await this.sendAlert('wallet_disconnect', {
subject: `🔴 Wallet RPC Disconnected (${minutes}+ min)`,
html: `
<h2>Wallet RPC Connection Lost</h2>
<p>The wallet RPC has been unreachable for <strong>${minutes} minutes</strong>.</p>
<p>Payouts cannot be processed while the wallet is disconnected.</p>
<p>Please check:</p>
<ul>
<li>Is the wallet-rpc process running?</li>
<li>Is the wallet file accessible?</li>
<li>Is there enough disk space?</li>
</ul>
<hr>
<p style="color: #666; font-size: 12px;">
Endpoint: ${process.env.PRIMARY_WALLET_ENDPOINT || 'not configured'}<br>
Time: ${new Date().toISOString()}
</p>
`
});
}
}
/**
* Check for pending payouts queue
*/
async checkPendingPayouts() {
if (!this.db) return;
try {
const result = await this.db.query(`
SELECT COUNT(*) as count FROM payouts
WHERE status = 'pending' OR status = 'failed'
`);
const pendingCount = parseInt(result.rows[0]?.count) || 0;
if (pendingCount >= this.highPendingThreshold) {
await this.sendAlert('high_pending', {
subject: `⚠️ High Pending Payouts: ${pendingCount}`,
html: `
<h2>High Pending Payout Queue</h2>
<p>There are <strong>${pendingCount}</strong> pending or failed payouts in the queue.</p>
<p>This may indicate:</p>
<ul>
<li>Wallet connectivity issues</li>
<li>Insufficient balance</li>
<li>Network congestion</li>
</ul>
<p>Please check the admin dashboard for details.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Threshold: ${this.highPendingThreshold}<br>
Time: ${new Date().toISOString()}
</p>
`
});
}
} catch (error) {
console.error('Failed to check pending payouts for alerts:', error.message);
}
}
/**
* Alert when a payout permanently fails
*/
async alertPayoutFailed(payout) {
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
const amountFormatted = (payout.amount / 1e12).toFixed(6);
await this.sendAlert('payout_failed', {
subject: `💀 Payout Permanently Failed - ${amountFormatted} ${cryptoType}`,
html: `
<h2>Payout Permanently Failed</h2>
<p>A payout has failed after all retry attempts:</p>
<ul>
<li><strong>Payout ID:</strong> ${payout.id}</li>
<li><strong>Game ID:</strong> ${payout.game_id}</li>
<li><strong>Amount:</strong> ${amountFormatted} ${cryptoType}</li>
<li><strong>Address:</strong> ${payout.payout_address?.substring(0, 20)}...</li>
<li><strong>Retry Count:</strong> ${payout.retry_count}</li>
<li><strong>Last Error:</strong> ${payout.last_error || 'Unknown'}</li>
</ul>
<p>Manual intervention may be required.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Time: ${new Date().toISOString()}
</p>
`
});
}
/**
* Run all checks (call this periodically)
*/
async runChecks() {
await this.checkWalletBalance();
await this.checkWalletConnection();
await this.checkPendingPayouts();
}
/**
* Start periodic checking
*/
startPeriodicChecks(intervalMs = 300000) { // Default 5 minutes
if (!this.enabled) {
return;
}
// Run initial check after a short delay
setTimeout(() => this.runChecks(), 10000);
// Then run periodically
this.checkInterval = setInterval(() => {
this.runChecks();
}, intervalMs);
console.log(`📧 Alert checks will run every ${intervalMs / 60000} minutes`);
}
/**
* Stop periodic checking
*/
stopPeriodicChecks() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
}
}
module.exports = AlertService;