mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 17:26:17 +00:00
feat: Refactor configuration management to disable editing via dashboard and implement persistent stats tracking
This commit is contained in:
262
src/dashboard/StatsManager.ts
Normal file
262
src/dashboard/StatsManager.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* StatsManager - Persistent dashboard statistics system
|
||||
* Saves all metrics to JSON files for persistence across restarts
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface DailyStats {
|
||||
date: string // ISO date (YYYY-MM-DD)
|
||||
totalPoints: number
|
||||
accountsCompleted: number
|
||||
accountsWithErrors: number
|
||||
totalSearches: number
|
||||
totalActivities: number
|
||||
runDuration: number // milliseconds
|
||||
}
|
||||
|
||||
export interface AccountDailyStats {
|
||||
email: string
|
||||
date: string
|
||||
pointsEarned: number
|
||||
desktopSearches: number
|
||||
mobileSearches: number
|
||||
activitiesCompleted: number
|
||||
errors: string[]
|
||||
completedAt?: string // ISO timestamp
|
||||
}
|
||||
|
||||
export interface GlobalStats {
|
||||
totalRunsAllTime: number
|
||||
totalPointsAllTime: number
|
||||
averagePointsPerDay: number
|
||||
lastRunDate?: string
|
||||
firstRunDate?: string
|
||||
}
|
||||
|
||||
export class StatsManager {
|
||||
private statsDir: string
|
||||
private dailyStatsPath: string
|
||||
private globalStatsPath: string
|
||||
|
||||
constructor() {
|
||||
this.statsDir = path.join(process.cwd(), 'sessions', 'dashboard-stats')
|
||||
this.dailyStatsPath = path.join(this.statsDir, 'daily')
|
||||
this.globalStatsPath = path.join(this.statsDir, 'global.json')
|
||||
|
||||
this.ensureDirectories()
|
||||
}
|
||||
|
||||
private ensureDirectories(): void {
|
||||
if (!fs.existsSync(this.statsDir)) {
|
||||
fs.mkdirSync(this.statsDir, { recursive: true })
|
||||
}
|
||||
if (!fs.existsSync(this.dailyStatsPath)) {
|
||||
fs.mkdirSync(this.dailyStatsPath, { recursive: true })
|
||||
}
|
||||
if (!fs.existsSync(this.globalStatsPath)) {
|
||||
this.saveGlobalStats({
|
||||
totalRunsAllTime: 0,
|
||||
totalPointsAllTime: 0,
|
||||
averagePointsPerDay: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save daily stats (one file per day)
|
||||
*/
|
||||
saveDailyStats(stats: DailyStats): void {
|
||||
try {
|
||||
const filePath = path.join(this.dailyStatsPath, `${stats.date}.json`)
|
||||
fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
console.error('[STATS] Failed to save daily stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load daily stats for specific date
|
||||
*/
|
||||
loadDailyStats(date: string): DailyStats | null {
|
||||
try {
|
||||
const filePath = path.join(this.dailyStatsPath, `${date}.json`)
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
|
||||
const data = fs.readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(data) as DailyStats
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for last N days
|
||||
*/
|
||||
getLastNDays(days: number): DailyStats[] {
|
||||
const result: DailyStats[] = []
|
||||
const today = new Date()
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
const dateStr = date.toISOString().slice(0, 10)
|
||||
|
||||
const stats = this.loadDailyStats(dateStr)
|
||||
if (stats) {
|
||||
result.push(stats)
|
||||
} else {
|
||||
// Create empty stats for missing days
|
||||
result.push({
|
||||
date: dateStr,
|
||||
totalPoints: 0,
|
||||
accountsCompleted: 0,
|
||||
accountsWithErrors: 0,
|
||||
totalSearches: 0,
|
||||
totalActivities: 0,
|
||||
runDuration: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result.reverse() // Chronological order
|
||||
}
|
||||
|
||||
/**
|
||||
* Save account-specific daily stats
|
||||
*/
|
||||
saveAccountDailyStats(stats: AccountDailyStats): void {
|
||||
try {
|
||||
const accountDir = path.join(this.dailyStatsPath, 'accounts')
|
||||
if (!fs.existsSync(accountDir)) {
|
||||
fs.mkdirSync(accountDir, { recursive: true })
|
||||
}
|
||||
|
||||
const maskedEmail = stats.email.replace(/@.*/, '@***')
|
||||
const filePath = path.join(accountDir, `${maskedEmail}_${stats.date}.json`)
|
||||
fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
console.error('[STATS] Failed to save account stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all account stats for a specific date
|
||||
*/
|
||||
getAccountStatsForDate(date: string): AccountDailyStats[] {
|
||||
try {
|
||||
const accountDir = path.join(this.dailyStatsPath, 'accounts')
|
||||
if (!fs.existsSync(accountDir)) return []
|
||||
|
||||
const files = fs.readdirSync(accountDir)
|
||||
.filter(f => f.endsWith(`_${date}.json`))
|
||||
|
||||
return files.map(file => {
|
||||
const data = fs.readFileSync(path.join(accountDir, file), 'utf-8')
|
||||
return JSON.parse(data) as AccountDailyStats
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save global (all-time) statistics
|
||||
*/
|
||||
saveGlobalStats(stats: GlobalStats): void {
|
||||
try {
|
||||
fs.writeFileSync(this.globalStatsPath, JSON.stringify(stats, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
console.error('[STATS] Failed to save global stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load global statistics
|
||||
*/
|
||||
loadGlobalStats(): GlobalStats {
|
||||
try {
|
||||
if (!fs.existsSync(this.globalStatsPath)) {
|
||||
return {
|
||||
totalRunsAllTime: 0,
|
||||
totalPointsAllTime: 0,
|
||||
averagePointsPerDay: 0
|
||||
}
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(this.globalStatsPath, 'utf-8')
|
||||
return JSON.parse(data) as GlobalStats
|
||||
} catch {
|
||||
return {
|
||||
totalRunsAllTime: 0,
|
||||
totalPointsAllTime: 0,
|
||||
averagePointsPerDay: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment global stats after a run
|
||||
*/
|
||||
incrementGlobalStats(pointsEarned: number): void {
|
||||
const stats = this.loadGlobalStats()
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
stats.totalRunsAllTime++
|
||||
stats.totalPointsAllTime += pointsEarned
|
||||
stats.lastRunDate = today
|
||||
|
||||
if (!stats.firstRunDate) {
|
||||
stats.firstRunDate = today
|
||||
}
|
||||
|
||||
// Calculate average (last 30 days)
|
||||
const last30Days = this.getLastNDays(30)
|
||||
const totalPoints30Days = last30Days.reduce((sum, day) => sum + day.totalPoints, 0)
|
||||
stats.averagePointsPerDay = Math.round(totalPoints30Days / 30)
|
||||
|
||||
this.saveGlobalStats(stats)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available stat dates
|
||||
*/
|
||||
getAllStatDates(): string[] {
|
||||
try {
|
||||
const files = fs.readdirSync(this.dailyStatsPath)
|
||||
.filter(f => f.endsWith('.json') && f !== 'global.json')
|
||||
.map(f => f.replace('.json', ''))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
return files
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old stats (keep last N days)
|
||||
*/
|
||||
pruneOldStats(keepDays: number = 90): void {
|
||||
try {
|
||||
const allDates = this.getAllStatDates()
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - keepDays)
|
||||
const cutoffStr = cutoffDate.toISOString().slice(0, 10)
|
||||
|
||||
for (const date of allDates) {
|
||||
if (date < cutoffStr) {
|
||||
const filePath = path.join(this.dailyStatsPath, `${date}.json`)
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[STATS] Failed to prune old stats:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const statsManager = new StatsManager()
|
||||
@@ -5,6 +5,7 @@ import { AccountHistory } from '../util/state/AccountHistory'
|
||||
import { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load'
|
||||
import { botController } from './BotController'
|
||||
import { dashboardState } from './state'
|
||||
import { statsManager } from './StatsManager'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -110,42 +111,44 @@ apiRouter.get('/history', (_req: Request, res: Response): void => {
|
||||
// GET /api/config - Current config (tokens masked)
|
||||
apiRouter.get('/config', (_req: Request, res: Response) => {
|
||||
try {
|
||||
// CRITICAL: Load raw config.jsonc to preserve comments
|
||||
const configPath = getConfigPath()
|
||||
if (!fs.existsSync(configPath)) {
|
||||
res.status(404).json({ error: 'Config file not found' })
|
||||
return
|
||||
}
|
||||
|
||||
// Read raw JSONC content (preserves comments)
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8')
|
||||
|
||||
// Parse and sanitize for display
|
||||
const config = loadConfig()
|
||||
const safe = JSON.parse(JSON.stringify(config))
|
||||
|
||||
// Mask sensitive data
|
||||
// Mask sensitive data (but keep structure)
|
||||
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)
|
||||
// WARNING: Show user this is read-only view
|
||||
res.json({
|
||||
config: safe,
|
||||
warning: 'This is a simplified view. Direct file editing recommended for complex changes.',
|
||||
rawPreview: rawConfig.split('\\n').slice(0, 10).join('\\n') + '\\n...' // First 10 lines
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/config - Update config (with backup)
|
||||
// POST /api/config - Update config (DISABLED - manual editing only)
|
||||
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: getErr(error) })
|
||||
}
|
||||
// DISABLED: Config editing via API is unsafe (loses JSONC comments)
|
||||
// Users should edit config.jsonc manually
|
||||
res.status(403).json({
|
||||
error: 'Config editing via dashboard is disabled to preserve JSONC format.',
|
||||
hint: 'Please edit src/config.jsonc manually with a text editor.'
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/start - Start bot in background
|
||||
@@ -235,7 +238,12 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
||||
const accountsWithErrors = accounts.filter(a => a.errors && a.errors.length > 0).length
|
||||
const avgPoints = accounts.length > 0 ? Math.round(totalPoints / accounts.length) : 0
|
||||
|
||||
// Load persistent stats
|
||||
const globalStats = statsManager.loadGlobalStats()
|
||||
const todayStats = statsManager.loadDailyStats(new Date().toISOString().slice(0, 10))
|
||||
|
||||
res.json({
|
||||
// Current session metrics
|
||||
totalAccounts: accounts.length,
|
||||
totalPoints,
|
||||
avgPoints,
|
||||
@@ -243,13 +251,73 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
||||
accountsRunning: accounts.filter(a => a.status === 'running').length,
|
||||
accountsCompleted: accounts.filter(a => a.status === 'completed').length,
|
||||
accountsIdle: accounts.filter(a => a.status === 'idle').length,
|
||||
accountsError: accounts.filter(a => a.status === 'error').length
|
||||
accountsError: accounts.filter(a => a.status === 'error').length,
|
||||
|
||||
// Persistent stats
|
||||
globalStats: {
|
||||
totalRunsAllTime: globalStats.totalRunsAllTime,
|
||||
totalPointsAllTime: globalStats.totalPointsAllTime,
|
||||
averagePointsPerDay: globalStats.averagePointsPerDay,
|
||||
lastRunDate: globalStats.lastRunDate,
|
||||
firstRunDate: globalStats.firstRunDate
|
||||
},
|
||||
|
||||
// Today's stats
|
||||
todayStats: todayStats || {
|
||||
totalPoints: 0,
|
||||
accountsCompleted: 0,
|
||||
accountsWithErrors: 0
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/stats/history/:days - Get stats history
|
||||
apiRouter.get('/stats/history/:days', (req: Request, res: Response) => {
|
||||
try {
|
||||
const daysParam = req.params.days
|
||||
if (!daysParam) {
|
||||
res.status(400).json({ error: 'Days parameter required' })
|
||||
return
|
||||
}
|
||||
const days = Math.min(parseInt(daysParam, 10) || 7, 90)
|
||||
const history = statsManager.getLastNDays(days)
|
||||
res.json(history)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/stats/record - Record new stats (called by bot after run)
|
||||
apiRouter.post('/stats/record', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pointsEarned, accountsCompleted, accountsWithErrors, totalSearches, totalActivities, runDuration } = req.body
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const existingStats = statsManager.loadDailyStats(today)
|
||||
|
||||
// Merge with existing stats if run multiple times today
|
||||
const dailyStats = {
|
||||
date: today,
|
||||
totalPoints: (existingStats?.totalPoints || 0) + (pointsEarned || 0),
|
||||
accountsCompleted: (existingStats?.accountsCompleted || 0) + (accountsCompleted || 0),
|
||||
accountsWithErrors: (existingStats?.accountsWithErrors || 0) + (accountsWithErrors || 0),
|
||||
totalSearches: (existingStats?.totalSearches || 0) + (totalSearches || 0),
|
||||
totalActivities: (existingStats?.totalActivities || 0) + (totalActivities || 0),
|
||||
runDuration: (existingStats?.runDuration || 0) + (runDuration || 0)
|
||||
}
|
||||
|
||||
statsManager.saveDailyStats(dailyStats)
|
||||
statsManager.incrementGlobalStats(pointsEarned || 0)
|
||||
|
||||
res.json({ success: true, stats: dailyStats })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/account/:email - Get specific account details
|
||||
apiRouter.get('/account/:email', (req: Request, res: Response): void => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user