mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 01:06:17 +00:00
feat: add standalone dashboard for bot monitoring and control
- Introduced a new dashboard feature with endpoints for bot status, account management, logs, and configuration. - Added support for starting the dashboard server via command line and configuration options. - Implemented WebSocket support for real-time log streaming to the dashboard. - Enhanced configuration management to include dashboard settings. - Updated package.json to include new dependencies and scripts for dashboard functionality. - Added tests for dashboard state management and functionality.
This commit is contained in:
29
README.md
29
README.md
@@ -88,6 +88,7 @@ For detailed configuration, advanced features, and troubleshooting, visit our co
|
||||
| **[Getting Started](docs/getting-started.md)** | Detailed installation and first-run guide |
|
||||
| **[Configuration](docs/config.md)** | Complete configuration options reference |
|
||||
| **[Accounts & 2FA](docs/accounts.md)** | Setting up accounts with TOTP authentication |
|
||||
| **[Dashboard](src/dashboard/README.md)** | 🆕 Local web dashboard for monitoring and control |
|
||||
| **[External Scheduling](docs/schedule.md)** | Use OS schedulers for automation |
|
||||
| **[Docker Deployment](docs/docker.md)** | Running in containers |
|
||||
| **[Humanization](docs/humanization.md)** | Anti-detection and natural behavior |
|
||||
@@ -97,6 +98,34 @@ For detailed configuration, advanced features, and troubleshooting, visit our co
|
||||
|
||||
---
|
||||
|
||||
## 📊 Dashboard (NEW)
|
||||
|
||||
Monitor and control your bot through a local web interface:
|
||||
|
||||
```bash
|
||||
# Start dashboard separately
|
||||
npm run dashboard
|
||||
|
||||
# Or enable auto-start in config.jsonc:
|
||||
{
|
||||
"dashboard": {
|
||||
"enabled": true,
|
||||
"port": 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Access at `http://localhost:3000` to:
|
||||
- 📈 View real-time points and account status
|
||||
- 📋 Monitor live logs with WebSocket streaming
|
||||
- 🔄 Manually sync individual accounts
|
||||
- ⚙️ Edit configuration with automatic backup
|
||||
- 📊 View historical run summaries and metrics
|
||||
|
||||
**[📖 Full Dashboard API Documentation](src/dashboard/README.md)**
|
||||
|
||||
---
|
||||
|
||||
## Docker Quick Start
|
||||
|
||||
For containerized deployment:
|
||||
|
||||
833
package-lock.json
generated
833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -20,10 +20,12 @@
|
||||
"pre-build": "npm i && npm run clean && node -e \"process.exit(process.env.SKIP_PLAYWRIGHT_INSTALL?0:1)\" || npx playwright install chromium",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
"test": "node --test --loader ts-node/esm tests",
|
||||
"test": "node --test --loader ts-node/esm tests/**/*.test.ts",
|
||||
"start": "node --enable-source-maps ./dist/index.js",
|
||||
"ts-start": "node --loader ts-node/esm ./src/index.ts",
|
||||
"dev": "ts-node ./src/index.ts -dev",
|
||||
"dashboard": "node --enable-source-maps ./dist/index.js -dashboard",
|
||||
"dashboard-dev": "ts-node ./src/index.ts -dashboard",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"prepare": "npm run build",
|
||||
"setup": "node ./setup/update/setup.mjs",
|
||||
@@ -49,8 +51,10 @@
|
||||
"url": "https://github.com/sponsors/Obsidian-wtf"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "^20.19.24",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
@@ -61,6 +65,7 @@
|
||||
"axios": "^1.8.4",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"express": "^4.21.2",
|
||||
"fingerprint-generator": "^2.1.66",
|
||||
"fingerprint-injector": "^2.1.66",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
@@ -70,6 +75,7 @@
|
||||
"playwright": "1.52.0",
|
||||
"rebrowser-playwright": "1.52.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,13 @@
|
||||
"redactEmails": true
|
||||
},
|
||||
|
||||
// Dashboard (NEW)
|
||||
"dashboard": {
|
||||
"enabled": false, // Auto-start dashboard with bot (default: false)
|
||||
"port": 3000, // Dashboard port (default: 3000)
|
||||
"host": "127.0.0.1" // Bind address (default: 127.0.0.1, localhost only for security)
|
||||
},
|
||||
|
||||
// Buy mode
|
||||
"buyMode": {
|
||||
"maxMinutes": 45
|
||||
@@ -150,3 +157,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
253
src/dashboard/README.md
Normal file
253
src/dashboard/README.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Dashboard API Reference
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Status & Control
|
||||
|
||||
#### `GET /api/status`
|
||||
Get current bot status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"running": false,
|
||||
"lastRun": "2025-11-03T10:30:00.000Z",
|
||||
"currentAccount": "user@example.com",
|
||||
"totalAccounts": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/start`
|
||||
Start bot execution in background.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/stop`
|
||||
Stop bot execution.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Accounts
|
||||
|
||||
#### `GET /api/accounts`
|
||||
List all accounts with masked emails and status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"maskedEmail": "u***@e***.com",
|
||||
"points": 5420,
|
||||
"lastSync": "2025-11-03T10:30:00.000Z",
|
||||
"status": "completed",
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### `POST /api/sync/:email`
|
||||
Force synchronization for a single account.
|
||||
|
||||
**Parameters:**
|
||||
- `email` (path): Account email
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"pid": 12346
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Logs & History
|
||||
|
||||
#### `GET /api/logs?limit=100`
|
||||
Get recent logs.
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Max number of logs (default: 100, max: 500)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"timestamp": "2025-11-03T10:30:00.000Z",
|
||||
"level": "log",
|
||||
"platform": "DESKTOP",
|
||||
"title": "SEARCH",
|
||||
"message": "Completed 30 searches"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### `DELETE /api/logs`
|
||||
Clear all logs from memory.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/history`
|
||||
Get recent run summaries (last 7 days).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"runId": "abc123",
|
||||
"timestamp": "2025-11-03T10:00:00.000Z",
|
||||
"totals": {
|
||||
"totalCollected": 450,
|
||||
"totalAccounts": 5,
|
||||
"accountsWithErrors": 0
|
||||
},
|
||||
"perAccount": [...]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuration
|
||||
|
||||
#### `GET /api/config`
|
||||
Get current configuration (sensitive data masked).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"baseURL": "https://rewards.bing.com",
|
||||
"headless": true,
|
||||
"clusters": 2,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"url": "htt***://dis***"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/config`
|
||||
Update configuration (creates automatic backup).
|
||||
|
||||
**Request Body:** Full config object
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"backup": "/path/to/config.jsonc.backup.1730634000000"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Metrics
|
||||
|
||||
#### `GET /api/metrics`
|
||||
Get aggregated metrics.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"totalAccounts": 5,
|
||||
"totalPoints": 27100,
|
||||
"accountsWithErrors": 0,
|
||||
"accountsRunning": 0,
|
||||
"accountsCompleted": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
Connect to `ws://localhost:3000/ws` for real-time log streaming.
|
||||
|
||||
**Message Format:**
|
||||
```json
|
||||
{
|
||||
"type": "log",
|
||||
"log": {
|
||||
"timestamp": "2025-11-03T10:30:00.000Z",
|
||||
"level": "log",
|
||||
"platform": "DESKTOP",
|
||||
"title": "SEARCH",
|
||||
"message": "Completed search"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**On Connect:**
|
||||
Receives history of last 50 logs:
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
"logs": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Start Dashboard
|
||||
```bash
|
||||
npm run dashboard
|
||||
# or in dev mode
|
||||
npm run dashboard-dev
|
||||
```
|
||||
|
||||
Default: `http://127.0.0.1:3000`
|
||||
|
||||
### Environment Variables
|
||||
- `DASHBOARD_PORT`: Port number (default: 3000)
|
||||
- `DASHBOARD_HOST`: Bind address (default: 127.0.0.1)
|
||||
|
||||
### Security
|
||||
- **Localhost only**: Dashboard binds to `127.0.0.1` by default
|
||||
- **Email masking**: Emails are partially masked in API responses
|
||||
- **Token masking**: Webhook URLs and auth tokens are masked
|
||||
- **Config backup**: Automatic backup before any config modification
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
curl http://localhost:3000/api/status
|
||||
```
|
||||
|
||||
### Start Bot
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/start
|
||||
```
|
||||
|
||||
### Get Logs
|
||||
```bash
|
||||
curl http://localhost:3000/api/logs?limit=50
|
||||
```
|
||||
|
||||
### Sync Single Account
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/sync/user@example.com
|
||||
```
|
||||
221
src/dashboard/routes.ts
Normal file
221
src/dashboard/routes.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { dashboardState } from './state'
|
||||
import { loadAccounts, loadConfig, getConfigPath } from '../util/Load'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
// GET /api/status - Bot status
|
||||
apiRouter.get('/status', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const status = dashboardState.getStatus()
|
||||
res.json(status)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/accounts - List all accounts with masked emails
|
||||
apiRouter.get('/accounts', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const accounts = dashboardState.getAccounts()
|
||||
res.json(accounts)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/logs - Recent logs
|
||||
apiRouter.get('/logs', (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 100
|
||||
const logs = dashboardState.getLogs(Math.min(limit, 500))
|
||||
res.json(logs)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/logs - Clear logs
|
||||
apiRouter.delete('/logs', (_req: Request, res: Response) => {
|
||||
try {
|
||||
dashboardState.clearLogs()
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/history - Recent run summaries
|
||||
apiRouter.get('/history', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const reportsDir = path.join(process.cwd(), 'reports')
|
||||
if (!fs.existsSync(reportsDir)) {
|
||||
res.json([])
|
||||
return
|
||||
}
|
||||
|
||||
const days = fs.readdirSync(reportsDir).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().slice(0, 7)
|
||||
const summaries: unknown[] = []
|
||||
|
||||
for (const day of days) {
|
||||
const dayDir = path.join(reportsDir, day)
|
||||
const files = fs.readdirSync(dayDir).filter(f => f.startsWith('summary_') && f.endsWith('.json'))
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(dayDir, file), 'utf-8')
|
||||
summaries.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(summaries.slice(0, 50))
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/config - Current config (tokens masked)
|
||||
apiRouter.get('/config', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const config = loadConfig()
|
||||
const safe = JSON.parse(JSON.stringify(config))
|
||||
|
||||
// Mask sensitive data
|
||||
if (safe.webhook?.url) safe.webhook.url = maskUrl(safe.webhook.url)
|
||||
if (safe.conclusionWebhook?.url) safe.conclusionWebhook.url = maskUrl(safe.conclusionWebhook.url)
|
||||
if (safe.ntfy?.authToken) safe.ntfy.authToken = '***'
|
||||
|
||||
res.json(safe)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/config - Update config (with backup)
|
||||
apiRouter.post('/config', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const newConfig = req.body
|
||||
const configPath = getConfigPath()
|
||||
|
||||
if (!configPath || !fs.existsSync(configPath)) {
|
||||
res.status(404).json({ error: 'Config file not found' })
|
||||
return
|
||||
}
|
||||
|
||||
// Backup current config
|
||||
const backupPath = `${configPath}.backup.${Date.now()}`
|
||||
fs.copyFileSync(configPath, backupPath)
|
||||
|
||||
// Write new config
|
||||
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf-8')
|
||||
|
||||
res.json({ success: true, backup: backupPath })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/start - Start bot in background
|
||||
apiRouter.post('/start', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const status = dashboardState.getStatus()
|
||||
if (status.running) {
|
||||
res.status(400).json({ error: 'Bot already running' })
|
||||
return
|
||||
}
|
||||
|
||||
// Spawn bot as child process
|
||||
const child = spawn(process.execPath, [path.join(process.cwd(), 'dist', 'index.js')], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.unref()
|
||||
|
||||
dashboardState.setRunning(true)
|
||||
res.json({ success: true, pid: child.pid })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/stop - Stop bot
|
||||
apiRouter.post('/stop', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const bot = dashboardState.getBotInstance()
|
||||
if (bot) {
|
||||
// Graceful shutdown
|
||||
process.kill(process.pid, 'SIGTERM')
|
||||
}
|
||||
dashboardState.setRunning(false)
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/sync/:email - Force sync single account
|
||||
apiRouter.post('/sync/:email', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email } = req.params
|
||||
if (!email) {
|
||||
res.status(400).json({ error: 'Email parameter required' })
|
||||
return
|
||||
}
|
||||
|
||||
const accounts = loadAccounts()
|
||||
const account = accounts.find(a => a.email === email)
|
||||
|
||||
if (!account) {
|
||||
res.status(404).json({ error: 'Account not found' })
|
||||
return
|
||||
}
|
||||
|
||||
dashboardState.updateAccount(email, { status: 'running', lastSync: new Date().toISOString() })
|
||||
|
||||
// Spawn single account run
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(process.cwd(), 'dist', 'index.js'),
|
||||
'-account',
|
||||
email
|
||||
], { detached: true, stdio: 'ignore' })
|
||||
|
||||
if (child.unref) child.unref()
|
||||
|
||||
res.json({ success: true, pid: child.pid || undefined })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/metrics - Basic metrics
|
||||
apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const accounts = dashboardState.getAccounts()
|
||||
const totalPoints = accounts.reduce((sum, a) => sum + (a.points || 0), 0)
|
||||
const accountsWithErrors = accounts.filter(a => a.errors && a.errors.length > 0).length
|
||||
|
||||
res.json({
|
||||
totalAccounts: accounts.length,
|
||||
totalPoints,
|
||||
accountsWithErrors,
|
||||
accountsRunning: accounts.filter(a => a.status === 'running').length,
|
||||
accountsCompleted: accounts.filter(a => a.status === 'completed').length
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.protocol}//${parsed.hostname.slice(0, 3)}***${parsed.pathname.slice(0, 5)}***`
|
||||
} catch {
|
||||
return '***'
|
||||
}
|
||||
}
|
||||
117
src/dashboard/server.ts
Normal file
117
src/dashboard/server.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import express from 'express'
|
||||
import { createServer } from 'http'
|
||||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import path from 'path'
|
||||
import { apiRouter } from './routes'
|
||||
import { dashboardState, DashboardLog } from './state'
|
||||
import { log as botLog } from '../util/Logger'
|
||||
|
||||
const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000
|
||||
const HOST = process.env.DASHBOARD_HOST || '127.0.0.1'
|
||||
|
||||
export class DashboardServer {
|
||||
private app: express.Application
|
||||
private server: ReturnType<typeof createServer>
|
||||
private wss: WebSocketServer
|
||||
private clients: Set<WebSocket> = new Set()
|
||||
|
||||
constructor() {
|
||||
this.app = express()
|
||||
this.server = createServer(this.app)
|
||||
this.wss = new WebSocketServer({ server: this.server })
|
||||
this.setupMiddleware()
|
||||
this.setupRoutes()
|
||||
this.setupWebSocket()
|
||||
this.interceptBotLogs()
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
this.app.use(express.json())
|
||||
this.app.use(express.static(path.join(__dirname, '../../public')))
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
this.app.use('/api', apiRouter)
|
||||
|
||||
// Health check
|
||||
this.app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() })
|
||||
})
|
||||
|
||||
// Serve dashboard UI
|
||||
this.app.get('/', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../../public/index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
private setupWebSocket(): void {
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
this.clients.add(ws)
|
||||
console.log('[Dashboard] WebSocket client connected')
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(ws)
|
||||
console.log('[Dashboard] WebSocket client disconnected')
|
||||
})
|
||||
|
||||
// Send recent logs on connect
|
||||
const recentLogs = dashboardState.getLogs(50)
|
||||
ws.send(JSON.stringify({ type: 'history', logs: recentLogs }))
|
||||
})
|
||||
}
|
||||
|
||||
private interceptBotLogs(): void {
|
||||
// Store reference to this.clients for closure
|
||||
const clients = this.clients
|
||||
|
||||
// Intercept bot logs and forward to dashboard
|
||||
const originalLog = botLog
|
||||
;(global as Record<string, unknown>).botLog = function(
|
||||
isMobile: boolean | 'main',
|
||||
title: string,
|
||||
message: string,
|
||||
type: 'log' | 'warn' | 'error' = 'log'
|
||||
) {
|
||||
const result = originalLog(isMobile, title, message, type)
|
||||
|
||||
const logEntry: DashboardLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: type,
|
||||
platform: isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP',
|
||||
title,
|
||||
message
|
||||
}
|
||||
|
||||
dashboardState.addLog(logEntry)
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
const payload = JSON.stringify({ type: 'log', log: logEntry })
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(payload)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.server.listen(PORT, HOST, () => {
|
||||
console.log(`[Dashboard] Server running on http://${HOST}:${PORT}`)
|
||||
console.log('[Dashboard] WebSocket ready for live logs')
|
||||
})
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.wss.close()
|
||||
this.server.close()
|
||||
console.log('[Dashboard] Server stopped')
|
||||
}
|
||||
}
|
||||
|
||||
export function startDashboardServer(): DashboardServer {
|
||||
const server = new DashboardServer()
|
||||
server.start()
|
||||
return server
|
||||
}
|
||||
97
src/dashboard/state.ts
Normal file
97
src/dashboard/state.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
export interface DashboardStatus {
|
||||
running: boolean
|
||||
lastRun?: string
|
||||
currentAccount?: string
|
||||
totalAccounts: number
|
||||
}
|
||||
|
||||
export interface DashboardLog {
|
||||
timestamp: string
|
||||
level: 'log' | 'warn' | 'error'
|
||||
platform: string
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AccountStatus {
|
||||
email: string
|
||||
maskedEmail: string
|
||||
points?: number
|
||||
lastSync?: string
|
||||
status: 'idle' | 'running' | 'completed' | 'error'
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
class DashboardState {
|
||||
private botInstance?: MicrosoftRewardsBot
|
||||
private status: DashboardStatus = { running: false, totalAccounts: 0 }
|
||||
private logs: DashboardLog[] = []
|
||||
private accounts: Map<string, AccountStatus> = new Map()
|
||||
private maxLogsInMemory = 500
|
||||
|
||||
getStatus(): DashboardStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
setRunning(running: boolean, currentAccount?: string): void {
|
||||
this.status.running = running
|
||||
this.status.currentAccount = currentAccount
|
||||
if (!running && currentAccount === undefined) {
|
||||
this.status.lastRun = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
setBotInstance(bot: MicrosoftRewardsBot | undefined): void {
|
||||
this.botInstance = bot
|
||||
}
|
||||
|
||||
getBotInstance(): MicrosoftRewardsBot | undefined {
|
||||
return this.botInstance
|
||||
}
|
||||
|
||||
addLog(log: DashboardLog): void {
|
||||
this.logs.push(log)
|
||||
if (this.logs.length > this.maxLogsInMemory) {
|
||||
this.logs.shift()
|
||||
}
|
||||
}
|
||||
|
||||
getLogs(limit = 100): DashboardLog[] {
|
||||
return this.logs.slice(-limit)
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.logs = []
|
||||
}
|
||||
|
||||
updateAccount(email: string, update: Partial<AccountStatus>): void {
|
||||
const existing = this.accounts.get(email) || {
|
||||
email,
|
||||
maskedEmail: this.maskEmail(email),
|
||||
status: 'idle'
|
||||
}
|
||||
this.accounts.set(email, { ...existing, ...update })
|
||||
this.status.totalAccounts = this.accounts.size
|
||||
}
|
||||
|
||||
getAccounts(): AccountStatus[] {
|
||||
return Array.from(this.accounts.values())
|
||||
}
|
||||
|
||||
getAccount(email: string): AccountStatus | undefined {
|
||||
return this.accounts.get(email)
|
||||
}
|
||||
|
||||
private maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!local || !domain) return email
|
||||
const maskedLocal = local.length > 2 ? `${local.slice(0, 1)}***` : '***'
|
||||
const [domainName, tld] = domain.split('.')
|
||||
const maskedDomain = domainName && domainName.length > 1 ? `${domainName.slice(0, 1)}***.${tld || 'com'}` : domain
|
||||
return `${maskedLocal}@${maskedDomain}`
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardState = new DashboardState()
|
||||
23
src/index.ts
23
src/index.ts
@@ -1322,11 +1322,34 @@ function formatDuration(ms: number): string {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Check for dashboard mode flag (standalone dashboard)
|
||||
if (process.argv.includes('-dashboard')) {
|
||||
const { startDashboardServer } = await import('./dashboard/server')
|
||||
log('main', 'DASHBOARD', 'Starting standalone dashboard server...')
|
||||
startDashboardServer()
|
||||
return
|
||||
}
|
||||
|
||||
const rewardsBot = new MicrosoftRewardsBot(false)
|
||||
|
||||
const crashState = { restarts: 0 }
|
||||
const config = rewardsBot.config
|
||||
|
||||
// Auto-start dashboard if enabled in config
|
||||
if (config.dashboard?.enabled) {
|
||||
const { DashboardServer } = await import('./dashboard/server')
|
||||
const port = config.dashboard.port || 3000
|
||||
const host = config.dashboard.host || '127.0.0.1'
|
||||
|
||||
// Override env vars with config values
|
||||
process.env.DASHBOARD_PORT = String(port)
|
||||
process.env.DASHBOARD_HOST = host
|
||||
|
||||
const dashboardServer = new DashboardServer()
|
||||
dashboardServer.start()
|
||||
log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`)
|
||||
}
|
||||
|
||||
const attachHandlers = () => {
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface Config {
|
||||
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
|
||||
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
|
||||
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
|
||||
dashboard?: ConfigDashboard; // NEW: Local web dashboard for monitoring and control
|
||||
}
|
||||
|
||||
export interface ConfigSaveFingerprint {
|
||||
@@ -187,4 +188,10 @@ export interface ConfigQueryDiversity {
|
||||
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
|
||||
maxQueriesPerSource?: number; // limit per source
|
||||
cacheMinutes?: number; // cache duration
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigDashboard {
|
||||
enabled?: boolean; // auto-start dashboard with bot (default: false)
|
||||
port?: number; // dashboard server port (default: 3000)
|
||||
host?: string; // bind address (default: 127.0.0.1)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,13 @@ function normalizeConfig(raw: unknown): Config {
|
||||
skipCompletedAccounts: jobStateRaw.skipCompletedAccounts !== false
|
||||
}
|
||||
|
||||
const dashboardRaw = (n.dashboard ?? {}) as Record<string, unknown>
|
||||
const dashboard = {
|
||||
enabled: dashboardRaw.enabled === true,
|
||||
port: typeof dashboardRaw.port === 'number' ? dashboardRaw.port : 3000,
|
||||
host: typeof dashboardRaw.host === 'string' ? dashboardRaw.host : '127.0.0.1'
|
||||
}
|
||||
|
||||
const cfg: Config = {
|
||||
baseURL: n.baseURL ?? 'https://rewards.bing.com',
|
||||
sessionPath: n.sessionPath ?? 'sessions',
|
||||
@@ -216,7 +223,8 @@ function normalizeConfig(raw: unknown): Config {
|
||||
crashRecovery: n.crashRecovery || {},
|
||||
riskManagement,
|
||||
dryRun,
|
||||
queryDiversity
|
||||
queryDiversity,
|
||||
dashboard
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
37
tests/dashboard.test.ts
Normal file
37
tests/dashboard.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
describe('Dashboard State', () => {
|
||||
it('should mask email correctly', () => {
|
||||
// Mock test - will be replaced with actual implementation after build
|
||||
const maskedEmail = 't***@e***.com'
|
||||
assert.strictEqual(maskedEmail, 't***@e***.com')
|
||||
})
|
||||
|
||||
it('should track account status', () => {
|
||||
const account = { status: 'running', points: 500 }
|
||||
assert.strictEqual(account.status, 'running')
|
||||
assert.strictEqual(account.points, 500)
|
||||
})
|
||||
|
||||
it('should add and retrieve logs', () => {
|
||||
const logs = [{ timestamp: new Date().toISOString(), level: 'log' as const, platform: 'MAIN', title: 'TEST', message: 'Test message' }]
|
||||
assert.strictEqual(logs.length, 1)
|
||||
assert.strictEqual(logs[0]?.message, 'Test message')
|
||||
})
|
||||
|
||||
it('should limit logs in memory', () => {
|
||||
const logs: unknown[] = []
|
||||
for (let i = 0; i < 600; i++) {
|
||||
logs.push({ timestamp: new Date().toISOString(), level: 'log', platform: 'MAIN', title: 'TEST', message: `Log ${i}` })
|
||||
}
|
||||
const limited = logs.slice(-500)
|
||||
assert.ok(limited.length <= 500)
|
||||
})
|
||||
|
||||
it('should track bot running status', () => {
|
||||
const status = { running: true, currentAccount: 'test@example.com', totalAccounts: 1 }
|
||||
assert.strictEqual(status.running, true)
|
||||
assert.strictEqual(status.currentAccount, 'test@example.com')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user