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:
@@ -712,25 +712,31 @@ function exportLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openConfig() {
|
function openConfig() {
|
||||||
showModal('Configuration Editor', `
|
showModal('Configuration Viewer', `
|
||||||
<div class="config-loading">Loading configuration...</div>
|
<div class="config-loading">Loading configuration...</div>
|
||||||
`, [])
|
`, [])
|
||||||
|
|
||||||
// Fetch current config
|
// Fetch config (read-only view)
|
||||||
fetch('/api/config')
|
fetch('/api/config')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(config => {
|
.then(data => {
|
||||||
const body = `
|
const body = `
|
||||||
<div class="config-editor">
|
<div class="config-editor">
|
||||||
<textarea id="configEditor" class="config-textarea">${JSON.stringify(config, null, 2)}</textarea>
|
<div class="config-warning">
|
||||||
<p class="config-hint">⚠️ Advanced users only. Invalid JSON will break the bot.</p>
|
⚠️ <strong>Read-Only View</strong><br>
|
||||||
|
This is a simplified preview. To edit config:<br>
|
||||||
|
1. Open <code>src/config.jsonc</code> in a text editor<br>
|
||||||
|
2. Make your changes<br>
|
||||||
|
3. Save and restart the bot
|
||||||
|
</div>
|
||||||
|
<textarea id="configEditor" class="config-textarea" readonly>${JSON.stringify(data.config, null, 2)}</textarea>
|
||||||
|
<p class="config-hint">💡 Manual editing preserves comments and complex settings</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{ cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Cancel' },
|
{ cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Close' }
|
||||||
{ cls: 'btn btn-sm btn-primary', action: 'saveConfig()', text: 'Save Changes' }
|
|
||||||
]
|
]
|
||||||
showModal('Configuration Editor', body, buttons)
|
showModal('Configuration Viewer', body, buttons)
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
showToast('Failed to load config: ' + e.message, 'error')
|
showToast('Failed to load config: ' + e.message, 'error')
|
||||||
@@ -739,32 +745,9 @@ function openConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveConfig() {
|
function saveConfig() {
|
||||||
const editor = document.getElementById('configEditor')
|
// Config editing is disabled - this function is now unused
|
||||||
if (!editor) return
|
showToast('Config editing disabled. Edit src/config.jsonc manually.', 'warning')
|
||||||
|
closeModal()
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewHistory() {
|
function viewHistory() {
|
||||||
|
|||||||
@@ -8,27 +8,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml"
|
<link rel="icon" type="image/svg+xml"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎯%3C/text%3E%3C/svg%3E">
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎯%3C/text%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<link rel="stylesheet" href="/style-extensions.css">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<style>
|
|
||||||
/* Inline SVG icon styles */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
324
public/style.css
324
public/style.css
@@ -877,4 +877,328 @@ body {
|
|||||||
.control-grid {
|
.control-grid {
|
||||||
grid-template-columns: 1fr;
|
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;
|
||||||
}
|
}
|
||||||
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 { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load'
|
||||||
import { botController } from './BotController'
|
import { botController } from './BotController'
|
||||||
import { dashboardState } from './state'
|
import { dashboardState } from './state'
|
||||||
|
import { statsManager } from './StatsManager'
|
||||||
|
|
||||||
export const apiRouter = Router()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
@@ -110,42 +111,44 @@ apiRouter.get('/history', (_req: Request, res: Response): void => {
|
|||||||
// GET /api/config - Current config (tokens masked)
|
// GET /api/config - Current config (tokens masked)
|
||||||
apiRouter.get('/config', (_req: Request, res: Response) => {
|
apiRouter.get('/config', (_req: Request, res: Response) => {
|
||||||
try {
|
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 config = loadConfig()
|
||||||
const safe = JSON.parse(JSON.stringify(config))
|
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.webhook?.url) safe.webhook.url = maskUrl(safe.webhook.url)
|
||||||
if (safe.conclusionWebhook?.url) safe.conclusionWebhook.url = maskUrl(safe.conclusionWebhook.url)
|
if (safe.conclusionWebhook?.url) safe.conclusionWebhook.url = maskUrl(safe.conclusionWebhook.url)
|
||||||
if (safe.ntfy?.authToken) safe.ntfy.authToken = '***'
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: getErr(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 => {
|
apiRouter.post('/config', (req: Request, res: Response): void => {
|
||||||
try {
|
// DISABLED: Config editing via API is unsafe (loses JSONC comments)
|
||||||
const newConfig = req.body
|
// Users should edit config.jsonc manually
|
||||||
const configPath = getConfigPath()
|
res.status(403).json({
|
||||||
|
error: 'Config editing via dashboard is disabled to preserve JSONC format.',
|
||||||
if (!configPath || !fs.existsSync(configPath)) {
|
hint: 'Please edit src/config.jsonc manually with a text editor.'
|
||||||
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) })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST /api/start - Start bot in background
|
// 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 accountsWithErrors = accounts.filter(a => a.errors && a.errors.length > 0).length
|
||||||
const avgPoints = accounts.length > 0 ? Math.round(totalPoints / accounts.length) : 0
|
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({
|
res.json({
|
||||||
|
// Current session metrics
|
||||||
totalAccounts: accounts.length,
|
totalAccounts: accounts.length,
|
||||||
totalPoints,
|
totalPoints,
|
||||||
avgPoints,
|
avgPoints,
|
||||||
@@ -243,13 +251,73 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
|||||||
accountsRunning: accounts.filter(a => a.status === 'running').length,
|
accountsRunning: accounts.filter(a => a.status === 'running').length,
|
||||||
accountsCompleted: accounts.filter(a => a.status === 'completed').length,
|
accountsCompleted: accounts.filter(a => a.status === 'completed').length,
|
||||||
accountsIdle: accounts.filter(a => a.status === 'idle').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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: getErr(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
|
// GET /api/account/:email - Get specific account details
|
||||||
apiRouter.get('/account/:email', (req: Request, res: Response): void => {
|
apiRouter.get('/account/:email', (req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user