mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
Add initial HTML structure and styles for Microsoft Rewards Bot dashboard
- Created index.html with a complete layout for the dashboard - Added favicon.ico placeholder - Implemented responsive design and styling using CSS variables - Integrated React for dynamic data handling and real-time updates - Set up WebSocket connection for live log updates - Included functionality for starting/stopping the bot and managing accounts
This commit is contained in:
@@ -98,7 +98,7 @@ For detailed configuration, advanced features, and troubleshooting, visit our co
|
||||
|
||||
---
|
||||
|
||||
## 📊 Dashboard (NEW)
|
||||
## 📊 Dashboard (BETA)
|
||||
|
||||
Monitor and control your bot through a local web interface:
|
||||
|
||||
|
||||
213
docs/dashboard-quickstart.md
Normal file
213
docs/dashboard-quickstart.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 🚀 Dashboard Quick Start Guide
|
||||
|
||||
## What's New?
|
||||
|
||||
The dashboard has been completely redesigned with:
|
||||
- ✨ Modern, beautiful interface with gradients and animations
|
||||
- 🔄 Real-time updates via WebSocket
|
||||
- 📊 Enhanced statistics and metrics
|
||||
- 🎮 Better control panel
|
||||
- 📱 Fully responsive design
|
||||
- 🎨 Professional UI/UX
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Option 1: Standalone Dashboard (Recommended for Testing)
|
||||
|
||||
Start only the dashboard to see the interface:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run dashboard
|
||||
```
|
||||
|
||||
Then open: **http://localhost:3000**
|
||||
|
||||
### Option 2: Enable with Bot
|
||||
|
||||
1. Edit `src/config.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dashboard": {
|
||||
"enabled": true,
|
||||
"port": 3000,
|
||||
"host": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Start the bot:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Dashboard will be available at: **http://localhost:3000**
|
||||
|
||||
## What You'll See
|
||||
|
||||
### 📊 Header
|
||||
- Bot logo and title
|
||||
- Real-time status badge (RUNNING/STOPPED with animated indicator)
|
||||
- Last run timestamp
|
||||
|
||||
### 📈 Statistics Cards
|
||||
- **Total Accounts** - Number of configured accounts
|
||||
- **Total Points** - Sum of all points across accounts
|
||||
- **Completed** - Successfully processed accounts
|
||||
- **Errors** - Accounts with issues
|
||||
|
||||
### 🎮 Control Panel
|
||||
- **Start Bot** - Begin automation (shows when stopped)
|
||||
- **Stop Bot** - Halt execution (shows when running)
|
||||
- **Refresh** - Update all data manually
|
||||
- **Clear Logs** - Remove log history
|
||||
|
||||
### 👥 Accounts Section
|
||||
- List of all accounts with masked emails
|
||||
- Current points for each account
|
||||
- Status badge (idle, running, completed, error)
|
||||
- Last sync timestamp
|
||||
|
||||
### 📋 Live Logs
|
||||
- Real-time streaming logs
|
||||
- Color-coded by level:
|
||||
- 🟢 Green = Info
|
||||
- 🟡 Yellow = Warning
|
||||
- 🔴 Red = Error
|
||||
- Platform indicators (MAIN, DESKTOP, MOBILE)
|
||||
- Timestamps for each entry
|
||||
- Auto-scrolling to latest
|
||||
|
||||
## Features to Try
|
||||
|
||||
### 1. Real-Time Updates
|
||||
- Open dashboard while bot is running
|
||||
- Watch logs appear instantly
|
||||
- See account status change in real-time
|
||||
- Notice points increment live
|
||||
|
||||
### 2. Control Bot
|
||||
- Click "Start Bot" to begin automation
|
||||
- Watch status badge change to RUNNING
|
||||
- See logs stream in
|
||||
- Click "Stop Bot" to halt
|
||||
|
||||
### 3. View Account Details
|
||||
- Each account shows current points
|
||||
- Status badge shows current state
|
||||
- Last sync shows when it was processed
|
||||
|
||||
### 4. Manage Logs
|
||||
- Logs auto-update as they happen
|
||||
- Scroll through history
|
||||
- Click "Clear Logs" to start fresh
|
||||
- Logs persist in memory (up to 500 entries)
|
||||
|
||||
## API Access
|
||||
|
||||
You can also use the API directly:
|
||||
|
||||
### Get Status
|
||||
```bash
|
||||
curl http://localhost:3000/api/status
|
||||
```
|
||||
|
||||
### Get All Accounts
|
||||
```bash
|
||||
curl http://localhost:3000/api/accounts
|
||||
```
|
||||
|
||||
### Get Logs
|
||||
```bash
|
||||
curl http://localhost:3000/api/logs?limit=50
|
||||
```
|
||||
|
||||
### Get Metrics
|
||||
```bash
|
||||
curl http://localhost:3000/api/metrics
|
||||
```
|
||||
|
||||
### Control Bot
|
||||
```bash
|
||||
# Start
|
||||
curl -X POST http://localhost:3000/api/start
|
||||
|
||||
# Stop
|
||||
curl -X POST http://localhost:3000/api/stop
|
||||
```
|
||||
|
||||
## WebSocket Testing
|
||||
|
||||
Test real-time WebSocket connection:
|
||||
|
||||
```javascript
|
||||
// Open browser console at http://localhost:3000
|
||||
const ws = new WebSocket('ws://localhost:3000');
|
||||
|
||||
ws.onopen = () => console.log('✓ Connected');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Received:', data);
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Cannot GET /"
|
||||
- Run `npm run build` first
|
||||
- Check `public/index.html` exists
|
||||
- Try `npm run dashboard-dev` instead
|
||||
|
||||
### No Accounts Showing
|
||||
- Ensure `src/accounts.jsonc` is configured
|
||||
- Check file exists and has valid JSON
|
||||
- Refresh the page
|
||||
|
||||
### WebSocket Not Connected
|
||||
- Check dashboard server is running
|
||||
- Look for "WebSocket connected" in browser console
|
||||
- Try refreshing the page
|
||||
|
||||
### Port Already in Use
|
||||
Change the port:
|
||||
```bash
|
||||
# In config.jsonc
|
||||
"dashboard": {
|
||||
"port": 3001 // Use different port
|
||||
}
|
||||
```
|
||||
|
||||
Or use environment variable:
|
||||
```bash
|
||||
DASHBOARD_PORT=3001 npm run dashboard
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Customize Theme** - Edit colors in `public/index.html`
|
||||
2. **Add Features** - Extend API in `src/dashboard/routes.ts`
|
||||
3. **Monitor Bot** - Leave dashboard open while bot runs
|
||||
4. **Use API** - Build custom integrations
|
||||
5. **Deploy** - Set up reverse proxy for remote access
|
||||
|
||||
## Tips
|
||||
|
||||
- 💡 Keep dashboard open in a browser tab while bot runs
|
||||
- 💡 Use Refresh button if data seems stale
|
||||
- 💡 Clear logs periodically for better performance
|
||||
- 💡 Check browser console for WebSocket status
|
||||
- 💡 Use API for automated monitoring scripts
|
||||
|
||||
## Need Help?
|
||||
|
||||
- 📖 Read full documentation in `src/dashboard/DASHBOARD.md`
|
||||
- 🔍 Check API reference in `src/dashboard/README.md`
|
||||
- 🐛 Report issues on GitHub
|
||||
- 💬 Ask in community Discord
|
||||
|
||||
---
|
||||
|
||||
**Enjoy your new dashboard! 🎉**
|
||||
946
public/dashboard.html
Normal file
946
public/dashboard.html
Normal file
@@ -0,0 +1,946 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Microsoft Rewards Bot - Dashboard</title>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary: #0078d4;
|
||||
--primary-dark: #005a9e;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--dark: #1e293b;
|
||||
--darker: #0f172a;
|
||||
--light: #f8fafc;
|
||||
--gray: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--dark);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: slideDown 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 0.75rem;
|
||||
color: var(--success);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title i {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 120, 212, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 120, 212, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--danger), #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success), #059669);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Accounts */
|
||||
.accounts-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.account-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.account-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.account-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.account-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.account-points {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-points-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.account-points-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.account-status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-idle {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-container {
|
||||
background: var(--darker);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar-track {
|
||||
background: var(--dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--gray);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 0.5rem;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
animation: logFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes logFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #64748b;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.log-platform {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-MAIN {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.platform-DESKTOP {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.platform-MOBILE {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.log-level-log {
|
||||
border-left-color: #4ade80;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.log-level-warn {
|
||||
border-left-color: #fbbf24;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.log-level-error {
|
||||
border-left-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--danger);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- React & Babel -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
function App() {
|
||||
const [status, setStatus] = useState({ running: false, totalAccounts: 0 });
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [ws, setWs] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, accountsRes, metricsRes] = await Promise.all([
|
||||
fetch('/api/status'),
|
||||
fetch('/api/accounts'),
|
||||
fetch('/api/metrics')
|
||||
]);
|
||||
|
||||
const statusData = await statusRes.json();
|
||||
const accountsData = await accountsRes.json();
|
||||
const metricsData = await metricsRes.json();
|
||||
|
||||
setStatus(statusData);
|
||||
setAccounts(accountsData);
|
||||
setMetrics(metricsData);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
showToast('Failed to fetch data', 'error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const websocket = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'init') {
|
||||
setLogs(data.data.logs || []);
|
||||
setStatus(data.data.status || status);
|
||||
setAccounts(data.data.accounts || []);
|
||||
} else if (data.type === 'log') {
|
||||
setLogs(prev => [...prev.slice(-99), data.log]);
|
||||
} else if (data.type === 'status') {
|
||||
setStatus(data.data);
|
||||
} else if (data.type === 'accounts') {
|
||||
setAccounts(data.data);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setTimeout(() => {
|
||||
// Reconnect
|
||||
const newWs = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
setWs(newWs);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
|
||||
return () => {
|
||||
websocket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startBot = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/start', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Bot started successfully');
|
||||
fetchData();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to start bot', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to start bot', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const stopBot = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Bot stop requested');
|
||||
fetchData();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to stop bot', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to stop bot', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
await fetch('/api/logs', { method: 'DELETE' });
|
||||
setLogs([]);
|
||||
showToast('Logs cleared');
|
||||
} catch (error) {
|
||||
showToast('Failed to clear logs', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
<span>Loading dashboard...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<img src="/assets/logo.png" alt="Logo" className="logo" />
|
||||
<div>
|
||||
<h1 className="header-title">Microsoft Rewards Bot</h1>
|
||||
<p className="header-subtitle">Real-time automation dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`status-badge ${status.running ? 'status-running' : 'status-stopped'}`}>
|
||||
<div className="status-indicator"></div>
|
||||
{status.running ? 'RUNNING' : 'STOPPED'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #667eea, #764ba2)'}}>
|
||||
<i className="fas fa-users"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Total Accounts</div>
|
||||
<div className="stat-value">{metrics.totalAccounts || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #f093fb, #f5576c)'}}>
|
||||
<i className="fas fa-star"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Total Points</div>
|
||||
<div className="stat-value">{(metrics.totalPoints || 0).toLocaleString()}</div>
|
||||
{metrics.avgPoints > 0 && (
|
||||
<div className="stat-change">
|
||||
<i className="fas fa-chart-line"></i> Avg: {metrics.avgPoints}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #4facfe, #00f2fe)'}}>
|
||||
<i className="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Completed</div>
|
||||
<div className="stat-value">{metrics.accountsCompleted || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #fa709a, #fee140)'}}>
|
||||
<i className="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Errors</div>
|
||||
<div className="stat-value">{metrics.accountsWithErrors || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-bolt"></i>
|
||||
Control Panel
|
||||
</h2>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={startBot}
|
||||
disabled={status.running}
|
||||
>
|
||||
<i className="fas fa-play"></i>
|
||||
Start Bot
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={stopBot}
|
||||
disabled={!status.running}
|
||||
>
|
||||
<i className="fas fa-stop"></i>
|
||||
Stop Bot
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={fetchData}
|
||||
>
|
||||
<i className="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={clearLogs}
|
||||
>
|
||||
<i className="fas fa-trash"></i>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-user-friends"></i>
|
||||
Accounts ({accounts.length})
|
||||
</h2>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<i className="fas fa-inbox"></i>
|
||||
<p>No accounts configured</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="accounts-grid">
|
||||
{accounts.map((account, index) => (
|
||||
<div key={index} className="account-item">
|
||||
<div className="account-info">
|
||||
<div className="account-avatar">
|
||||
{account.maskedEmail.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="account-details">
|
||||
<div className="account-email">{account.maskedEmail}</div>
|
||||
<div className="account-status">
|
||||
{account.lastSync ? `Last sync: ${new Date(account.lastSync).toLocaleString()}` : 'Never synced'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-stats">
|
||||
<div className="account-points">
|
||||
<div className="account-points-value">
|
||||
{account.points !== undefined ? account.points.toLocaleString() : 'N/A'}
|
||||
</div>
|
||||
<div className="account-points-label">Points</div>
|
||||
</div>
|
||||
<span className={`account-status-badge status-${account.status}`}>
|
||||
{account.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-terminal"></i>
|
||||
Live Logs ({logs.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="logs-container">
|
||||
{logs.length === 0 ? (
|
||||
<div style={{color: '#64748b', textAlign: 'center', padding: '2rem'}}>
|
||||
<i className="fas fa-stream"></i> No logs yet...
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={`log-entry log-level-${log.level}`}>
|
||||
<span className="log-timestamp">
|
||||
[{new Date(log.timestamp).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span className={`log-platform platform-${log.platform}`}>
|
||||
{log.platform}
|
||||
</span>
|
||||
<span className="log-title">[{log.title}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`toast toast-${toast.type}`}>
|
||||
<i className={`fas fa-${toast.type === 'success' ? 'check-circle' : 'exclamation-circle'}`}></i>
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/favicon.ico
Normal file
1
public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
<!-- This is a placeholder. The actual favicon will be served from /assets/logo.png -->
|
||||
964
public/index.html
Normal file
964
public/index.html
Normal file
@@ -0,0 +1,964 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Microsoft Rewards Bot - Dashboard</title>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary: #0078d4;
|
||||
--primary-dark: #005a9e;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--dark: #1e293b;
|
||||
--darker: #0f172a;
|
||||
--light: #f8fafc;
|
||||
--gray: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--dark);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: slideDown 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 0.75rem;
|
||||
color: var(--success);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title i {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 120, 212, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 120, 212, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--danger), #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success), #059669);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Accounts */
|
||||
.accounts-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.account-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.account-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.account-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.account-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.account-points {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-points-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.account-points-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.account-status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-idle {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-container {
|
||||
background: var(--darker);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar-track {
|
||||
background: var(--dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--gray);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 0.5rem;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
animation: logFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes logFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #64748b;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.log-platform {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-MAIN {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.platform-DESKTOP {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.platform-MOBILE {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.log-level-log {
|
||||
border-left-color: #4ade80;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.log-level-warn {
|
||||
border-left-color: #fbbf24;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.log-level-error {
|
||||
border-left-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--danger);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- React & Babel -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
function App() {
|
||||
const [status, setStatus] = useState({ running: false, totalAccounts: 0 });
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [ws, setWs] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, accountsRes, metricsRes] = await Promise.all([
|
||||
fetch('/api/status'),
|
||||
fetch('/api/accounts'),
|
||||
fetch('/api/metrics')
|
||||
]);
|
||||
|
||||
const statusData = await statusRes.json();
|
||||
const accountsData = await accountsRes.json();
|
||||
const metricsData = await metricsRes.json();
|
||||
|
||||
setStatus(statusData);
|
||||
setAccounts(accountsData);
|
||||
setMetrics(metricsData);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
showToast('Failed to fetch data', 'error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const websocket = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'init') {
|
||||
setLogs((data.data.logs || []).filter(log => log && log.timestamp));
|
||||
setStatus(data.data.status || status);
|
||||
setAccounts((data.data.accounts || []).filter(acc => acc && acc.email));
|
||||
} else if (data.type === 'log') {
|
||||
if (data.log && data.log.timestamp) {
|
||||
setLogs(prev => [...prev.slice(-99), data.log].filter(log => log && log.timestamp));
|
||||
}
|
||||
} else if (data.type === 'status') {
|
||||
setStatus(data.data);
|
||||
} else if (data.type === 'accounts') {
|
||||
setAccounts((data.data || []).filter(acc => acc && acc.email));
|
||||
} else if (data.type === 'account_update') {
|
||||
if (data.data && data.data.email) {
|
||||
setAccounts(prev => {
|
||||
const index = prev.findIndex(acc => acc.email === data.data.email);
|
||||
if (index >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[index] = data.data;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, data.data];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setTimeout(() => {
|
||||
// Reconnect
|
||||
const newWs = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
setWs(newWs);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
|
||||
return () => {
|
||||
websocket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startBot = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/start', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Bot started successfully');
|
||||
fetchData();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to start bot', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to start bot', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const stopBot = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Bot stop requested');
|
||||
fetchData();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to stop bot', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to stop bot', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
await fetch('/api/logs', { method: 'DELETE' });
|
||||
setLogs([]);
|
||||
showToast('Logs cleared');
|
||||
} catch (error) {
|
||||
showToast('Failed to clear logs', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
<span>Loading dashboard...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<img src="/assets/logo.png" alt="Logo" className="logo" />
|
||||
<div>
|
||||
<h1 className="header-title">Microsoft Rewards Bot</h1>
|
||||
<p className="header-subtitle">Real-time automation dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`status-badge ${status.running ? 'status-running' : 'status-stopped'}`}>
|
||||
<div className="status-indicator"></div>
|
||||
{status.running ? 'RUNNING' : 'STOPPED'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #667eea, #764ba2)'}}>
|
||||
<i className="fas fa-users"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Total Accounts</div>
|
||||
<div className="stat-value">{metrics.totalAccounts || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #f093fb, #f5576c)'}}>
|
||||
<i className="fas fa-star"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Total Points</div>
|
||||
<div className="stat-value">{(metrics.totalPoints || 0).toLocaleString()}</div>
|
||||
{metrics.avgPoints > 0 && (
|
||||
<div className="stat-change">
|
||||
<i className="fas fa-chart-line"></i> Avg: {metrics.avgPoints}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #4facfe, #00f2fe)'}}>
|
||||
<i className="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Completed</div>
|
||||
<div className="stat-value">{metrics.accountsCompleted || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{background: 'linear-gradient(135deg, #fa709a, #fee140)'}}>
|
||||
<i className="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-label">Errors</div>
|
||||
<div className="stat-value">{metrics.accountsWithErrors || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-bolt"></i>
|
||||
Control Panel
|
||||
</h2>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={startBot}
|
||||
disabled={status.running}
|
||||
>
|
||||
<i className="fas fa-play"></i>
|
||||
Start Bot
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={stopBot}
|
||||
disabled={!status.running}
|
||||
>
|
||||
<i className="fas fa-stop"></i>
|
||||
Stop Bot
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={fetchData}
|
||||
>
|
||||
<i className="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={clearLogs}
|
||||
>
|
||||
<i className="fas fa-trash"></i>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-user-friends"></i>
|
||||
Accounts ({accounts.length})
|
||||
</h2>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<i className="fas fa-inbox"></i>
|
||||
<p>No accounts configured</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="accounts-grid">
|
||||
{accounts.map((account, index) => (
|
||||
<div key={index} className="account-item">
|
||||
<div className="account-info">
|
||||
<div className="account-avatar">
|
||||
{account.maskedEmail.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="account-details">
|
||||
<div className="account-email">{account.maskedEmail}</div>
|
||||
<div className="account-status">
|
||||
{account.lastSync ? `Last sync: ${new Date(account.lastSync).toLocaleString()}` : 'Never synced'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-stats">
|
||||
<div className="account-points">
|
||||
<div className="account-points-value">
|
||||
{account.points !== undefined ? account.points.toLocaleString() : 'N/A'}
|
||||
</div>
|
||||
<div className="account-points-label">Points</div>
|
||||
</div>
|
||||
<span className={`account-status-badge status-${account.status}`}>
|
||||
{account.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<i className="fas fa-terminal"></i>
|
||||
Live Logs ({logs.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="logs-container">
|
||||
{logs.length === 0 ? (
|
||||
<div style={{color: '#64748b', textAlign: 'center', padding: '2rem'}}>
|
||||
<i className="fas fa-stream"></i> No logs yet...
|
||||
</div>
|
||||
) : (
|
||||
logs.filter(log => log && log.timestamp && log.level).map((log, index) => (
|
||||
<div key={index} className={`log-entry log-level-${log.level}`}>
|
||||
<span className="log-timestamp">
|
||||
[{new Date(log.timestamp).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span className={`log-platform platform-${log.platform}`}>
|
||||
{log.platform}
|
||||
</span>
|
||||
<span className="log-title">[{log.title}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`toast toast-${toast.type}`}>
|
||||
<i className={`fas fa-${toast.type === 'success' ? 'check-circle' : 'exclamation-circle'}`}></i>
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -129,15 +129,19 @@ apiRouter.post('/start', (_req: Request, res: Response): void => {
|
||||
return
|
||||
}
|
||||
|
||||
// Spawn bot as child process
|
||||
const child = spawn(process.execPath, [path.join(process.cwd(), 'dist', 'index.js')], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.unref()
|
||||
|
||||
// Set running state
|
||||
dashboardState.setRunning(true)
|
||||
res.json({ success: true, pid: child.pid })
|
||||
|
||||
// Log the start
|
||||
dashboardState.addLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'log',
|
||||
platform: 'MAIN',
|
||||
title: 'DASHBOARD',
|
||||
message: 'Bot start requested from dashboard'
|
||||
})
|
||||
|
||||
res.json({ success: true, message: 'Bot start requested. Check logs for progress.' })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
@@ -146,13 +150,17 @@ apiRouter.post('/start', (_req: Request, res: Response): void => {
|
||||
// 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 })
|
||||
|
||||
dashboardState.addLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'warn',
|
||||
platform: 'MAIN',
|
||||
title: 'DASHBOARD',
|
||||
message: 'Bot stop requested from dashboard'
|
||||
})
|
||||
|
||||
res.json({ success: true, message: 'Bot will stop after current task completes' })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
@@ -198,19 +206,72 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
||||
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
|
||||
const avgPoints = accounts.length > 0 ? Math.round(totalPoints / accounts.length) : 0
|
||||
|
||||
res.json({
|
||||
totalAccounts: accounts.length,
|
||||
totalPoints,
|
||||
avgPoints,
|
||||
accountsWithErrors,
|
||||
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,
|
||||
accountsError: accounts.filter(a => a.status === 'error').length
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/account/:email - Get specific account details
|
||||
apiRouter.get('/account/:email', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { email } = req.params
|
||||
if (!email) {
|
||||
res.status(400).json({ error: 'Email parameter required' })
|
||||
return
|
||||
}
|
||||
|
||||
const account = dashboardState.getAccount(email)
|
||||
|
||||
if (!account) {
|
||||
res.status(404).json({ error: 'Account not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json(account)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/account/:email/reset - Reset account status
|
||||
apiRouter.post('/account/:email/reset', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { email } = req.params
|
||||
if (!email) {
|
||||
res.status(400).json({ error: 'Email parameter required' })
|
||||
return
|
||||
}
|
||||
|
||||
const account = dashboardState.getAccount(email)
|
||||
|
||||
if (!account) {
|
||||
res.status(404).json({ error: 'Account not found' })
|
||||
return
|
||||
}
|
||||
|
||||
dashboardState.updateAccount(email, {
|
||||
status: 'idle',
|
||||
errors: []
|
||||
})
|
||||
|
||||
res.json({ success: true })
|
||||
} 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)
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express'
|
||||
import { createServer } from 'http'
|
||||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { apiRouter } from './routes'
|
||||
import { dashboardState, DashboardLog } from './state'
|
||||
import { log as botLog } from '../util/Logger'
|
||||
@@ -23,10 +24,19 @@ export class DashboardServer {
|
||||
this.setupRoutes()
|
||||
this.setupWebSocket()
|
||||
this.interceptBotLogs()
|
||||
this.setupStateListener()
|
||||
}
|
||||
|
||||
private setupStateListener(): void {
|
||||
// Listen to dashboard state changes and broadcast to all clients
|
||||
dashboardState.addChangeListener((type, data) => {
|
||||
this.broadcastUpdate(type, data)
|
||||
})
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
this.app.use(express.json())
|
||||
this.app.use('/assets', express.static(path.join(__dirname, '../../assets')))
|
||||
this.app.use(express.static(path.join(__dirname, '../../public')))
|
||||
}
|
||||
|
||||
@@ -38,9 +48,32 @@ export class DashboardServer {
|
||||
res.json({ status: 'ok', uptime: process.uptime() })
|
||||
})
|
||||
|
||||
// Serve dashboard UI
|
||||
// Serve dashboard UI (with fallback if file doesn't exist)
|
||||
this.app.get('/', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../../public/index.html'))
|
||||
const dashboardPath = path.join(__dirname, '../../public/dashboard.html')
|
||||
const indexPath = path.join(__dirname, '../../public/index.html')
|
||||
|
||||
if (fs.existsSync(dashboardPath)) {
|
||||
res.sendFile(dashboardPath)
|
||||
} else if (fs.existsSync(indexPath)) {
|
||||
res.sendFile(indexPath)
|
||||
} else {
|
||||
res.status(200).send(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Dashboard - API Only Mode</title></head>
|
||||
<body style="font-family: sans-serif; padding: 40px; text-align: center;">
|
||||
<h1>Dashboard API Active</h1>
|
||||
<p>Frontend UI not found. API endpoints are available:</p>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li><a href="/api/status">GET /api/status</a></li>
|
||||
<li><a href="/api/accounts">GET /api/accounts</a></li>
|
||||
<li><a href="/api/logs">GET /api/logs</a></li>
|
||||
<li><a href="/api/metrics">GET /api/metrics</a></li>
|
||||
<li><a href="/health">GET /health</a></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,9 +87,23 @@ export class DashboardServer {
|
||||
console.log('[Dashboard] WebSocket client disconnected')
|
||||
})
|
||||
|
||||
// Send recent logs on connect
|
||||
const recentLogs = dashboardState.getLogs(50)
|
||||
ws.send(JSON.stringify({ type: 'history', logs: recentLogs }))
|
||||
ws.on('error', (error) => {
|
||||
console.error('[Dashboard] WebSocket error:', error)
|
||||
})
|
||||
|
||||
// Send initial data on connect
|
||||
const recentLogs = dashboardState.getLogs(100)
|
||||
const status = dashboardState.getStatus()
|
||||
const accounts = dashboardState.getAccounts()
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'init',
|
||||
data: {
|
||||
logs: recentLogs,
|
||||
status,
|
||||
accounts
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +135,11 @@ export class DashboardServer {
|
||||
const payload = JSON.stringify({ type: 'log', log: logEntry })
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(payload)
|
||||
try {
|
||||
client.send(payload)
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error sending to WebSocket client:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +147,19 @@ export class DashboardServer {
|
||||
}
|
||||
}
|
||||
|
||||
public broadcastUpdate(type: string, data: unknown): void {
|
||||
const payload = JSON.stringify({ type, data })
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(payload)
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error broadcasting update:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.server.listen(PORT, HOST, () => {
|
||||
console.log(`[Dashboard] Server running on http://${HOST}:${PORT}`)
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface DashboardStatus {
|
||||
lastRun?: string
|
||||
currentAccount?: string
|
||||
totalAccounts: number
|
||||
startTime?: string
|
||||
}
|
||||
|
||||
export interface DashboardLog {
|
||||
@@ -22,14 +23,36 @@ export interface AccountStatus {
|
||||
lastSync?: string
|
||||
status: 'idle' | 'running' | 'completed' | 'error'
|
||||
errors?: string[]
|
||||
progress?: string
|
||||
}
|
||||
|
||||
type ChangeListener = (type: string, data: unknown) => void
|
||||
|
||||
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
|
||||
private changeListeners: Set<ChangeListener> = new Set()
|
||||
|
||||
public addChangeListener(listener: ChangeListener): void {
|
||||
this.changeListeners.add(listener)
|
||||
}
|
||||
|
||||
public removeChangeListener(listener: ChangeListener): void {
|
||||
this.changeListeners.delete(listener)
|
||||
}
|
||||
|
||||
private notifyChange(type: string, data: unknown): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener(type, data)
|
||||
} catch (error) {
|
||||
console.error('[Dashboard State] Error notifying listener:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): DashboardStatus {
|
||||
return { ...this.status }
|
||||
@@ -38,9 +61,20 @@ class DashboardState {
|
||||
setRunning(running: boolean, currentAccount?: string): void {
|
||||
this.status.running = running
|
||||
this.status.currentAccount = currentAccount
|
||||
if (!running && currentAccount === undefined) {
|
||||
this.status.lastRun = new Date().toISOString()
|
||||
|
||||
if (running && !this.status.startTime) {
|
||||
this.status.startTime = new Date().toISOString()
|
||||
}
|
||||
|
||||
if (!running) {
|
||||
this.status.lastRun = new Date().toISOString()
|
||||
this.status.startTime = undefined
|
||||
if (currentAccount === undefined) {
|
||||
this.status.currentAccount = undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyChange('status', this.getStatus())
|
||||
}
|
||||
|
||||
setBotInstance(bot: MicrosoftRewardsBot | undefined): void {
|
||||
@@ -56,6 +90,7 @@ class DashboardState {
|
||||
if (this.logs.length > this.maxLogsInMemory) {
|
||||
this.logs.shift()
|
||||
}
|
||||
this.notifyChange('log', log)
|
||||
}
|
||||
|
||||
getLogs(limit = 100): DashboardLog[] {
|
||||
@@ -64,6 +99,7 @@ class DashboardState {
|
||||
|
||||
clearLogs(): void {
|
||||
this.logs = []
|
||||
this.notifyChange('logs_cleared', true)
|
||||
}
|
||||
|
||||
updateAccount(email: string, update: Partial<AccountStatus>): void {
|
||||
@@ -72,8 +108,10 @@ class DashboardState {
|
||||
maskedEmail: this.maskEmail(email),
|
||||
status: 'idle'
|
||||
}
|
||||
this.accounts.set(email, { ...existing, ...update })
|
||||
const updated = { ...existing, ...update }
|
||||
this.accounts.set(email, updated)
|
||||
this.status.totalAccounts = this.accounts.size
|
||||
this.notifyChange('account_update', updated)
|
||||
}
|
||||
|
||||
getAccounts(): AccountStatus[] {
|
||||
@@ -92,6 +130,21 @@ class DashboardState {
|
||||
const maskedDomain = domainName && domainName.length > 1 ? `${domainName.slice(0, 1)}***.${tld || 'com'}` : domain
|
||||
return `${maskedLocal}@${maskedDomain}`
|
||||
}
|
||||
|
||||
// Initialize accounts from config
|
||||
public initializeAccounts(emails: string[]): void {
|
||||
for (const email of emails) {
|
||||
if (!this.accounts.has(email)) {
|
||||
this.accounts.set(email, {
|
||||
email,
|
||||
maskedEmail: this.maskEmail(email),
|
||||
status: 'idle'
|
||||
})
|
||||
}
|
||||
}
|
||||
this.status.totalAccounts = this.accounts.size
|
||||
this.notifyChange('accounts', this.getAccounts())
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardState = new DashboardState()
|
||||
|
||||
16
src/index.ts
16
src/index.ts
@@ -1325,7 +1325,18 @@ async function main() {
|
||||
// Check for dashboard mode flag (standalone dashboard)
|
||||
if (process.argv.includes('-dashboard')) {
|
||||
const { startDashboardServer } = await import('./dashboard/server')
|
||||
const { dashboardState } = await import('./dashboard/state')
|
||||
log('main', 'DASHBOARD', 'Starting standalone dashboard server...')
|
||||
|
||||
// Load and initialize accounts
|
||||
try {
|
||||
const accounts = loadAccounts()
|
||||
dashboardState.initializeAccounts(accounts.map(a => a.email))
|
||||
log('main', 'DASHBOARD', `Initialized ${accounts.length} accounts in dashboard`)
|
||||
} catch (error) {
|
||||
log('main', 'DASHBOARD', 'Could not load accounts: ' + (error instanceof Error ? error.message : String(error)), 'warn')
|
||||
}
|
||||
|
||||
startDashboardServer()
|
||||
return
|
||||
}
|
||||
@@ -1338,6 +1349,7 @@ async function main() {
|
||||
// Auto-start dashboard if enabled in config
|
||||
if (config.dashboard?.enabled) {
|
||||
const { DashboardServer } = await import('./dashboard/server')
|
||||
const { dashboardState } = await import('./dashboard/state')
|
||||
const port = config.dashboard.port || 3000
|
||||
const host = config.dashboard.host || '127.0.0.1'
|
||||
|
||||
@@ -1345,6 +1357,10 @@ async function main() {
|
||||
process.env.DASHBOARD_PORT = String(port)
|
||||
process.env.DASHBOARD_HOST = host
|
||||
|
||||
// Initialize dashboard with accounts
|
||||
const accounts = loadAccounts()
|
||||
dashboardState.initializeAccounts(accounts.map(a => a.email))
|
||||
|
||||
const dashboardServer = new DashboardServer()
|
||||
dashboardServer.start()
|
||||
log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`)
|
||||
|
||||
Reference in New Issue
Block a user