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:
2025-11-03 22:27:26 +01:00
parent 4e5f0f2a95
commit 6cd512e1b8
12 changed files with 1641 additions and 6 deletions

View File

@@ -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 | | **[Getting Started](docs/getting-started.md)** | Detailed installation and first-run guide |
| **[Configuration](docs/config.md)** | Complete configuration options reference | | **[Configuration](docs/config.md)** | Complete configuration options reference |
| **[Accounts & 2FA](docs/accounts.md)** | Setting up accounts with TOTP authentication | | **[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 | | **[External Scheduling](docs/schedule.md)** | Use OS schedulers for automation |
| **[Docker Deployment](docs/docker.md)** | Running in containers | | **[Docker Deployment](docs/docker.md)** | Running in containers |
| **[Humanization](docs/humanization.md)** | Anti-detection and natural behavior | | **[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 ## Docker Quick Start
For containerized deployment: For containerized deployment:

833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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", "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", "typecheck": "tsc --noEmit",
"build": "tsc", "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", "start": "node --enable-source-maps ./dist/index.js",
"ts-start": "node --loader ts-node/esm ./src/index.ts", "ts-start": "node --loader ts-node/esm ./src/index.ts",
"dev": "ts-node ./src/index.ts -dev", "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}\"", "lint": "eslint \"src/**/*.{ts,tsx}\"",
"prepare": "npm run build", "prepare": "npm run build",
"setup": "node ./setup/update/setup.mjs", "setup": "node ./setup/update/setup.mjs",
@@ -49,8 +51,10 @@
"url": "https://github.com/sponsors/Obsidian-wtf" "url": "https://github.com/sponsors/Obsidian-wtf"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.25",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node": "^20.19.24", "@types/node": "^20.19.24",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/eslint-plugin": "^7.17.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-modules-newline": "^0.0.6", "eslint-plugin-modules-newline": "^0.0.6",
@@ -61,6 +65,7 @@
"axios": "^1.8.4", "axios": "^1.8.4",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"express": "^4.21.2",
"fingerprint-generator": "^2.1.66", "fingerprint-generator": "^2.1.66",
"fingerprint-injector": "^2.1.66", "fingerprint-injector": "^2.1.66",
"http-proxy-agent": "^7.0.2", "http-proxy-agent": "^7.0.2",
@@ -70,6 +75,7 @@
"playwright": "1.52.0", "playwright": "1.52.0",
"rebrowser-playwright": "1.52.0", "rebrowser-playwright": "1.52.0",
"socks-proxy-agent": "^8.0.5", "socks-proxy-agent": "^8.0.5",
"ts-node": "^10.9.2" "ts-node": "^10.9.2",
"ws": "^8.18.3"
} }
} }

View File

@@ -135,6 +135,13 @@
"redactEmails": true "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 // Buy mode
"buyMode": { "buyMode": {
"maxMinutes": 45 "maxMinutes": 45
@@ -150,3 +157,4 @@
} }
} }

253
src/dashboard/README.md Normal file
View 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
View 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
View 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
View 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()

View File

@@ -1322,11 +1322,34 @@ function formatDuration(ms: number): string {
} }
async function main() { 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 rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 } const crashState = { restarts: 0 }
const config = rewardsBot.config 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 = () => { const attachHandlers = () => {
process.on('unhandledRejection', (reason: unknown) => { process.on('unhandledRejection', (reason: unknown) => {
log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error') log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')

View File

@@ -30,6 +30,7 @@ export interface Config {
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing) dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
dashboard?: ConfigDashboard; // NEW: Local web dashboard for monitoring and control
} }
export interface ConfigSaveFingerprint { export interface ConfigSaveFingerprint {
@@ -187,4 +188,10 @@ export interface ConfigQueryDiversity {
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
maxQueriesPerSource?: number; // limit per source maxQueriesPerSource?: number; // limit per source
cacheMinutes?: number; // cache duration 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)
}

View File

@@ -187,6 +187,13 @@ function normalizeConfig(raw: unknown): Config {
skipCompletedAccounts: jobStateRaw.skipCompletedAccounts !== false 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 = { const cfg: Config = {
baseURL: n.baseURL ?? 'https://rewards.bing.com', baseURL: n.baseURL ?? 'https://rewards.bing.com',
sessionPath: n.sessionPath ?? 'sessions', sessionPath: n.sessionPath ?? 'sessions',
@@ -216,7 +223,8 @@ function normalizeConfig(raw: unknown): Config {
crashRecovery: n.crashRecovery || {}, crashRecovery: n.crashRecovery || {},
riskManagement, riskManagement,
dryRun, dryRun,
queryDiversity queryDiversity,
dashboard
} }
return cfg return cfg

37
tests/dashboard.test.ts Normal file
View 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')
})
})