diff --git a/public/app.js b/public/app.js index 26c8fea..9d9e63d 100644 --- a/public/app.js +++ b/public/app.js @@ -712,25 +712,31 @@ function exportLogs() { } function openConfig() { - showModal('Configuration Editor', ` + showModal('Configuration Viewer', `
Loading configuration...
`, []) - // Fetch current config + // Fetch config (read-only view) fetch('/api/config') .then(r => r.json()) - .then(config => { + .then(data => { const body = `
- -

⚠️ Advanced users only. Invalid JSON will break the bot.

+
+ ⚠️ Read-Only View
+ This is a simplified preview. To edit config:
+ 1. Open src/config.jsonc in a text editor
+ 2. Make your changes
+ 3. Save and restart the bot +
+ +

💡 Manual editing preserves comments and complex settings

` const buttons = [ - { cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Cancel' }, - { cls: 'btn btn-sm btn-primary', action: 'saveConfig()', text: 'Save Changes' } + { cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Close' } ] - showModal('Configuration Editor', body, buttons) + showModal('Configuration Viewer', body, buttons) }) .catch(e => { showToast('Failed to load config: ' + e.message, 'error') @@ -739,32 +745,9 @@ function openConfig() { } function saveConfig() { - const editor = document.getElementById('configEditor') - if (!editor) return - - try { - const newConfig = JSON.parse(editor.value) - - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newConfig) - }) - .then(r => r.json()) - .then(data => { - if (data.success) { - showToast('Configuration saved! Restart bot for changes to apply.', 'success') - closeModal() - } else { - showToast('Save failed: ' + (data.error || 'Unknown error'), 'error') - } - }) - .catch(e => { - showToast('Save failed: ' + e.message, 'error') - }) - } catch (e) { - showToast('Invalid JSON format: ' + e.message, 'error') - } + // Config editing is disabled - this function is now unused + showToast('Config editing disabled. Edit src/config.jsonc manually.', 'warning') + closeModal() } function viewHistory() { diff --git a/public/index.html b/public/index.html index 7940906..a78b673 100644 --- a/public/index.html +++ b/public/index.html @@ -8,27 +8,7 @@ - - diff --git a/public/style-extensions.css b/public/style-extensions.css deleted file mode 100644 index 79962da..0000000 --- a/public/style-extensions.css +++ /dev/null @@ -1,82 +0,0 @@ -/* Dashboard Extensions - Config Editor & History Viewer */ - -/* Config Editor Styles */ -.config-editor { - width: 100%; - min-height: 400px; -} - -.config-textarea { - width: 100%; - min-height: 400px; - padding: var(--spacing-md); - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - color: var(--text-primary); - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 13px; - line-height: 1.6; - resize: vertical; - transition: border-color var(--transition-fast); -} - -.config-textarea:focus { - outline: none; - border-color: var(--accent-blue); - box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2); -} - -.config-hint { - margin-top: var(--spacing-sm); - font-size: 13px; - color: var(--text-muted); - text-align: center; -} - -.config-loading { - text-align: center; - padding: var(--spacing-xl); - color: var(--text-secondary); -} - -/* History Viewer Styles */ -.history-list { - max-height: 500px; - overflow-y: auto; -} - -.history-row { - padding: var(--spacing-md); - margin-bottom: var(--spacing-sm); - background: var(--bg-tertiary); - border-radius: var(--radius-md); - border-left: 3px solid var(--accent-blue); - transition: all var(--transition-fast); -} - -.history-row:hover { - background: var(--bg-secondary); - border-left-color: var(--accent-green); - transform: translateX(2px); -} - -.history-date { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.history-stats { - display: flex; - gap: var(--spacing-md); - font-size: 13px; - color: var(--text-secondary); -} - -.history-stats span { - padding: 2px 8px; - background: var(--bg-primary); - border-radius: var(--radius-sm); -} \ No newline at end of file diff --git a/public/style.css b/public/style.css index c6c06a1..20b05f0 100644 --- a/public/style.css +++ b/public/style.css @@ -877,4 +877,328 @@ body { .control-grid { grid-template-columns: 1fr; } +} + +/* =================================================================== */ +/* ICON STYLES (moved from inline) */ +/* =================================================================== */ + +.icon { + width: 1em; + height: 1em; + fill: currentColor; + vertical-align: -0.125em; +} + +.icon-lg { + width: 1.25em; + height: 1.25em; +} + +.icon-sm { + width: 0.625em; + height: 0.625em; +} + +/* =================================================================== */ +/* CONFIG EDITOR & HISTORY VIEWER (moved from style-extensions.css) */ +/* =================================================================== */ + +.config-editor { + width: 100%; + min-height: 400px; +} + +.config-textarea { + width: 100%; + min-height: 400px; + padding: var(--spacing-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.6; + resize: vertical; + transition: border-color var(--transition-fast); +} + +.config-textarea:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2); +} + +.config-hint { + margin-top: var(--spacing-sm); + font-size: 13px; + color: var(--text-muted); + text-align: center; +} + +.config-warning { + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); + background: var(--accent-orange); + color: var(--bg-primary); + border-radius: var(--radius-md); + font-size: 14px; + line-height: 1.8; +} + +.config-warning strong { + font-weight: 600; + display: block; + margin-bottom: var(--spacing-xs); +} + +.config-warning code { + background: rgba(0, 0, 0, 0.2); + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; +} + +.config-loading { + text-align: center; + padding: var(--spacing-xl); + color: var(--text-secondary); +} + +.history-list { + max-height: 500px; + overflow-y: auto; +} + +.history-row { + padding: var(--spacing-md); + margin-bottom: var(--spacing-sm); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + border-left: 3px solid var(--accent-blue); + transition: all var(--transition-fast); + animation: slideInLeft 0.3s ease-out; +} + +.history-row:hover { + background: var(--bg-secondary); + border-left-color: var(--accent-green); + transform: translateX(4px); +} + +.history-date { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-xs); +} + +.history-stats { + display: flex; + gap: var(--spacing-md); + font-size: 13px; + color: var(--text-secondary); +} + +.history-stats span { + padding: 2px 8px; + background: var(--bg-primary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.history-stats span:hover { + background: var(--accent-blue); + color: var(--bg-primary); + transform: scale(1.05); +} + +/* =================================================================== */ +/* PROFESSIONAL ANIMATIONS */ +/* =================================================================== */ + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + + 100% { + background-position: 1000px 0; + } +} + +/* Apply animations to elements */ +.card { + animation: slideInUp 0.4s ease-out; +} + +.stat-card { + animation: scaleIn 0.3s ease-out; +} + +.stat-card:nth-child(2) { + animation-delay: 0.1s; +} + +.stat-card:nth-child(3) { + animation-delay: 0.2s; +} + +.stat-card:nth-child(4) { + animation-delay: 0.3s; +} + +.log-entry { + animation: slideInLeft 0.2s ease-out; +} + +.account-item { + animation: slideInRight 0.3s ease-out; +} + +.toast { + animation: slideInRight 0.3s ease-out; +} + +.modal.show { + animation: fadeIn 0.3s ease-out; +} + +.modal-content { + animation: scaleIn 0.3s ease-out; +} + +.status-badge { + transition: all var(--transition-fast); +} + +.status-badge.status-running { + animation: pulse 2s ease-in-out infinite; +} + +/* Loading skeleton animation */ +.loading-skeleton { + background: linear-gradient(90deg, + var(--bg-tertiary) 0%, + var(--bg-secondary) 50%, + var(--bg-tertiary) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-md); +} + +/* Smooth transitions for interactive elements */ +.btn, +.control-btn, +.action-btn, +.period-btn { + transition: all var(--transition-fast); +} + +.btn:hover, +.control-btn:hover, +.action-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.btn:active, +.control-btn:active, +.action-btn:active { + transform: translateY(0); +} + +/* Chart containers smooth entry */ +.chart-container { + animation: fadeIn 0.5s ease-out; +} + +/* Stats value counter animation */ +.stat-value { + transition: all var(--transition-normal); +} + +.stat-value.updating { + animation: pulse 0.5s ease-out; } \ No newline at end of file diff --git a/src/dashboard/StatsManager.ts b/src/dashboard/StatsManager.ts new file mode 100644 index 0000000..98b4f6c --- /dev/null +++ b/src/dashboard/StatsManager.ts @@ -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() diff --git a/src/dashboard/routes.ts b/src/dashboard/routes.ts index 394bd48..faed274 100644 --- a/src/dashboard/routes.ts +++ b/src/dashboard/routes.ts @@ -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 {