mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user