Add files via upload

v0.3 Update
This commit is contained in:
DJObleezy 2025-03-23 23:39:04 -07:00 committed by GitHub
parent 9236dcbdf9
commit 29ecc7732b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 6483 additions and 150 deletions

686
App.py Normal file
View File

@ -0,0 +1,686 @@
"""
Main application module for the Bitcoin Mining Dashboard.
"""
import os
import logging
import time
import gc
import psutil
import signal
import sys
import threading
import json
from flask import Flask, render_template, jsonify, Response, request
from datetime import datetime
from zoneinfo import ZoneInfo
from flask_caching import Cache
from apscheduler.schedulers.background import BackgroundScheduler
# Import custom modules
from config import load_config
from data_service import MiningDashboardService
from worker_service import WorkerService
from state_manager import StateManager, arrow_history, metrics_log
# Initialize Flask app
app = Flask(__name__)
# Set up caching using a simple in-memory cache
cache = Cache(app, config={'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 10})
# Global variables for SSE connections and metrics
MAX_SSE_CONNECTIONS = 10 # Maximum concurrent SSE connections
MAX_SSE_CONNECTION_TIME = 900 # 15 minutes maximum SSE connection time
active_sse_connections = 0
sse_connections_lock = threading.Lock()
# Global variables for metrics and scheduling
cached_metrics = None
last_metrics_update_time = None
scheduler_last_successful_run = None
scheduler_recreate_lock = threading.Lock()
# Track scheduler health
scheduler = None
# Global start time
SERVER_START_TIME = datetime.now(ZoneInfo("America/Los_Angeles"))
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Initialize state manager with Redis URL from environment
redis_url = os.environ.get("REDIS_URL")
state_manager = StateManager(redis_url)
# --- Disable Client Caching for All Responses ---
@app.after_request
def add_header(response):
"""Disable browser caching for all responses."""
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# --- Memory usage monitoring ---
def log_memory_usage():
"""Log current memory usage."""
try:
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
logging.info(f"Memory usage: {mem_info.rss / 1024 / 1024:.2f} MB (RSS)")
# Log the size of key data structures
logging.info(f"Arrow history entries: {sum(len(v) for v in arrow_history.values() if isinstance(v, list))}")
logging.info(f"Metrics log entries: {len(metrics_log)}")
logging.info(f"Active SSE connections: {active_sse_connections}")
except Exception as e:
logging.error(f"Error logging memory usage: {e}")
# --- Modified update_metrics_job function ---
def update_metrics_job(force=False):
"""
Background job to update metrics.
Args:
force (bool): Whether to force update regardless of timing
"""
global cached_metrics, last_metrics_update_time, scheduler, scheduler_last_successful_run
try:
# Check scheduler health - enhanced logic to detect failed executors
if not scheduler or not hasattr(scheduler, 'running'):
logging.error("Scheduler object is invalid, attempting to recreate")
with scheduler_recreate_lock:
create_scheduler()
return
if not scheduler.running:
logging.warning("Scheduler stopped unexpectedly, attempting to restart")
try:
scheduler.start()
logging.info("Scheduler restarted successfully")
except Exception as e:
logging.error(f"Failed to restart scheduler: {e}")
# More aggressive recovery - recreate scheduler entirely
with scheduler_recreate_lock:
create_scheduler()
return
# Test the scheduler's executor by checking its state
try:
# Check if any jobs exist and are scheduled
jobs = scheduler.get_jobs()
if not jobs:
logging.error("No jobs found in scheduler - recreating")
with scheduler_recreate_lock:
create_scheduler()
return
# Check if the next run time is set for any job
next_runs = [job.next_run_time for job in jobs]
if not any(next_runs):
logging.error("No jobs with next_run_time found - recreating scheduler")
with scheduler_recreate_lock:
create_scheduler()
return
except RuntimeError as e:
# Properly handle the "cannot schedule new futures after shutdown" error
if "cannot schedule new futures after shutdown" in str(e):
logging.error("Detected dead executor, recreating scheduler")
with scheduler_recreate_lock:
create_scheduler()
return
except Exception as e:
logging.error(f"Error checking scheduler state: {e}")
# Skip update if the last one was too recent (prevents overlapping runs)
# Unless force=True is specified
current_time = time.time()
if not force and last_metrics_update_time and (current_time - last_metrics_update_time < 30):
logging.info("Skipping metrics update - previous update too recent")
return
# Set last update time to now
last_metrics_update_time = current_time
# Add timeout handling with a timer
job_timeout = 45 # seconds
job_successful = False
def timeout_handler():
if not job_successful:
logging.error("Background job timed out after 45 seconds")
# Set timeout timer
timer = threading.Timer(job_timeout, timeout_handler)
timer.daemon = True
timer.start()
try:
# Use the dashboard service to fetch metrics
metrics = dashboard_service.fetch_metrics()
if metrics:
# Update cached metrics
cached_metrics = metrics
# Update state history
state_manager.update_metrics_history(metrics)
logging.info("Background job: Metrics updated successfully")
job_successful = True
# Mark successful run time for watchdog
scheduler_last_successful_run = time.time()
# Persist critical state
state_manager.persist_critical_state(cached_metrics, scheduler_last_successful_run, last_metrics_update_time)
# Periodically check and prune data to prevent memory growth
if current_time % 300 < 60: # Every ~5 minutes
state_manager.prune_old_data()
# Only save state to Redis on a similar schedule, not every update
if current_time % 300 < 60: # Every ~5 minutes
state_manager.save_graph_state()
# Periodic full memory cleanup (every 2 hours)
if current_time % 7200 < 60: # Every ~2 hours
logging.info("Performing full memory cleanup")
gc.collect(generation=2) # Force full collection
else:
logging.error("Background job: Metrics update returned None")
except Exception as e:
logging.error(f"Background job: Unexpected error: {e}")
import traceback
logging.error(traceback.format_exc())
log_memory_usage()
finally:
# Cancel timer in finally block to ensure it's always canceled
timer.cancel()
except Exception as e:
logging.error(f"Background job: Unhandled exception: {e}")
import traceback
logging.error(traceback.format_exc())
# --- SchedulerWatchdog to monitor and recover ---
def scheduler_watchdog():
"""Periodically check if the scheduler is running and healthy."""
global scheduler, scheduler_last_successful_run
try:
# If no successful run in past 2 minutes, consider the scheduler dead
if (scheduler_last_successful_run is None or
time.time() - scheduler_last_successful_run > 120):
logging.warning("Scheduler watchdog: No successful runs detected in last 2 minutes")
# Check if actual scheduler exists and is reported as running
if not scheduler or not getattr(scheduler, 'running', False):
logging.error("Scheduler watchdog: Scheduler appears to be dead, recreating")
# Use the lock to avoid multiple threads recreating simultaneously
with scheduler_recreate_lock:
create_scheduler()
except Exception as e:
logging.error(f"Error in scheduler watchdog: {e}")
# --- Create Scheduler ---
def create_scheduler():
"""Create and configure a new scheduler instance with proper error handling."""
try:
# Stop existing scheduler if it exists
global scheduler
if 'scheduler' in globals() and scheduler:
try:
# Check if scheduler is running before attempting to shut it down
if hasattr(scheduler, 'running') and scheduler.running:
logging.info("Shutting down existing scheduler before creating a new one")
scheduler.shutdown(wait=False)
except Exception as e:
logging.error(f"Error shutting down existing scheduler: {e}")
# Create a new scheduler with more robust configuration
new_scheduler = BackgroundScheduler(
job_defaults={
'coalesce': True, # Combine multiple missed runs into a single one
'max_instances': 1, # Prevent job overlaps
'misfire_grace_time': 30 # Allow misfires up to 30 seconds
}
)
# Add the update job
new_scheduler.add_job(
func=update_metrics_job,
trigger="interval",
seconds=60,
id='update_metrics_job',
replace_existing=True
)
# Add watchdog job - runs every 30 seconds to check scheduler health
new_scheduler.add_job(
func=scheduler_watchdog,
trigger="interval",
seconds=30,
id='scheduler_watchdog',
replace_existing=True
)
# Start the scheduler
new_scheduler.start()
logging.info("Scheduler created and started successfully")
scheduler = new_scheduler
return new_scheduler
except Exception as e:
logging.error(f"Error creating scheduler: {e}")
return None
# --- Custom Template Filter ---
@app.template_filter('commafy')
def commafy(value):
"""Add commas to numbers for better readability."""
try:
return "{:,}".format(int(value))
except Exception:
return value
# --- Fixed SSE Endpoint with proper request context handling ---
@app.route('/stream')
def stream():
"""SSE endpoint for real-time updates."""
# Important: Capture any request context information BEFORE the generator
# This ensures we're not trying to access request outside its context
def event_stream():
global active_sse_connections, cached_metrics
client_id = None
try:
# Check if we're at the connection limit
with sse_connections_lock:
if active_sse_connections >= MAX_SSE_CONNECTIONS:
logging.warning(f"Connection limit reached ({MAX_SSE_CONNECTIONS}), refusing new SSE connection")
yield f"data: {{\"error\": \"Too many connections, please try again later\", \"retry\": 5000}}\n\n"
return
active_sse_connections += 1
client_id = f"client-{int(time.time() * 1000) % 10000}"
logging.info(f"SSE {client_id}: Connection established (total: {active_sse_connections})")
# Set a maximum connection time - increased to 15 minutes for better user experience
end_time = time.time() + MAX_SSE_CONNECTION_TIME
last_timestamp = None
# Send initial data immediately to prevent delay in dashboard updates
if cached_metrics:
yield f"data: {json.dumps(cached_metrics)}\n\n"
last_timestamp = cached_metrics.get("server_timestamp")
else:
# Send ping if no data available yet
yield f"data: {{\"type\": \"ping\", \"client_id\": \"{client_id}\"}}\n\n"
# Main event loop with improved error handling
while time.time() < end_time:
try:
# Send data only if it's changed
if cached_metrics and cached_metrics.get("server_timestamp") != last_timestamp:
data = json.dumps(cached_metrics)
last_timestamp = cached_metrics.get("server_timestamp")
yield f"data: {data}\n\n"
# Send regular pings about every 30 seconds to keep connection alive
if int(time.time()) % 30 == 0:
yield f"data: {{\"type\": \"ping\", \"time\": {int(time.time())}, \"connections\": {active_sse_connections}}}\n\n"
# Sleep to reduce CPU usage
time.sleep(1)
# Warn client 60 seconds before timeout so client can prepare to reconnect
remaining_time = end_time - time.time()
if remaining_time < 60 and int(remaining_time) % 15 == 0: # Every 15 sec in last minute
yield f"data: {{\"type\": \"timeout_warning\", \"remaining\": {int(remaining_time)}}}\n\n"
except Exception as e:
logging.error(f"SSE {client_id}: Error in stream: {e}")
time.sleep(2) # Prevent tight error loops
# Connection timeout reached - send a reconnect instruction to client
logging.info(f"SSE {client_id}: Connection timeout reached ({MAX_SSE_CONNECTION_TIME}s)")
yield f"data: {{\"type\": \"timeout\", \"message\": \"Connection timeout reached\", \"reconnect\": true}}\n\n"
except GeneratorExit:
# This is how we detect client disconnection
logging.info(f"SSE {client_id}: Client disconnected (GeneratorExit)")
# Don't yield here - just let the generator exit normally
finally:
# Always decrement the connection counter when done
with sse_connections_lock:
active_sse_connections = max(0, active_sse_connections - 1)
logging.info(f"SSE {client_id}: Connection closed (remaining: {active_sse_connections})")
# Configure response with improved error handling
try:
response = Response(event_stream(), mimetype="text/event-stream")
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' # Disable nginx buffering
response.headers['Access-Control-Allow-Origin'] = '*' # Allow CORS
return response
except Exception as e:
logging.error(f"Error creating SSE response: {e}")
return jsonify({"error": "Internal server error"}), 500
# Duplicate stream endpoint for the dashboard path
@app.route('/dashboard/stream')
def dashboard_stream():
"""Duplicate of the stream endpoint for the dashboard route."""
return stream()
# --- Routes ---
@app.route("/")
def boot():
"""Serve the boot sequence page."""
return render_template("boot.html", base_url=request.host_url.rstrip('/'))
# --- Updated Dashboard Route ---
@app.route("/dashboard")
def dashboard():
"""Serve the main dashboard page."""
global cached_metrics, last_metrics_update_time
# Make sure we have metrics data before rendering the template
if cached_metrics is None:
# Force an immediate metrics fetch regardless of the time since last update
logging.info("Dashboard accessed with no cached metrics - forcing immediate fetch")
try:
# Force update with the force parameter
update_metrics_job(force=True)
except Exception as e:
logging.error(f"Error during forced metrics fetch: {e}")
# If still None after our attempt, create default metrics
if cached_metrics is None:
default_metrics = {
"server_timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat(),
"server_start_time": SERVER_START_TIME.astimezone(ZoneInfo("America/Los_Angeles")).isoformat(),
"hashrate_24hr": None,
"hashrate_24hr_unit": "TH/s",
"hashrate_3hr": None,
"hashrate_3hr_unit": "TH/s",
"hashrate_10min": None,
"hashrate_10min_unit": "TH/s",
"hashrate_60sec": None,
"hashrate_60sec_unit": "TH/s",
"pool_total_hashrate": None,
"pool_total_hashrate_unit": "TH/s",
"workers_hashing": 0,
"total_last_share": None,
"block_number": None,
"btc_price": 0,
"network_hashrate": 0,
"difficulty": 0,
"daily_revenue": 0,
"daily_power_cost": 0,
"daily_profit_usd": 0,
"monthly_profit_usd": 0,
"daily_mined_sats": 0,
"monthly_mined_sats": 0,
"unpaid_earnings": "0",
"est_time_to_payout": None,
"last_block_height": None,
"last_block_time": None,
"last_block_earnings": None,
"blocks_found": "0",
"estimated_earnings_per_day_sats": 0,
"estimated_earnings_next_block_sats": 0,
"estimated_rewards_in_window_sats": 0,
"arrow_history": {}
}
logging.warning("Rendering dashboard with default metrics - no data available yet")
current_time = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M:%S %p")
return render_template("dashboard.html", metrics=default_metrics, current_time=current_time)
# If we have metrics, use them
current_time = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M:%S %p")
return render_template("dashboard.html", metrics=cached_metrics, current_time=current_time)
@app.route("/api/metrics")
def api_metrics():
"""API endpoint for metrics data."""
if cached_metrics is None:
update_metrics_job()
return jsonify(cached_metrics)
# --- Workers Dashboard Route and API ---
@app.route("/workers")
def workers_dashboard():
"""Serve the workers overview dashboard page."""
current_time = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M:%S %p")
# Only get minimal worker stats for initial page load
# Client-side JS will fetch the full data via API
workers_data = worker_service.get_workers_data(cached_metrics)
return render_template("workers.html",
current_time=current_time,
workers_total=workers_data.get('workers_total', 0),
workers_online=workers_data.get('workers_online', 0),
workers_offline=workers_data.get('workers_offline', 0),
total_hashrate=workers_data.get('total_hashrate', 0),
hashrate_unit=workers_data.get('hashrate_unit', 'TH/s'),
total_earnings=workers_data.get('total_earnings', 0),
daily_sats=workers_data.get('daily_sats', 0),
avg_acceptance_rate=workers_data.get('avg_acceptance_rate', 0))
@app.route("/api/workers")
def api_workers():
"""API endpoint for worker data."""
# Get the force_refresh parameter from the query string (default: False)
force_refresh = request.args.get('force', 'false').lower() == 'true'
return jsonify(worker_service.get_workers_data(cached_metrics, force_refresh=force_refresh))
# --- New Time Endpoint for Fine Syncing ---
@app.route("/api/time")
def api_time():
"""API endpoint for server time."""
return jsonify({
"server_timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat(),
"server_start_time": SERVER_START_TIME.astimezone(ZoneInfo("America/Los_Angeles")).isoformat()
})
# Health check endpoint with detailed diagnostics
@app.route("/api/health")
def health_check():
"""Health check endpoint with enhanced system diagnostics."""
# Calculate uptime
uptime_seconds = (datetime.now(ZoneInfo("America/Los_Angeles")) - SERVER_START_TIME).total_seconds()
# Get process memory usage
try:
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
memory_usage_mb = mem_info.rss / 1024 / 1024
memory_percent = process.memory_percent()
except Exception as e:
logging.error(f"Error getting memory usage: {e}")
memory_usage_mb = 0
memory_percent = 0
# Check data freshness
data_age = 0
if cached_metrics and cached_metrics.get("server_timestamp"):
try:
last_update = datetime.fromisoformat(cached_metrics["server_timestamp"])
data_age = (datetime.now(ZoneInfo("America/Los_Angeles")) - last_update).total_seconds()
except Exception as e:
logging.error(f"Error calculating data age: {e}")
# Determine health status
health_status = "healthy"
if data_age > 300: # Data older than 5 minutes
health_status = "degraded"
if not cached_metrics:
health_status = "unhealthy"
# Build response with detailed diagnostics
status = {
"status": health_status,
"uptime": uptime_seconds,
"uptime_formatted": f"{int(uptime_seconds // 3600)}h {int((uptime_seconds % 3600) // 60)}m {int(uptime_seconds % 60)}s",
"connections": active_sse_connections,
"memory": {
"usage_mb": round(memory_usage_mb, 2),
"percent": round(memory_percent, 2)
},
"data": {
"last_update": cached_metrics.get("server_timestamp") if cached_metrics else None,
"age_seconds": int(data_age),
"available": cached_metrics is not None
},
"scheduler": {
"running": scheduler.running if hasattr(scheduler, "running") else False,
"last_successful_run": scheduler_last_successful_run
},
"redis": {
"connected": state_manager.redis_client is not None
},
"timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
# Log health check if status is not healthy
if health_status != "healthy":
logging.warning(f"Health check returning {health_status} status: {status}")
return jsonify(status)
# Add enhanced scheduler health check endpoint
@app.route("/api/scheduler-health")
def scheduler_health():
"""API endpoint for scheduler health information."""
try:
scheduler_status = {
"running": scheduler.running if hasattr(scheduler, "running") else False,
"job_count": len(scheduler.get_jobs()) if hasattr(scheduler, "get_jobs") else 0,
"next_run": str(scheduler.get_jobs()[0].next_run_time) if hasattr(scheduler, "get_jobs") and scheduler.get_jobs() else None,
"last_update": last_metrics_update_time,
"time_since_update": time.time() - last_metrics_update_time if last_metrics_update_time else None,
"last_successful_run": scheduler_last_successful_run,
"time_since_successful": time.time() - scheduler_last_successful_run if scheduler_last_successful_run else None
}
return jsonify(scheduler_status)
except Exception as e:
return jsonify({"error": str(e)}), 500
# Add a health check route that can attempt to fix the scheduler if needed
@app.route("/api/fix-scheduler", methods=["POST"])
def fix_scheduler():
"""API endpoint to recreate the scheduler."""
try:
with scheduler_recreate_lock:
new_scheduler = create_scheduler()
if new_scheduler:
global scheduler
scheduler = new_scheduler
return jsonify({"status": "success", "message": "Scheduler recreated successfully"})
else:
return jsonify({"status": "error", "message": "Failed to recreate scheduler"}), 500
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route("/api/force-refresh", methods=["POST"])
def force_refresh():
"""Emergency endpoint to force metrics refresh."""
logging.warning("Emergency force-refresh requested")
try:
# Force fetch new metrics
metrics = dashboard_service.fetch_metrics()
if metrics:
global cached_metrics, scheduler_last_successful_run
cached_metrics = metrics
scheduler_last_successful_run = time.time()
logging.info(f"Force refresh successful, new timestamp: {metrics['server_timestamp']}")
return jsonify({"status": "success", "message": "Metrics refreshed", "timestamp": metrics['server_timestamp']})
else:
return jsonify({"status": "error", "message": "Failed to fetch metrics"}), 500
except Exception as e:
logging.error(f"Force refresh error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.errorhandler(404)
def page_not_found(e):
"""Error handler for 404 errors."""
return render_template("error.html", message="Page not found."), 404
@app.errorhandler(500)
def internal_server_error(e):
"""Error handler for 500 errors."""
logging.error("Internal server error: %s", e)
return render_template("error.html", message="Internal server error."), 500
class RobustMiddleware:
"""WSGI middleware for enhanced error handling."""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
try:
return self.app(environ, start_response)
except Exception as e:
logging.exception("Unhandled exception in WSGI app")
start_response("500 Internal Server Error", [("Content-Type", "text/html")])
return [b"<h1>Internal Server Error</h1>"]
# Add the middleware
app.wsgi_app = RobustMiddleware(app.wsgi_app)
# Initialize the dashboard service and worker service
config = load_config()
dashboard_service = MiningDashboardService(
config.get("power_cost", 0.0),
config.get("power_usage", 0.0),
config.get("wallet")
)
worker_service = WorkerService()
# Restore critical state if available
last_run, last_update = state_manager.load_critical_state()
if last_run:
scheduler_last_successful_run = last_run
if last_update:
last_metrics_update_time = last_update
# Initialize the scheduler
scheduler = create_scheduler()
# Graceful shutdown handler for clean termination
def graceful_shutdown(signum, frame):
"""Handle shutdown signals gracefully."""
logging.info(f"Received shutdown signal {signum}, shutting down gracefully")
# Save state before shutting down
state_manager.save_graph_state()
# Stop the scheduler
if scheduler:
try:
scheduler.shutdown(wait=True) # wait for running jobs to complete
logging.info("Scheduler shutdown complete")
except Exception as e:
logging.error(f"Error shutting down scheduler: {e}")
# Log connection info before exit
logging.info(f"Active SSE connections at shutdown: {active_sse_connections}")
# Exit with success code
sys.exit(0)
# Register signal handlers
signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)
# Run once at startup to initialize data
update_metrics_job(force=True)
if __name__ == "__main__":
# When deploying with Gunicorn in Docker, run with --workers=1 --threads=8 to ensure global state is shared.
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

300
README.md
View File

@ -1,150 +1,150 @@
# Ocean.xyz Bitcoin Mining Dashboard
## A Practical Monitoring Solution for Bitcoin Miners
This open-source dashboard provides comprehensive monitoring for Ocean.xyz pool miners, offering real-time data on hashrate, profitability, and worker status. Designed to be resource-efficient and user-friendly, it helps miners maintain oversight of their operations.
---
## Gallery:
![Boot Sequence](https://github.com/user-attachments/assets/8205e8c0-79ad-4780-bc50-237131373cf8)
![Main Dashboard](https://github.com/user-attachments/assets/33dafb93-38ef-4fee-aba1-3a7d38eca3c9)
![Workers Overview](https://github.com/user-attachments/assets/ae78c34c-fbdf-4186-9706-760a67eac44c)
---
## Practical Mining Intelligence
The dashboard aggregates essential metrics in one accessible interface:
- **Profitability Analysis**: Monitor daily and monthly earnings in BTC and USD
- **Worker Status**: Track online/offline status of mining equipment
- **Payout Monitoring**: View unpaid balance and estimated time to next payout
- **Network Metrics**: Stay informed of difficulty adjustments and network hashrate
- **Cost Analysis**: Calculate profit margins based on power consumption
## Key Features
### Mining Performance Metrics
- **Hashrate Visualization**: Clear graphical representation of hashrate trends
- **Financial Calculations**: Automatic conversion between BTC and USD values
- **Payout Estimation**: Projected time until minimum payout threshold is reached
- **Network Intelligence**: Current Bitcoin price, difficulty, and total network hashrate
### Worker Management
- **Equipment Overview**: Consolidated view of all mining devices
- **Status Monitoring**: Clear indicators for active and inactive devices
- **Performance Data**: Individual hashrate, temperature, and acceptance rate metrics
- **Filtering Options**: Sort and search by device type or operational status
### Thoughtful Design Elements
- **Retro Terminal Monitor**: A floating system monitor with classic design aesthetics
- **Boot Sequence**: An engaging initialization sequence on startup
- **Responsive Interface**: Adapts seamlessly to desktop and mobile devices
## Quick Start
### Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Run the setup script:
```
python setup.py
```
4. Configure your wallet:
```json
{
"power_cost": 0.12,
"power_usage": 3450,
"wallet": "your-wallet-address"
}
```
5. Start the application:
```
python App.py
```
6. Open your browser at `http://localhost:5000`
### Docker Deployment
```bash
docker run -d -p 5000:5000 \
-e WALLET=your-wallet-address \
-e POWER_COST=0.12 \
-e POWER_USAGE=3450 \
yourusername/bitcoin-mining-dashboard
```
For full deployment instructions, see [DEPLOYMENT.md](DEPLOYMENT.md).
## Dashboard Components
### Main Dashboard
- Interactive hashrate visualization
- Detailed profitability metrics
- Network statistics
- Current Bitcoin price
- Balance and payment information
### Workers Dashboard
- Fleet summary with aggregate statistics
- Individual worker performance metrics
- Status indicators for each device
- Flexible filtering and search functionality
### Retro Terminal Monitor
- Floating interface providing system statistics
- Progress indicator for data refresh cycles
- System uptime display
- Minimizable design for unobtrusive monitoring
- Thoughtful visual styling reminiscent of classic computer terminals
## System Requirements
The application is designed for efficient resource utilization:
- Compatible with standard desktop and laptop computers
- Modest CPU and memory requirements
- Suitable for continuous operation
- Cross-platform support for Windows, macOS, and Linux
## Project Structure
For details on the project's architecture and organization, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).
## Troubleshooting
For optimal performance:
1. Use the refresh function if data appears outdated
2. Verify network connectivity for consistent updates
3. Restart the application after configuration changes
4. Access the health endpoint at `/api/health` for system status information
## Getting Started
1. Download the latest release
2. Configure with your mining information
3. Launch the application to begin monitoring
The dashboard requires only your Ocean.xyz mining wallet address for basic functionality.
---
## Technical Foundation
Built on Flask with Chart.js for visualization and Server-Sent Events for real-time updates, this dashboard retrieves data from Ocean.xyz and performs calculations based on current network metrics and your specified parameters.
The application prioritizes stability and efficiency for reliable long-term operation. Source code is available for review and customization.
## Acknowledgments
- Ocean.xyz mining pool for their service
- The open-source community for their contributions
- Bitcoin protocol developers
Available under the MIT License. This is an independent project not affiliated with Ocean.xyz.
# Ocean.xyz Bitcoin Mining Dashboard
## A Practical Monitoring Solution for Bitcoin Miners
This open-source dashboard provides comprehensive monitoring for Ocean.xyz pool miners, offering real-time data on hashrate, profitability, and worker status. Designed to be resource-efficient and user-friendly, it helps miners maintain oversight of their operations.
---
## Gallery:
![Boot Sequence](https://github.com/user-attachments/assets/8205e8c0-79ad-4780-bc50-237131373cf8)
![Main Dashboard](https://github.com/user-attachments/assets/33dafb93-38ef-4fee-aba1-3a7d38eca3c9)
![Workers Overview](https://github.com/user-attachments/assets/ae78c34c-fbdf-4186-9706-760a67eac44c)
---
## Practical Mining Intelligence
The dashboard aggregates essential metrics in one accessible interface:
- **Profitability Analysis**: Monitor daily and monthly earnings in BTC and USD
- **Worker Status**: Track online/offline status of mining equipment
- **Payout Monitoring**: View unpaid balance and estimated time to next payout
- **Network Metrics**: Stay informed of difficulty adjustments and network hashrate
- **Cost Analysis**: Calculate profit margins based on power consumption
## Key Features
### Mining Performance Metrics
- **Hashrate Visualization**: Clear graphical representation of hashrate trends
- **Financial Calculations**: Automatic conversion between BTC and USD values
- **Payout Estimation**: Projected time until minimum payout threshold is reached
- **Network Intelligence**: Current Bitcoin price, difficulty, and total network hashrate
### Worker Management
- **Equipment Overview**: Consolidated view of all mining devices
- **Status Monitoring**: Clear indicators for active and inactive devices
- **Performance Data**: Individual hashrate, temperature, and acceptance rate metrics
- **Filtering Options**: Sort and search by device type or operational status
### Thoughtful Design Elements
- **Retro Terminal Monitor**: A floating system monitor with classic design aesthetics
- **Boot Sequence**: An engaging initialization sequence on startup
- **Responsive Interface**: Adapts seamlessly to desktop and mobile devices
## Quick Start
### Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Run the setup script:
```
python setup.py
```
4. Configure your wallet:
```json
{
"power_cost": 0.12,
"power_usage": 3450,
"wallet": "your-wallet-address"
}
```
5. Start the application:
```
python App.py
```
6. Open your browser at `http://localhost:5000`
### Docker Deployment
```bash
docker run -d -p 5000:5000 \
-e WALLET=your-wallet-address \
-e POWER_COST=0.12 \
-e POWER_USAGE=3450 \
yourusername/bitcoin-mining-dashboard
```
For full deployment instructions, see [DEPLOYMENT.md](DEPLOYMENT.md).
## Dashboard Components
### Main Dashboard
- Interactive hashrate visualization
- Detailed profitability metrics
- Network statistics
- Current Bitcoin price
- Balance and payment information
### Workers Dashboard
- Fleet summary with aggregate statistics
- Individual worker performance metrics
- Status indicators for each device
- Flexible filtering and search functionality
### Retro Terminal Monitor
- Floating interface providing system statistics
- Progress indicator for data refresh cycles
- System uptime display
- Minimizable design for unobtrusive monitoring
- Thoughtful visual styling reminiscent of classic computer terminals
## System Requirements
The application is designed for efficient resource utilization:
- Compatible with standard desktop and laptop computers
- Modest CPU and memory requirements
- Suitable for continuous operation
- Cross-platform support for Windows, macOS, and Linux
## Project Structure
For details on the project's architecture and organization, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).
## Troubleshooting
For optimal performance:
1. Use the refresh function if data appears outdated
2. Verify network connectivity for consistent updates
3. Restart the application after configuration changes
4. Access the health endpoint at `/api/health` for system status information
## Getting Started
1. Download the latest release
2. Configure with your mining information
3. Launch the application to begin monitoring
The dashboard requires only your Ocean.xyz mining wallet address for basic functionality.
---
## Technical Foundation
Built on Flask with Chart.js for visualization and Server-Sent Events for real-time updates, this dashboard retrieves data from Ocean.xyz and performs calculations based on current network metrics and your specified parameters.
The application prioritizes stability and efficiency for reliable long-term operation. Source code is available for review and customization.
## Acknowledgments
- Ocean.xyz mining pool for their service
- The open-source community for their contributions
- Bitcoin protocol developers
Available under the MIT License. This is an independent project not affiliated with Ocean.xyz.

5
config.json Normal file
View File

@ -0,0 +1,5 @@
{
"power_cost": 0.0,
"power_usage": 0.0,
"wallet": "bc1py5zmrtssheq3shd8cptpl5l5m3txxr5afynyg2gyvam6w78s4dlqqnt4v9"
}

69
config.py Normal file
View File

@ -0,0 +1,69 @@
"""
Configuration management module for the Bitcoin Mining Dashboard.
Responsible for loading and managing application settings.
"""
import os
import json
import logging
# Default configuration file path
CONFIG_FILE = "config.json"
def load_config():
"""
Load configuration from file or return defaults if file doesn't exist.
Returns:
dict: Configuration dictionary with settings
"""
default_config = {
"power_cost": 0.0,
"power_usage": 0.0,
"wallet": "bc1py5zmrtssheq3shd8cptpl5l5m3txxr5afynyg2gyvam6w78s4dlqqnt4v9"
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
logging.info(f"Configuration loaded from {CONFIG_FILE}")
return config
except Exception as e:
logging.error(f"Error loading config: {e}")
else:
logging.warning(f"Config file {CONFIG_FILE} not found, using defaults")
return default_config
def save_config(config):
"""
Save configuration to file.
Args:
config (dict): Configuration dictionary to save
Returns:
bool: True if save was successful, False otherwise
"""
try:
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
logging.info(f"Configuration saved to {CONFIG_FILE}")
return True
except Exception as e:
logging.error(f"Error saving config: {e}")
return False
def get_value(key, default=None):
"""
Get a configuration value by key with fallback to default.
Args:
key (str): Configuration key to look up
default: Default value if key is not found
Returns:
Value for the key or default if not found
"""
config = load_config()
return config.get(key, default)

438
data_service.py Normal file
View File

@ -0,0 +1,438 @@
"""
Data service module for fetching and processing mining data.
"""
import logging
import re
import time
import json
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from concurrent.futures import ThreadPoolExecutor
import requests
from bs4 import BeautifulSoup
from models import OceanData, convert_to_ths
class MiningDashboardService:
"""Service for fetching and processing mining dashboard data."""
def __init__(self, power_cost, power_usage, wallet):
"""
Initialize the mining dashboard service.
Args:
power_cost (float): Cost of power in $ per kWh
power_usage (float): Power usage in watts
wallet (str): Bitcoin wallet address for Ocean.xyz
"""
self.power_cost = power_cost
self.power_usage = power_usage
self.wallet = wallet
self.cache = {}
self.sats_per_btc = 100_000_000
self.previous_values = {}
self.session = requests.Session()
def fetch_metrics(self):
"""
Fetch metrics from Ocean.xyz and other sources.
Returns:
dict: Mining metrics data
"""
# Add execution time tracking
start_time = time.time()
try:
with ThreadPoolExecutor(max_workers=2) as executor:
future_ocean = executor.submit(self.get_ocean_data)
future_btc = executor.submit(self.get_bitcoin_stats)
try:
ocean_data = future_ocean.result(timeout=15)
btc_stats = future_btc.result(timeout=15)
except Exception as e:
logging.error(f"Error fetching metrics concurrently: {e}")
return None
if ocean_data is None:
logging.error("Failed to retrieve Ocean data")
return None
difficulty, network_hashrate, btc_price, block_count = btc_stats
# If we failed to get network hashrate, use a reasonable default to prevent division by zero
if network_hashrate is None:
logging.warning("Using default network hashrate")
network_hashrate = 500e18 # ~500 EH/s as a reasonable fallback
# If we failed to get BTC price, use a reasonable default
if btc_price is None:
logging.warning("Using default BTC price")
btc_price = 75000 # $75,000 as a reasonable fallback
# Convert hashrates to a common unit (TH/s) for consistency
hr3 = ocean_data.hashrate_3hr or 0
hr3_unit = (ocean_data.hashrate_3hr_unit or 'th/s').lower()
local_hashrate = convert_to_ths(hr3, hr3_unit) * 1e12 # Convert to H/s for calculation
hash_proportion = local_hashrate / network_hashrate if network_hashrate else 0
block_reward = 3.125
blocks_per_day = 86400 / 600
daily_btc_gross = hash_proportion * block_reward * blocks_per_day
daily_btc_net = daily_btc_gross * (1 - 0.02 - 0.028)
daily_revenue = round(daily_btc_net * btc_price, 2) if btc_price is not None else None
daily_power_cost = round((self.power_usage / 1000) * self.power_cost * 24, 2)
daily_profit_usd = round(daily_revenue - daily_power_cost, 2) if daily_revenue is not None else None
monthly_profit_usd = round(daily_profit_usd * 30, 2) if daily_profit_usd is not None else None
daily_mined_sats = int(round(daily_btc_net * self.sats_per_btc))
monthly_mined_sats = daily_mined_sats * 30
# Use default 0 for earnings if scraping returned None.
estimated_earnings_per_day = ocean_data.estimated_earnings_per_day if ocean_data.estimated_earnings_per_day is not None else 0
estimated_earnings_next_block = ocean_data.estimated_earnings_next_block if ocean_data.estimated_earnings_next_block is not None else 0
estimated_rewards_in_window = ocean_data.estimated_rewards_in_window if ocean_data.estimated_rewards_in_window is not None else 0
metrics = {
'pool_total_hashrate': ocean_data.pool_total_hashrate,
'pool_total_hashrate_unit': ocean_data.pool_total_hashrate_unit,
'hashrate_24hr': ocean_data.hashrate_24hr,
'hashrate_24hr_unit': ocean_data.hashrate_24hr_unit,
'hashrate_3hr': ocean_data.hashrate_3hr,
'hashrate_3hr_unit': ocean_data.hashrate_3hr_unit,
'hashrate_10min': ocean_data.hashrate_10min,
'hashrate_10min_unit': ocean_data.hashrate_10min_unit,
'hashrate_5min': ocean_data.hashrate_5min,
'hashrate_5min_unit': ocean_data.hashrate_5min_unit,
'hashrate_60sec': ocean_data.hashrate_60sec,
'hashrate_60sec_unit': ocean_data.hashrate_60sec_unit,
'workers_hashing': ocean_data.workers_hashing,
'btc_price': btc_price,
'block_number': block_count,
'network_hashrate': (network_hashrate / 1e18) if network_hashrate else None,
'difficulty': difficulty,
'daily_btc_net': daily_btc_net,
'estimated_earnings_per_day': estimated_earnings_per_day,
'daily_revenue': daily_revenue,
'daily_power_cost': daily_power_cost,
'daily_profit_usd': daily_profit_usd,
'monthly_profit_usd': monthly_profit_usd,
'daily_mined_sats': daily_mined_sats,
'monthly_mined_sats': monthly_mined_sats,
'estimated_earnings_next_block': estimated_earnings_next_block,
'estimated_rewards_in_window': estimated_rewards_in_window,
'unpaid_earnings': ocean_data.unpaid_earnings,
'est_time_to_payout': ocean_data.est_time_to_payout,
'last_block_height': ocean_data.last_block_height,
'last_block_time': ocean_data.last_block_time,
'total_last_share': ocean_data.total_last_share,
'blocks_found': ocean_data.blocks_found or "0",
'last_block_earnings': ocean_data.last_block_earnings
}
metrics['estimated_earnings_per_day_sats'] = int(round(estimated_earnings_per_day * self.sats_per_btc))
metrics['estimated_earnings_next_block_sats'] = int(round(estimated_earnings_next_block * self.sats_per_btc))
metrics['estimated_rewards_in_window_sats'] = int(round(estimated_rewards_in_window * self.sats_per_btc))
# --- Add server timestamps to the response in Los Angeles Time ---
metrics["server_timestamp"] = datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
metrics["server_start_time"] = datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
# Log execution time
execution_time = time.time() - start_time
metrics["execution_time"] = execution_time
if execution_time > 10:
logging.warning(f"Metrics fetch took {execution_time:.2f} seconds")
else:
logging.info(f"Metrics fetch completed in {execution_time:.2f} seconds")
return metrics
except Exception as e:
logging.error(f"Unexpected error in fetch_metrics: {e}")
return None
def get_ocean_data(self):
"""
Get mining data from Ocean.xyz.
Returns:
OceanData: Ocean.xyz mining data
"""
base_url = "https://ocean.xyz"
stats_url = f"{base_url}/stats/{self.wallet}"
headers = {
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Cache-Control': 'no-cache'
}
# Create an empty data object to populate
data = OceanData()
try:
response = self.session.get(stats_url, headers=headers, timeout=10)
if not response.ok:
logging.error(f"Error fetching ocean data: status code {response.status_code}")
return None
soup = BeautifulSoup(response.text, 'html.parser')
# Safely extract pool status information
try:
pool_status = soup.find("p", id="pool-status-item")
if pool_status:
text = pool_status.get_text(strip=True)
m_total = re.search(r'HASHRATE:\s*([\d\.]+)\s*(\w+/s)', text, re.IGNORECASE)
if m_total:
raw_val = float(m_total.group(1))
unit = m_total.group(2)
data.pool_total_hashrate = raw_val
data.pool_total_hashrate_unit = unit
span = pool_status.find("span", class_="pool-status-newline")
if span:
last_block_text = span.get_text(strip=True)
m_block = re.search(r'LAST BLOCK:\s*(\d+\s*\(.*\))', last_block_text, re.IGNORECASE)
if m_block:
full_last_block = m_block.group(1)
data.last_block = full_last_block
match = re.match(r'(\d+)\s*\((.*?)\)', full_last_block)
if match:
data.last_block_height = match.group(1)
data.last_block_time = match.group(2)
else:
data.last_block_height = full_last_block
data.last_block_time = ""
except Exception as e:
logging.error(f"Error parsing pool status: {e}")
# Parse the earnings value from the earnings table and convert to sats.
try:
earnings_table = soup.find('tbody', id='earnings-tablerows')
if earnings_table:
latest_row = earnings_table.find('tr', class_='table-row')
if latest_row:
cells = latest_row.find_all('td', class_='table-cell')
if len(cells) >= 3:
earnings_text = cells[2].get_text(strip=True)
earnings_value = earnings_text.replace('BTC', '').strip()
try:
btc_earnings = float(earnings_value)
sats = int(round(btc_earnings * 100000000))
data.last_block_earnings = str(sats)
except Exception:
data.last_block_earnings = earnings_value
except Exception as e:
logging.error(f"Error parsing earnings data: {e}")
# Parse hashrate data from the hashrates table
try:
time_mapping = {
'24 hrs': ('hashrate_24hr', 'hashrate_24hr_unit'),
'3 hrs': ('hashrate_3hr', 'hashrate_3hr_unit'),
'10 min': ('hashrate_10min', 'hashrate_10min_unit'),
'5 min': ('hashrate_5min', 'hashrate_5min_unit'),
'60 sec': ('hashrate_60sec', 'hashrate_60sec_unit')
}
hashrate_table = soup.find('tbody', id='hashrates-tablerows')
if hashrate_table:
for row in hashrate_table.find_all('tr', class_='table-row'):
cells = row.find_all('td', class_='table-cell')
if len(cells) >= 2:
period_text = cells[0].get_text(strip=True).lower()
hashrate_str = cells[1].get_text(strip=True).lower()
try:
parts = hashrate_str.split()
hashrate_val = float(parts[0])
unit = parts[1] if len(parts) > 1 else 'th/s'
for key, (attr, unit_attr) in time_mapping.items():
if key.lower() in period_text:
setattr(data, attr, hashrate_val)
setattr(data, unit_attr, unit)
break
except Exception as e:
logging.error(f"Error parsing hashrate '{hashrate_str}': {e}")
except Exception as e:
logging.error(f"Error parsing hashrate table: {e}")
# Parse lifetime stats data
try:
lifetime_snap = soup.find('div', id='lifetimesnap-statcards')
if lifetime_snap:
for container in lifetime_snap.find_all('div', class_='blocks dashboard-container'):
label_div = container.find('div', class_='blocks-label')
if label_div:
label_text = label_div.get_text(strip=True).lower()
earnings_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
if earnings_span:
span_text = earnings_span.get_text(strip=True)
try:
earnings_value = float(span_text.split()[0].replace(',', ''))
if "earnings" in label_text and "day" in label_text:
data.estimated_earnings_per_day = earnings_value
except Exception:
pass
except Exception as e:
logging.error(f"Error parsing lifetime stats: {e}")
# Parse payout stats data
try:
payout_snap = soup.find('div', id='payoutsnap-statcards')
if payout_snap:
for container in payout_snap.find_all('div', class_='blocks dashboard-container'):
label_div = container.find('div', class_='blocks-label')
if label_div:
label_text = label_div.get_text(strip=True).lower()
earnings_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
if earnings_span:
span_text = earnings_span.get_text(strip=True)
try:
earnings_value = float(span_text.split()[0].replace(',', ''))
if "earnings" in label_text and "block" in label_text:
data.estimated_earnings_next_block = earnings_value
elif "rewards" in label_text and "window" in label_text:
data.estimated_rewards_in_window = earnings_value
except Exception:
pass
except Exception as e:
logging.error(f"Error parsing payout stats: {e}")
# Parse user stats data
try:
usersnap = soup.find('div', id='usersnap-statcards')
if usersnap:
for container in usersnap.find_all('div', class_='blocks dashboard-container'):
label_div = container.find('div', class_='blocks-label')
if label_div:
label_text = label_div.get_text(strip=True).lower()
value_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
if value_span:
span_text = value_span.get_text(strip=True)
if "workers currently hashing" in label_text:
try:
data.workers_hashing = int(span_text.replace(",", ""))
except Exception:
pass
elif "unpaid earnings" in label_text and "btc" in span_text.lower():
try:
data.unpaid_earnings = float(span_text.split()[0].replace(',', ''))
except Exception:
pass
elif "estimated time until minimum payout" in label_text:
data.est_time_to_payout = span_text
except Exception as e:
logging.error(f"Error parsing user stats: {e}")
# Parse blocks found data
try:
blocks_container = soup.find(lambda tag: tag.name == "div" and "blocks found" in tag.get_text(strip=True).lower())
if blocks_container:
span = blocks_container.find_next_sibling("span")
if span:
num_match = re.search(r'(\d+)', span.get_text(strip=True))
if num_match:
data.blocks_found = num_match.group(1)
except Exception as e:
logging.error(f"Error parsing blocks found: {e}")
# Parse last share time data
try:
workers_table = soup.find("tbody", id="workers-tablerows")
if workers_table:
for row in workers_table.find_all("tr", class_="table-row"):
cells = row.find_all("td")
if cells and cells[0].get_text(strip=True).lower().startswith("total"):
last_share_str = cells[2].get_text(strip=True)
try:
naive_dt = datetime.strptime(last_share_str, "%Y-%m-%d %H:%M")
utc_dt = naive_dt.replace(tzinfo=ZoneInfo("UTC"))
la_dt = utc_dt.astimezone(ZoneInfo("America/Los_Angeles"))
data.total_last_share = la_dt.strftime("%Y-%m-%d %I:%M %p")
except Exception as e:
logging.error(f"Error converting last share time '{last_share_str}': {e}")
data.total_last_share = last_share_str
break
except Exception as e:
logging.error(f"Error parsing last share time: {e}")
return data
except Exception as e:
logging.error(f"Error fetching Ocean data: {e}")
return None
def fetch_url(self, url: str, timeout: int = 5):
"""
Fetch URL with error handling.
Args:
url (str): URL to fetch
timeout (int): Timeout in seconds
Returns:
Response: Request response or None if failed
"""
try:
return self.session.get(url, timeout=timeout)
except Exception as e:
logging.error(f"Error fetching {url}: {e}")
return None
def get_bitcoin_stats(self):
"""
Fetch Bitcoin network statistics with improved error handling and caching.
Returns:
tuple: (difficulty, network_hashrate, btc_price, block_count)
"""
urls = {
"difficulty": "https://blockchain.info/q/getdifficulty",
"hashrate": "https://blockchain.info/q/hashrate",
"ticker": "https://blockchain.info/ticker",
"blockcount": "https://blockchain.info/q/getblockcount"
}
# Use previous cached values as defaults if available
difficulty = self.cache.get("difficulty")
network_hashrate = self.cache.get("network_hashrate")
btc_price = self.cache.get("btc_price")
block_count = self.cache.get("block_count")
try:
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {key: executor.submit(self.fetch_url, url) for key, url in urls.items()}
responses = {key: futures[key].result(timeout=5) for key in futures}
# Process each response individually with error handling
if responses["difficulty"] and responses["difficulty"].ok:
try:
difficulty = float(responses["difficulty"].text)
self.cache["difficulty"] = difficulty
except (ValueError, TypeError) as e:
logging.error(f"Error parsing difficulty: {e}")
if responses["hashrate"] and responses["hashrate"].ok:
try:
network_hashrate = float(responses["hashrate"].text) * 1e9
self.cache["network_hashrate"] = network_hashrate
except (ValueError, TypeError) as e:
logging.error(f"Error parsing network hashrate: {e}")
if responses["ticker"] and responses["ticker"].ok:
try:
ticker_data = responses["ticker"].json()
btc_price = float(ticker_data.get("USD", {}).get("last", btc_price))
self.cache["btc_price"] = btc_price
except (ValueError, TypeError, json.JSONDecodeError) as e:
logging.error(f"Error parsing BTC price: {e}")
if responses["blockcount"] and responses["blockcount"].ok:
try:
block_count = int(responses["blockcount"].text)
self.cache["block_count"] = block_count
except (ValueError, TypeError) as e:
logging.error(f"Error parsing block count: {e}")
except Exception as e:
logging.error(f"Error fetching Bitcoin stats: {e}")
return difficulty, network_hashrate, btc_price, block_count

66
dockerfile Normal file
View File

@ -0,0 +1,66 @@
FROM python:3.9-slim
WORKDIR /app
# Install curl for healthcheck and other dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install dependencies first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application files
COPY *.py .
COPY config.json .
COPY setup.py .
# Create necessary directories
RUN mkdir -p static/css static/js templates logs
# Copy static files and templates
COPY static/css/*.css static/css/
COPY static/js/*.js static/js/
COPY templates/*.html templates/
# Run the setup script to ensure proper organization
RUN python setup.py
# Create a non-root user for better security
RUN adduser --disabled-password --gecos '' appuser
# Change ownership of the /app directory so appuser can write files
RUN chown -R appuser:appuser /app
# Create a directory for logs with proper permissions
RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs
# Switch to non-root user
USER appuser
# Expose the application port
EXPOSE 5000
# Set environment variables
ENV FLASK_ENV=production
ENV PYTHONUNBUFFERED=1
ENV PYTHON_UNBUFFERED=1
# Add healthcheck
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD curl -f http://localhost:5000/api/health || exit 1
# Use Gunicorn as the production WSGI server
CMD ["gunicorn", "-b", "0.0.0.0:5000", "App:app", \
"--workers=1", \
"--threads=12", \
"--timeout=600", \
"--keep-alive=5", \
"--log-level=info", \
"--access-logfile=-", \
"--error-logfile=-", \
"--log-file=-", \
"--graceful-timeout=60", \
"--worker-tmp-dir=/dev/shm"]

59
models.py Normal file
View File

@ -0,0 +1,59 @@
"""
Data models for the Bitcoin Mining Dashboard.
"""
from dataclasses import dataclass
@dataclass
class OceanData:
"""Data structure for Ocean.xyz pool mining data."""
pool_total_hashrate: float = None
pool_total_hashrate_unit: str = None
hashrate_24hr: float = None
hashrate_24hr_unit: str = None
hashrate_3hr: float = None
hashrate_3hr_unit: str = None
hashrate_10min: float = None
hashrate_10min_unit: str = None
hashrate_5min: float = None
hashrate_5min_unit: str = None
hashrate_60sec: float = None
hashrate_60sec_unit: str = None
estimated_earnings_per_day: float = None
estimated_earnings_next_block: float = None
estimated_rewards_in_window: float = None
workers_hashing: int = None
unpaid_earnings: float = None
est_time_to_payout: str = None
last_block: str = None
last_block_height: str = None
last_block_time: str = None
blocks_found: str = None
total_last_share: str = "N/A"
last_block_earnings: str = None
def convert_to_ths(value: float, unit: str) -> float:
"""
Convert any hashrate unit to TH/s equivalent.
Args:
value (float): The numerical value of the hashrate
unit (str): The unit of measurement (e.g., 'PH/s', 'EH/s', etc.)
Returns:
float: The hashrate value in TH/s
"""
unit = unit.lower()
if 'ph/s' in unit:
return value * 1000 # 1 PH/s = 1000 TH/s
elif 'eh/s' in unit:
return value * 1000000 # 1 EH/s = 1,000,000 TH/s
elif 'gh/s' in unit:
return value / 1000 # 1 TH/s = 1000 GH/s
elif 'mh/s' in unit:
return value / 1000000 # 1 TH/s = 1,000,000 MH/s
elif 'th/s' in unit:
return value
else:
# Log unexpected unit
logging.warning(f"Unexpected hashrate unit: {unit}, defaulting to treating as TH/s")
return value

21
requirements.txt Normal file
View File

@ -0,0 +1,21 @@
Flask==2.3.3
requests==2.31.0
beautifulsoup4==4.12.2
Flask-Caching==2.1.0
gunicorn==22.0.0
htmlmin==0.1.12
redis==5.0.1
APScheduler==3.10.4
psutil==5.9.5
Werkzeug==2.3.7
Jinja2==3.1.2
itsdangerous==2.1.2
MarkupSafe==2.1.3
soupsieve==2.5
tzdata==2023.3
pytz==2023.3
tzlocal==5.0.1
urllib3==2.0.7
idna==3.4
certifi==2023.7.22
six==1.16.0

379
state_manager.py Normal file
View File

@ -0,0 +1,379 @@
"""
State manager module for handling persistent state and history.
"""
import logging
import json
import time
import gc
import threading
import redis
# Global variables for arrow history, legacy hashrate history, and a log of full metrics snapshots.
arrow_history = {} # stored per second
hashrate_history = []
metrics_log = []
# Limits for data collections to prevent memory growth
MAX_HISTORY_ENTRIES = 180 # 3 hours worth at 1 min intervals
# Lock for thread safety
state_lock = threading.Lock()
class StateManager:
"""Manager for persistent state and history data."""
def __init__(self, redis_url=None):
"""
Initialize the state manager.
Args:
redis_url (str, optional): Redis URL for persistent storage
"""
self.redis_client = self._connect_to_redis(redis_url) if redis_url else None
self.STATE_KEY = "graph_state"
self.last_save_time = 0
# Load state if available
self.load_graph_state()
def _connect_to_redis(self, redis_url):
"""
Connect to Redis with retry logic.
Args:
redis_url (str): Redis URL
Returns:
redis.Redis: Redis client or None if connection failed
"""
if not redis_url:
logging.info("Redis URL not configured, using in-memory state only.")
return None
retry_count = 0
max_retries = 3
while retry_count < max_retries:
try:
client = redis.Redis.from_url(redis_url)
client.ping() # Test the connection
logging.info(f"Connected to Redis at {redis_url}")
return client
except Exception as e:
retry_count += 1
if retry_count < max_retries:
logging.warning(f"Redis connection attempt {retry_count} failed: {e}. Retrying...")
time.sleep(1) # Wait before retrying
else:
logging.error(f"Could not connect to Redis after {max_retries} attempts: {e}")
return None
def load_graph_state(self):
"""Load graph state from Redis with support for the optimized format."""
global arrow_history, hashrate_history, metrics_log
if not self.redis_client:
logging.info("Redis not available, using in-memory state.")
return
try:
# Check version to handle format changes
version = self.redis_client.get(f"{self.STATE_KEY}_version")
version = version.decode('utf-8') if version else "1.0"
state_json = self.redis_client.get(self.STATE_KEY)
if state_json:
state = json.loads(state_json)
# Handle different versions of the data format
if version == "2.0": # Optimized format
# Restore arrow_history
compact_arrow_history = state.get("arrow_history", {})
for key, values in compact_arrow_history.items():
arrow_history[key] = [
{"time": entry.get("t", ""),
"value": entry.get("v", 0),
"arrow": ""} # Default empty arrow
for entry in values
]
# Restore hashrate_history
hashrate_history = state.get("hashrate_history", [])
# Restore metrics_log
compact_metrics_log = state.get("metrics_log", [])
metrics_log = []
for entry in compact_metrics_log:
metrics_log.append({
"timestamp": entry.get("ts", ""),
"metrics": entry.get("m", {})
})
else: # Original format
arrow_history = state.get("arrow_history", {})
hashrate_history = state.get("hashrate_history", [])
metrics_log = state.get("metrics_log", [])
logging.info(f"Loaded graph state from Redis (format version {version}).")
else:
logging.info("No previous graph state found in Redis.")
except Exception as e:
logging.error(f"Error loading graph state from Redis: {e}")
def save_graph_state(self):
"""Save graph state to Redis with optimized frequency, pruning, and data reduction."""
if not self.redis_client:
logging.info("Redis not available, skipping state save.")
return
# Check if we've saved recently to avoid too frequent saves
# Only save at most once every 5 minutes
current_time = time.time()
if hasattr(self, 'last_save_time') and current_time - self.last_save_time < 300: # 300 seconds = 5 minutes
logging.debug("Skipping Redis save - last save was less than 5 minutes ago")
return
# Update the last save time
self.last_save_time = current_time
# Prune data first to reduce volume
self.prune_old_data()
# Create compact versions of the data structures for Redis storage
try:
# 1. Create compact arrow_history with minimal data
compact_arrow_history = {}
for key, values in arrow_history.items():
if isinstance(values, list) and values:
# Only store recent history (last 2 hours)
recent_values = values[-120:] if len(values) > 120 else values
# Use shorter field names and remove unnecessary fields
compact_arrow_history[key] = [
{"t": entry["time"], "v": entry["value"]}
for entry in recent_values
]
# 2. Only keep essential hashrate_history
compact_hashrate_history = hashrate_history[-60:] if len(hashrate_history) > 60 else hashrate_history
# 3. Only keep recent metrics_log entries (last 30 minutes)
# This is typically the largest data structure
compact_metrics_log = []
if metrics_log:
# Keep only last 30 entries (30 minutes assuming 1-minute updates)
recent_logs = metrics_log[-30:]
for entry in recent_logs:
# Only keep necessary fields from each metrics entry
if "metrics" in entry and "timestamp" in entry:
metrics_copy = {}
original_metrics = entry["metrics"]
# Only copy the most important metrics for historical tracking
essential_keys = [
"hashrate_60sec", "hashrate_24hr", "btc_price",
"workers_hashing", "unpaid_earnings", "difficulty",
"network_hashrate", "daily_profit_usd"
]
for key in essential_keys:
if key in original_metrics:
metrics_copy[key] = original_metrics[key]
# Skip arrow_history within metrics as we already stored it separately
compact_metrics_log.append({
"ts": entry["timestamp"],
"m": metrics_copy
})
# Create the final state object
state = {
"arrow_history": compact_arrow_history,
"hashrate_history": compact_hashrate_history,
"metrics_log": compact_metrics_log
}
# Convert to JSON once to reuse and measure size
state_json = json.dumps(state)
data_size_kb = len(state_json) / 1024
# Log data size for monitoring
logging.info(f"Saving graph state to Redis: {data_size_kb:.2f} KB (optimized format)")
# Only save if data size is reasonable (adjust threshold as needed)
if data_size_kb > 2000: # 2MB warning threshold (reduced from 5MB)
logging.warning(f"Redis save data size is still large: {data_size_kb:.2f} KB")
# Store version info to handle future format changes
self.redis_client.set(f"{self.STATE_KEY}_version", "2.0")
self.redis_client.set(self.STATE_KEY, state_json)
logging.info(f"Successfully saved graph state to Redis ({data_size_kb:.2f} KB)")
except Exception as e:
logging.error(f"Error saving graph state to Redis: {e}")
def prune_old_data(self):
"""Remove old data to prevent memory growth with optimized strategy."""
global arrow_history, metrics_log
with state_lock:
# Prune arrow_history with more sophisticated approach
for key in arrow_history:
if isinstance(arrow_history[key], list):
if len(arrow_history[key]) > MAX_HISTORY_ENTRIES:
# For most recent data (last hour) - keep every point
recent_data = arrow_history[key][-60:]
# For older data, reduce resolution by keeping every other point
older_data = arrow_history[key][:-60]
if len(older_data) > 0:
sparse_older_data = [older_data[i] for i in range(0, len(older_data), 2)]
arrow_history[key] = sparse_older_data + recent_data
else:
arrow_history[key] = recent_data
logging.info(f"Pruned {key} history from {len(arrow_history[key])} to {len(sparse_older_data + recent_data) if older_data else len(recent_data)} entries")
# Prune metrics_log more aggressively
if len(metrics_log) > MAX_HISTORY_ENTRIES:
# Keep most recent entries at full resolution
recent_logs = metrics_log[-60:]
# Reduce resolution of older entries
older_logs = metrics_log[:-60]
if len(older_logs) > 0:
sparse_older_logs = [older_logs[i] for i in range(0, len(older_logs), 3)] # Keep every 3rd entry
metrics_log = sparse_older_logs + recent_logs
logging.info(f"Pruned metrics log from {len(metrics_log)} to {len(sparse_older_logs + recent_logs)} entries")
# Free memory more aggressively
gc.collect()
def persist_critical_state(self, cached_metrics, scheduler_last_successful_run, last_metrics_update_time):
"""
Store critical state in Redis for recovery after worker restarts.
Args:
cached_metrics (dict): Current metrics
scheduler_last_successful_run (float): Timestamp of last successful scheduler run
last_metrics_update_time (float): Timestamp of last metrics update
"""
if not self.redis_client:
return
try:
# Only persist if we have valid data
if cached_metrics and cached_metrics.get("server_timestamp"):
state = {
"cached_metrics_timestamp": cached_metrics.get("server_timestamp"),
"last_successful_run": scheduler_last_successful_run,
"last_update_time": last_metrics_update_time
}
self.redis_client.set("critical_state", json.dumps(state))
logging.info(f"Persisted critical state to Redis, timestamp: {cached_metrics.get('server_timestamp')}")
except Exception as e:
logging.error(f"Error persisting critical state: {e}")
def load_critical_state(self):
"""
Recover critical state variables after a worker restart.
Returns:
tuple: (last_successful_run, last_update_time)
"""
if not self.redis_client:
return None, None
try:
state_json = self.redis_client.get("critical_state")
if state_json:
state = json.loads(state_json.decode('utf-8'))
last_successful_run = state.get("last_successful_run")
last_update_time = state.get("last_update_time")
logging.info(f"Loaded critical state from Redis, last run: {last_successful_run}")
# We don't restore cached_metrics itself, as we'll fetch fresh data
# Just note that we have state to recover from
logging.info(f"Last metrics timestamp from Redis: {state.get('cached_metrics_timestamp')}")
return last_successful_run, last_update_time
except Exception as e:
logging.error(f"Error loading critical state: {e}")
return None, None
def update_metrics_history(self, metrics):
"""
Update history collections with new metrics data.
Args:
metrics (dict): New metrics data
"""
global arrow_history, hashrate_history, metrics_log
# Skip if metrics is None
if not metrics:
return
arrow_keys = [
"pool_total_hashrate", "hashrate_24hr", "hashrate_3hr", "hashrate_10min",
"hashrate_60sec", "block_number", "btc_price", "network_hashrate",
"difficulty", "daily_revenue", "daily_power_cost", "daily_profit_usd",
"monthly_profit_usd", "daily_mined_sats", "monthly_mined_sats", "unpaid_earnings",
"estimated_earnings_per_day_sats", "estimated_earnings_next_block_sats", "estimated_rewards_in_window_sats",
"workers_hashing"
]
# --- Bucket by second (Los Angeles Time) with thread safety ---
from datetime import datetime
from zoneinfo import ZoneInfo
current_second = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%H:%M:%S")
with state_lock:
for key in arrow_keys:
if metrics.get(key) is not None:
current_val = metrics[key]
arrow = ""
if key in arrow_history and arrow_history[key]:
try:
previous_val = arrow_history[key][-1]["value"]
if current_val > previous_val:
arrow = ""
elif current_val < previous_val:
arrow = ""
except Exception as e:
logging.error(f"Error calculating arrow for {key}: {e}")
if key not in arrow_history:
arrow_history[key] = []
if not arrow_history[key] or arrow_history[key][-1]["time"] != current_second:
arrow_history[key].append({
"time": current_second,
"value": current_val,
"arrow": arrow
})
else:
arrow_history[key][-1]["value"] = current_val
arrow_history[key][-1]["arrow"] = arrow
# Cap history to three hours worth (180 entries)
if len(arrow_history[key]) > MAX_HISTORY_ENTRIES:
arrow_history[key] = arrow_history[key][-MAX_HISTORY_ENTRIES:]
# --- Aggregate arrow_history by minute for the graph ---
aggregated_history = {}
for key, entries in arrow_history.items():
minute_groups = {}
for entry in entries:
minute = entry["time"][:5] # extract HH:MM
minute_groups[minute] = entry # take last entry for that minute
aggregated_history[key] = list(minute_groups.values())
metrics["arrow_history"] = aggregated_history
metrics["history"] = hashrate_history
entry = {"timestamp": datetime.now().isoformat(), "metrics": metrics}
metrics_log.append(entry)
# Cap the metrics log to three hours worth (180 entries)
if len(metrics_log) > MAX_HISTORY_ENTRIES:
metrics_log = metrics_log[-MAX_HISTORY_ENTRIES:]

215
static/css/boot.css Normal file
View File

@ -0,0 +1,215 @@
/* Base Styles with a subtle radial background for extra depth */
body {
background: linear-gradient(135deg, #121212, #000000);
color: #f7931a;
font-family: 'VT323', monospace;
font-size: 20px;
line-height: 1.4;
margin: 0;
padding: 10px;
overflow-x: hidden;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.4);
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
/* CRT Screen Effect */
body::before {
content: " ";
display: block;
position: fixed;
top: 0; left: 0; bottom: 0; right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
z-index: 2;
opacity: 0.15;
}
/* Flicker Animation */
@keyframes flicker {
0% { opacity: 0.97; }
5% { opacity: 0.95; }
10% { opacity: 0.97; }
15% { opacity: 0.94; }
20% { opacity: 0.98; }
50% { opacity: 0.95; }
80% { opacity: 0.96; }
90% { opacity: 0.94; }
100% { opacity: 0.98; }
}
/* Terminal Window with scrolling enabled */
#terminal {
width: 100%;
max-width: 900px;
margin: 0 auto;
white-space: pre-wrap;
word-break: break-word;
animation: flicker 4s infinite;
height: 400px;
overflow-y: auto;
position: relative;
flex: 1;
}
#terminal-content {
position: absolute;
bottom: 0;
width: 100%;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background-color: #f7931a;
animation: blink 1s step-end infinite;
vertical-align: middle;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Neon-inspired color classes */
.green {
color: #39ff14 !important;
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14;
}
.blue {
color: #00dfff !important;
text-shadow: 0 0 10px #00dfff, 0 0 20px #00dfff;
}
.yellow {
color: #ffd700 !important;
text-shadow: 0 0 8px #ffd700, 0 0 16px #ffd700;
}
.white {
color: #ffffff !important;
text-shadow: 0 0 8px #ffffff, 0 0 16px #ffffff;
}
.red {
color: #ff2d2d !important;
text-shadow: 0 0 10px #ff2d2d, 0 0 20px #ff2d2d;
}
.magenta {
color: #ff2d95 !important;
text-shadow: 0 0 10px #ff2d95, 0 0 20px #ff2d95;
}
/* Bitcoin Logo styling with extra neon border */
#bitcoin-logo {
display: block;
visibility: hidden;
text-align: center;
margin: 10px auto;
font-size: 10px;
line-height: 1;
color: #f7931a;
text-shadow: 0 0 10px rgba(247, 147, 26, 0.8);
white-space: pre;
width: 260px;
padding: 10px;
border: 2px solid #f7931a;
background-color: #0a0a0a;
box-shadow: 0 0 15px rgba(247, 147, 26, 0.5);
font-family: monospace;
opacity: 0;
transition: opacity 1s ease;
}
/* Skip Button */
#skip-button {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #f7931a;
color: #000;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-family: 'VT323', monospace;
font-size: 16px;
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
transition: all 0.2s ease;
}
#skip-button:hover {
background-color: #ffa32e;
box-shadow: 0 0 12px rgba(247, 147, 26, 0.7);
}
/* Prompt Styling */
#prompt-container {
display: none;
white-space: nowrap;
}
#prompt-text {
color: #f7931a;
margin-right: 5px;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
display: inline;
}
#user-input {
background: transparent;
border: none;
color: #f7931a;
font-family: 'VT323', monospace;
font-size: 20px;
caret-color: transparent;
outline: none;
width: 20px;
padding: 0;
margin: 0;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
display: inline-block;
vertical-align: top;
}
.prompt-cursor {
display: inline-block;
width: 10px;
height: 16px;
background-color: #f7931a;
animation: blink 1s step-end infinite;
vertical-align: middle;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
position: relative;
top: 1px;
margin-left: -2px;
}
/* Mobile Responsiveness */
@media (max-width: 600px) {
body { font-size: 14px; padding: 10px; }
#terminal { margin: 0; }
}
/* Loading and Debug Info */
#loading-message {
text-align: center;
margin-bottom: 10px;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
}
#debug-info {
position: fixed;
bottom: 10px;
left: 10px;
color: #666;
font-size: 12px;
z-index: 100;
}

421
static/css/common.css Normal file
View File

@ -0,0 +1,421 @@
/* Common styling elements shared across all pages */
:root {
--bg-color: #0a0a0a;
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
--primary-color: #f7931a;
--accent-color: #00ffff;
--text-color: #ffffff;
--card-padding: 0.5rem;
--text-size-base: 16px;
--terminal-font: 'VT323', monospace;
--header-font: 'Orbitron', sans-serif;
}
@media (min-width: 768px) {
:root {
--card-padding: 0.75rem;
--text-size-base: 18px;
}
}
/* CRT Screen Effect */
body::before {
content: " ";
display: block;
position: fixed;
top: 0; left: 0; bottom: 0; right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
z-index: 2;
opacity: 0.15;
}
/* Flicker Animation */
@keyframes flicker {
0% { opacity: 0.97; }
5% { opacity: 0.95; }
10% { opacity: 0.97; }
15% { opacity: 0.94; }
20% { opacity: 0.98; }
50% { opacity: 0.95; }
80% { opacity: 0.96; }
90% { opacity: 0.94; }
100% { opacity: 0.98; }
}
body {
background: var(--bg-gradient);
color: var(--text-color);
padding-top: 0.5rem;
font-size: var(--text-size-base);
font-family: var(--terminal-font);
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
}
h1 {
font-size: 24px;
font-weight: bold;
color: var(--primary-color);
font-family: var(--header-font);
letter-spacing: 1px;
text-shadow: 0 0 10px var(--primary-color);
animation: flicker 4s infinite;
}
@media (min-width: 768px) {
h1 {
font-size: 26px;
}
}
/* Navigation links */
.navigation-links {
display: flex;
justify-content: center;
margin-top: 10px;
margin-bottom: 15px;
}
.nav-link {
padding: 5px 15px;
margin: 0 10px;
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--primary-color);
text-decoration: none;
font-family: var(--terminal-font);
transition: all 0.3s ease;
}
.nav-link:hover {
background-color: var(--primary-color);
color: var(--bg-color);
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
.nav-link.active {
background-color: var(--primary-color);
color: var(--bg-color);
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
/* Top right link */
#topRightLink {
position: absolute;
top: 10px;
right: 10px;
color: grey;
text-decoration: none;
font-size: 0.9rem;
text-shadow: 0 0 5px grey;
}
/* Card styles */
.card,
.card-header,
.card-body,
.card-footer {
border-radius: 0 !important;
}
/* Enhanced card with scanlines */
.card {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
margin-bottom: 0.5rem;
padding: var(--card-padding);
flex: 1;
position: relative;
overflow: hidden;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
}
/* Scanline effect for cards */
.card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.05),
rgba(0, 0, 0, 0.05) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1;
}
.card-header {
background-color: #000;
color: var(--primary-color);
font-weight: bold;
padding: 0.3rem 0.5rem;
font-size: 1.1rem;
border-bottom: 1px solid var(--primary-color);
text-shadow: 0 0 5px var(--primary-color);
animation: flicker 4s infinite;
font-family: var(--header-font);
}
.card-body hr {
border-top: 1px solid var(--primary-color);
margin: 0.25rem 0;
}
/* Connection status indicator */
#connectionStatus {
display: none;
position: fixed;
top: 10px;
right: 10px;
background: rgba(255,0,0,0.7);
color: white;
padding: 10px;
border-radius: 5px;
z-index: 9999;
font-size: 0.9rem;
text-shadow: 0 0 5px rgba(255, 0, 0, 0.8);
box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
}
/* Last Updated text with subtle animation */
#lastUpdated {
animation: flicker 5s infinite;
text-align: center;
}
/* Cursor blink for terminal feel */
#terminal-cursor {
display: inline-block;
width: 10px;
height: 16px;
background-color: #f7931a;
margin-left: 2px;
animation: blink 1s step-end infinite;
vertical-align: middle;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Container */
.container-fluid {
max-width: 1200px;
margin: 0 auto;
padding-left: 1rem;
padding-right: 1rem;
position: relative;
}
/* Status indicators */
.online-dot {
display: inline-block;
width: 8px;
height: 8px;
background: #32CD32;
border-radius: 50%;
margin-left: 0.5em;
position: relative;
top: -2px;
animation: glow 1s infinite;
box-shadow: 0 0 10px #32CD32, 0 0 20px #32CD32;
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 10px #32CD32, 0 0 15px #32CD32; }
50% { box-shadow: 0 0 15px #32CD32, 0 0 25px #32CD32; }
}
.offline-dot {
display: inline-block;
width: 8px;
height: 8px;
background: red;
border-radius: 50%;
margin-left: 0.5em;
animation: glowRed 1s infinite;
box-shadow: 0 0 10px red, 0 0 20px red;
}
@keyframes glowRed {
0%, 100% { box-shadow: 0 0 10px red, 0 0 15px red; }
50% { box-shadow: 0 0 15px red, 0 0 25px red; }
}
/* Color utility classes */
.green-glow, .status-green {
color: #39ff14 !important;
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14;
}
.red-glow, .status-red {
color: #ff2d2d !important;
text-shadow: 0 0 10px #ff2d2d, 0 0 20px #ff2d2d;
}
.yellow-glow {
color: #ffd700 !important;
text-shadow: 0 0 6px #ffd700, 0 0 12px #ffd700;
}
.blue-glow {
color: #00dfff !important;
text-shadow: 0 0 6px #00dfff, 0 0 12px #00dfff;
}
.white-glow {
color: #ffffff !important;
text-shadow: 0 0 6px #ffffff, 0 0 12px #ffffff;
}
/* Basic color classes for backward compatibility */
.green {
color: #39ff14 !important;
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14;
}
.blue {
color: #00dfff !important;
text-shadow: 0 0 10px #00dfff, 0 0 20px #00dfff;
}
.yellow {
color: #ffd700 !important;
text-shadow: 0 0 8px #ffd700, 0 0 16px #ffd700;
}
.white {
color: #ffffff !important;
text-shadow: 0 0 8px #ffffff, 0 0 16px #ffffff;
}
.red {
color: #ff2d2d !important;
text-shadow: 0 0 10px #ff2d2d, 0 0 20px #ff2d2d;
}
.magenta {
color: #ff2d95 !important;
text-shadow: 0 0 10px #ff2d95, 0 0 20px #ff2d95;
}
/* Bitcoin Progress Bar Styles */
.bitcoin-progress-container {
width: 100%;
max-width: 300px;
height: 20px;
background-color: #111;
border: 1px solid var(--primary-color);
border-radius: 0;
margin: 0.5rem auto;
position: relative;
overflow: hidden;
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
align-self: center;
}
.bitcoin-progress-inner {
height: 100%;
width: 0;
background: linear-gradient(90deg, #f7931a, #ffa500);
border-radius: 0;
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.bitcoin-progress-inner::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.1) 40%);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.bitcoin-icons {
position: absolute;
top: 50%;
left: 0;
width: 100%;
transform: translateY(-50%);
display: flex;
justify-content: space-around;
font-size: 12px;
color: rgba(0, 0, 0, 0.7);
}
.glow-effect {
box-shadow: 0 0 15px #f7931a, 0 0 25px #f7931a;
animation: pulse 1s infinite;
}
/* Extra styling for when server update is late */
.waiting-for-update {
animation: waitingPulse 2s infinite !important;
}
@keyframes waitingPulse {
0%, 100% { box-shadow: 0 0 10px #f7931a, 0 0 15px #f7931a; opacity: 0.8; }
50% { box-shadow: 0 0 20px #f7931a, 0 0 35px #f7931a; opacity: 1; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
#progress-text {
font-size: 1rem;
color: var(--primary-color);
margin-top: 0.3rem;
text-shadow: 0 0 5px var(--primary-color);
text-align: center;
width: 100%;
}
/* Mobile responsiveness */
@media (max-width: 576px) {
.container-fluid {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.card-body {
padding: 0.5rem;
}
h1 {
font-size: 22px;
}
.card-header {
font-size: 1rem;
}
#topRightLink {
position: static;
display: block;
text-align: right;
margin-bottom: 0.5rem;
}
}

190
static/css/dashboard.css Normal file
View File

@ -0,0 +1,190 @@
/* Specific styles for the main dashboard */
#graphContainer {
background-color: #000;
padding: 0.5rem;
margin-bottom: 1rem;
height: 230px;
border: 1px solid var(--primary-color);
box-shadow: 0 0 10px rgba(247, 147, 26, 0.2);
position: relative;
}
/* Add scanline effect to graph */
#graphContainer::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1;
}
/* Override for Payout & Misc card */
#payoutMiscCard {
margin-bottom: 0.5rem;
}
/* Row equal height for card alignment */
.row.equal-height {
display: flex;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.row.equal-height > [class*="col-"] {
display: flex;
margin-bottom: 0.5rem;
}
.row.equal-height > [class*="col-"] .card {
width: 100%;
}
/* Arrow indicator styles */
.arrow {
display: inline-block;
font-weight: bold;
margin-left: 0.5rem;
}
/* Bounce animations for indicators */
@keyframes bounceUp {
0% { transform: translateY(0); }
25% { transform: translateY(-2px); }
50% { transform: translateY(0); }
75% { transform: translateY(-2px); }
100% { transform: translateY(0); }
}
@keyframes bounceDown {
0% { transform: translateY(0); }
25% { transform: translateY(2px); }
50% { transform: translateY(0); }
75% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
.bounce-up {
animation: bounceUp 1s infinite;
}
.bounce-down {
animation: bounceDown 1s infinite;
}
.chevron {
font-size: 0.8rem;
position: relative;
top: 3px;
}
/* Refresh timer container */
#refreshUptime {
text-align: center;
margin-top: 0.5rem;
}
#refreshContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
}
#uptimeTimer strong {
font-weight: bold;
}
#uptimeTimer {
margin-top: 0;
}
/* Metric styling by category */
.metric-value {
color: var(--text-color);
font-weight: bold;
text-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
}
/* Yellow color family (BTC price, sats metrics, time to payout) */
#btc_price,
#daily_mined_sats,
#monthly_mined_sats,
#estimated_earnings_per_day_sats,
#estimated_earnings_next_block_sats,
#estimated_rewards_in_window_sats,
#est_time_to_payout {
color: #ffd700;
text-shadow: 0 0 6px rgba(255, 215, 0, 0.6);
}
/* Green color family (profits, earnings) */
#unpaid_earnings,
#daily_revenue,
#daily_profit_usd,
#monthly_profit_usd {
color: #32CD32;
text-shadow: 0 0 6px rgba(50, 205, 50, 0.6);
}
/* Red color family (costs) */
#daily_power_cost {
color: #ff5555 !important;
text-shadow: 0 0 6px rgba(255, 85, 85, 0.6);
}
/* White metrics (general stats) */
.metric-value.white,
#block_number,
#network_hashrate,
#difficulty,
#workers_hashing,
#last_share,
#blocks_found,
#last_block_height {
color: #ffffff;
text-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
}
/* Blue metrics (time data) */
#last_block_time {
color: #00dfff;
text-shadow: 0 0 6px rgba(0, 223, 255, 0.6);
}
.card-body strong {
color: var(--primary-color);
margin-right: 0.25rem;
text-shadow: 0 0 2px var(--primary-color);
}
.card-body p {
margin: 0.25rem 0;
line-height: 1.2;
}
/* Hidden Congrats Message */
#congratsMessage {
display: none;
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: #f7931a;
color: #000;
padding: 10px;
border-radius: 5px;
box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);
}

138
static/css/error.css Normal file
View File

@ -0,0 +1,138 @@
:root {
--bg-color: #0a0a0a;
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
--primary-color: #f7931a;
--text-color: white;
--terminal-font: 'VT323', monospace;
--header-font: 'Orbitron', sans-serif;
}
/* CRT Screen Effect */
body::before {
content: " ";
display: block;
position: fixed;
top: 0; left: 0; bottom: 0; right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
z-index: 2;
opacity: 0.15;
}
/* Flicker Animation */
@keyframes flicker {
0% { opacity: 0.97; }
5% { opacity: 0.95; }
10% { opacity: 0.97; }
15% { opacity: 0.94; }
20% { opacity: 0.98; }
50% { opacity: 0.95; }
80% { opacity: 0.96; }
90% { opacity: 0.94; }
100% { opacity: 0.98; }
}
body {
background: var(--bg-gradient);
color: var(--text-color);
padding-top: 50px;
font-family: var(--terminal-font);
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
}
a.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: black;
margin-top: 20px;
font-family: var(--header-font);
text-shadow: none;
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
transition: all 0.3s ease;
}
a.btn-primary:hover {
background-color: #ffa64d;
box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);
}
/* Enhanced error container with scanlines */
.error-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
padding: 2rem;
border: 1px solid var(--primary-color);
border-radius: 0;
background-color: rgba(0, 0, 0, 0.3);
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
position: relative;
overflow: hidden;
animation: flicker 4s infinite;
}
/* Scanline effect for error container */
.error-container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1;
}
h1 {
color: var(--primary-color);
margin-bottom: 1rem;
font-family: var(--header-font);
font-weight: bold;
text-shadow: 0 0 10px var(--primary-color);
position: relative;
z-index: 2;
}
p {
margin-bottom: 1.5rem;
font-size: 1.5rem;
position: relative;
z-index: 2;
color: #ff5555;
text-shadow: 0 0 8px rgba(255, 85, 85, 0.6);
}
/* Cursor blink for terminal feel */
.terminal-cursor {
display: inline-block;
width: 10px;
height: 20px;
background-color: #f7931a;
margin-left: 2px;
animation: blink 1s step-end infinite;
vertical-align: middle;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Error code styling */
.error-code {
font-family: var(--terminal-font);
font-size: 1.2rem;
color: #00dfff;
text-shadow: 0 0 10px #00dfff, 0 0 20px #00dfff;
margin-bottom: 1rem;
}

View File

@ -0,0 +1,369 @@
/* Retro Floating Refresh Bar Styles */
:root {
--terminal-bg: #000000;
--terminal-border: #f7931a;
--terminal-text: #f7931a;
--terminal-glow: rgba(247, 147, 26, 0.7);
--terminal-width: 300px;
}
/* Adjust width for desktop */
@media (min-width: 768px) {
:root {
--terminal-width: 340px;
}
}
/* Remove the existing refresh timer container styles */
#refreshUptime {
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
/* Add padding to the bottom of the page to prevent floating bar from covering content
body {
padding-bottom: 100px !important;
}
*/
/* Floating Retro Terminal Container */
#retro-terminal-bar {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: var(--terminal-width);
background-color: var(--terminal-bg);
border: 2px solid var(--terminal-border);
/* box-shadow: 0 0 15px var(--terminal-glow); */
z-index: 1000;
font-family: 'VT323', monospace;
overflow: hidden;
padding: 5px;
}
/* Desktop positioning (bottom right) */
@media (min-width: 768px) {
#retro-terminal-bar {
left: auto;
right: 20px;
transform: none;
}
}
/* Terminal header with control buttons */
/* Update the terminal title to match card headers */
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
border-bottom: 1px solid var(--terminal-border);
padding-bottom: 3px;
background-color: #000; /* Match card header background */
}
.terminal-title {
color: var(--primary-color);
font-weight: bold;
font-size: 1.1rem; /* Match card header font size */
border-bottom: none;
text-shadow: 0 0 5px var(--primary-color);
animation: flicker 4s infinite; /* Add flicker animation from card headers */
font-family: var(--header-font); /* Use the same font variable */
padding: 0.3rem 0; /* Match card header padding */
letter-spacing: 1px;
}
/* Make sure we're using the flicker animation defined in the main CSS */
@keyframes flicker {
0% { opacity: 0.97; }
5% { opacity: 0.95; }
10% { opacity: 0.97; }
15% { opacity: 0.94; }
20% { opacity: 0.98; }
50% { opacity: 0.95; }
80% { opacity: 0.96; }
90% { opacity: 0.94; }
100% { opacity: 0.98; }
}
.terminal-controls {
display: flex;
gap: 5px;
}
.terminal-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #555;
transition: background-color 0.3s;
}
.terminal-dot:hover {
background-color: #999;
cursor: pointer;
}
.terminal-dot.minimize:hover {
background-color: #ffcc00;
}
.terminal-dot.close:hover {
background-color: #ff3b30;
}
/* Terminal content area */
.terminal-content {
position: relative;
color: #ffffff;
padding: 5px 0;
}
/* Scanline effect for authentic CRT look */
.terminal-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1;
animation: flicker 0.15s infinite;
}
@keyframes flicker {
0% { opacity: 1.0; }
50% { opacity: 0.98; }
100% { opacity: 1.0; }
}
/* Enhanced Progress Bar with tick marks */
#retro-terminal-bar .bitcoin-progress-container {
width: 100%;
height: 20px;
background-color: #111;
border: 1px solid var(--terminal-border);
margin-bottom: 10px;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.8);
}
/* Tick marks on progress bar */
.progress-ticks {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
padding: 0 5px;
color: rgba(255, 255, 255, 0.6);
font-size: 10px;
pointer-events: none;
z-index: 3;
}
.progress-ticks span {
display: flex;
align-items: flex-end;
height: 100%;
padding-bottom: 2px;
}
.tick-mark {
position: absolute;
top: 0;
width: 1px;
height: 5px;
background-color: rgba(255, 255, 255, 0.4);
}
.tick-mark.major {
height: 8px;
background-color: rgba(255, 255, 255, 0.6);
}
/* The actual progress bar */
#retro-terminal-bar #bitcoin-progress-inner {
height: 100%;
width: 0;
background: linear-gradient(90deg, #f7931a, #ffa500);
position: relative;
transition: width 1s linear;
}
/* Position the original inner container correctly */
#retro-terminal-bar #refreshContainer {
display: block;
width: 100%;
}
/* Blinking scan line animation */
.scan-line {
position: absolute;
height: 2px;
width: 100%;
background-color: rgba(255, 255, 255, 0.7);
animation: scan 3s linear infinite;
box-shadow: 0 0 8px 1px rgba(255, 255, 255, 0.5);
z-index: 2;
}
@keyframes scan {
0% { top: -2px; }
100% { top: 22px; }
}
/* Text styling */
#retro-terminal-bar #progress-text {
font-size: 16px;
color: var(--terminal-text);
text-shadow: 0 0 5px var(--terminal-text);
margin-top: 5px;
text-align: center;
position: relative;
z-index: 2;
}
#retro-terminal-bar #uptimeTimer {
font-size: 16px;
color: var(--terminal-text);
text-shadow: 0 0 5px var(--terminal-text);
text-align: center;
position: relative;
z-index: 2;
border-top: 1px solid rgba(247, 147, 26, 0.3);
padding-top: 5px;
margin-top: 5px;
}
/* Terminal cursor */
#retro-terminal-bar #terminal-cursor {
display: inline-block;
width: 8px;
height: 14px;
background-color: var(--terminal-text);
margin-left: 2px;
animation: blink 1s step-end infinite;
box-shadow: 0 0 8px var(--terminal-text);
}
/* Glowing effect during the last few seconds */
#retro-terminal-bar #bitcoin-progress-inner.glow-effect {
box-shadow: 0 0 15px #f7931a, 0 0 25px #f7931a;
}
#retro-terminal-bar .waiting-for-update {
animation: waitingPulse 2s infinite !important;
}
@keyframes waitingPulse {
0%, 100% { box-shadow: 0 0 10px #f7931a, 0 0 15px #f7931a; opacity: 0.8; }
50% { box-shadow: 0 0 20px #f7931a, 0 0 35px #f7931a; opacity: 1; }
}
/* Status indicators */
.status-indicators {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 12px;
color: #aaa;
}
.status-indicator {
display: flex;
align-items: center;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-dot.connected {
background-color: #32CD32;
box-shadow: 0 0 5px #32CD32;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 0.8; }
50% { opacity: 1; }
100% { opacity: 0.8; }
}
/* Collapse/expand functionality */
#retro-terminal-bar.collapsed .terminal-content {
display: none;
}
#retro-terminal-bar.collapsed {
width: 180px;
}
/* On desktop, move the collapsed bar to bottom right */
@media (min-width: 768px) {
#retro-terminal-bar.collapsed {
right: 20px;
transform: none;
}
}
/* Show button */
#show-terminal-button {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 1000;
background-color: #f7931a;
color: #000;
border: none;
padding: 8px 12px;
cursor: pointer;
font-family: 'VT323', monospace;
font-size: 14px;
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
#show-terminal-button:hover {
background-color: #ffaa33;
}
/* Mobile responsiveness */
@media (max-width: 576px) {
#retro-terminal-bar {
width: 280px;
bottom: 10px;
}
.terminal-title {
font-size: 14px;
}
.terminal-dot {
width: 6px;
height: 6px;
}
#show-terminal-button {
padding: 6px 10px;
font-size: 12px;
}
}

333
static/css/workers.css Normal file
View File

@ -0,0 +1,333 @@
/* Styles specific to the workers page */
/* Search and filter controls */
.controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.search-box {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--text-color);
padding: 5px 10px;
font-family: var(--terminal-font);
min-width: 200px;
}
.search-box:focus {
outline: none;
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
}
.filter-button {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 5px 10px;
font-family: var(--terminal-font);
cursor: pointer;
}
.filter-button.active {
background-color: var(--primary-color);
color: var(--bg-color);
}
/* Worker grid for worker cards */
.worker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
margin-top: 10px;
}
/* Worker card styles */
.worker-card {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
position: relative;
overflow: hidden;
padding: 10px;
height: 100%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.worker-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.05),
rgba(0, 0, 0, 0.05) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1;
}
.worker-card-online {
border-color: #32CD32;
box-shadow: 0 0 8px rgba(50, 205, 50, 0.4);
}
.worker-card-offline {
border-color: #ff5555;
box-shadow: 0 0 8px rgba(255, 85, 85, 0.4);
}
.worker-name {
color: var(--primary-color);
font-weight: bold;
font-size: 1.2rem;
text-shadow: 0 0 5px var(--primary-color);
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 2;
position: relative;
}
.worker-stats {
margin-top: 8px;
font-size: 0.9rem;
z-index: 2;
position: relative;
}
.worker-stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.worker-stats-label {
color: #aaa;
}
.hashrate-bar {
height: 4px;
background: linear-gradient(90deg, #1137F5, #39ff14);
margin-top: 4px;
margin-bottom: 8px;
position: relative;
z-index: 2;
}
/* Worker badge */
.worker-type {
position: absolute;
top: 10px;
right: 10px;
font-size: 0.7rem;
background-color: rgba(0, 0, 0, 0.6);
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 1px 5px;
z-index: 2;
}
/* Status badges */
.status-badge {
display: inline-block;
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 3px;
z-index: 2;
position: relative;
}
.status-badge-online {
background-color: rgba(50, 205, 50, 0.2);
border: 1px solid #32CD32;
color: #32CD32;
text-shadow: 0 0 5px rgba(50, 205, 50, 0.8);
}
.status-badge-offline {
background-color: rgba(255, 85, 85, 0.2);
border: 1px solid #ff5555;
color: #ff5555;
text-shadow: 0 0 5px rgba(255, 85, 85, 0.8);
}
/* Stats bars */
.stats-bar-container {
width: 100%;
height: 4px;
background-color: rgba(255, 255, 255, 0.1);
margin-top: 2px;
margin-bottom: 5px;
position: relative;
z-index: 2;
}
.stats-bar {
height: 100%;
background: linear-gradient(90deg, #1137F5, #39ff14);
}
/* Summary stats in the header */
.summary-stats {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
gap: 15px;
margin: 15px 0;
}
.summary-stat {
text-align: center;
min-width: 120px;
}
.summary-stat-value {
font-size: 1.6rem;
font-weight: bold;
margin-bottom: 5px;
}
.summary-stat-label {
font-size: 0.9rem;
color: #aaa;
}
/* Worker count ring */
.worker-ring {
width: 90px;
height: 90px;
border-radius: 50%;
position: relative;
margin: 0 auto;
background: conic-gradient(
#32CD32 0% calc(var(--online-percent) * 100%),
#ff5555 calc(var(--online-percent) * 100%) 100%
);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
}
.worker-ring-inner {
width: 70px;
height: 70px;
border-radius: 50%;
background-color: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: bold;
color: var(--text-color);
}
/* Mini hashrate chart */
.mini-chart {
height: 40px;
width: 100%;
margin-top: 5px;
position: relative;
z-index: 2;
}
.loading-fade {
opacity: 0.6;
transition: opacity 0.3s ease;
}
/* Mobile responsiveness */
@media (max-width: 576px) {
.controls-bar {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
.filter-buttons {
display: flex;
justify-content: space-between;
}
.worker-grid {
grid-template-columns: 1fr;
}
.summary-stats {
flex-direction: column;
align-items: center;
}
.summary-stat {
width: 100%;
}
}
@media (max-width: 768px) {
/* Fix for "Made by" link collision with title */
#topRightLink {
position: static !important;
display: block !important;
text-align: right !important;
margin-bottom: 0.5rem !important;
margin-top: 0 !important;
font-size: 0.8rem !important;
}
/* Adjust heading for better mobile display */
h1 {
font-size: 20px !important;
line-height: 1.2 !important;
margin-top: 0.5rem !important;
padding-top: 0 !important;
}
/* Improve container padding for mobile */
.container-fluid {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
/* Ensure top section has appropriate spacing */
.row.mb-3 {
margin-top: 0.5rem !important;
}
}
/* Add a more aggressive breakpoint for very small screens */
@media (max-width: 380px) {
#topRightLink {
margin-bottom: 0.75rem !important;
font-size: 0.7rem !important;
}
h1 {
font-size: 18px !important;
margin-bottom: 0.5rem !important;
}
/* Further reduce container padding */
.container-fluid {
padding-left: 0.3rem !important;
padding-right: 0.3rem !important;
}
}

801
static/js/main.js Normal file
View File

@ -0,0 +1,801 @@
"use strict";
// Global variables
let previousMetrics = {};
let persistentArrows = {};
let serverTimeOffset = 0;
let serverStartTime = null;
let latestMetrics = null;
let initialLoad = true;
let trendData = [];
let trendLabels = [];
let trendChart = null;
let connectionRetryCount = 0;
let maxRetryCount = 10;
let reconnectionDelay = 1000; // Start with 1 second
let pingInterval = null;
let lastPingTime = Date.now();
let connectionLostTimeout = null;
// Bitcoin-themed progress bar functionality
let progressInterval;
let currentProgress = 0;
let lastUpdateTime = Date.now();
let expectedUpdateInterval = 60000; // Expected server update interval (60 seconds)
const PROGRESS_MAX = 60; // 60 seconds for a complete cycle
// Initialize the progress bar and start the animation
function initProgressBar() {
// Clear any existing interval
if (progressInterval) {
clearInterval(progressInterval);
}
// Set last update time to now
lastUpdateTime = Date.now();
// Reset progress with initial offset
currentProgress = 1; // Start at 1 instead of 0 for offset
updateProgressBar(currentProgress);
// Start the interval
progressInterval = setInterval(function() {
// Calculate elapsed time since last update
const elapsedTime = Date.now() - lastUpdateTime;
// Calculate progress percentage based on elapsed time with +1 second offset
const secondsElapsed = Math.floor(elapsedTime / 1000) + 1; // Add 1 second offset
// If we've gone past the expected update time
if (secondsElapsed >= PROGRESS_MAX) {
// Keep the progress bar full but show waiting state
currentProgress = PROGRESS_MAX;
} else {
// Normal progress with offset
currentProgress = secondsElapsed;
}
updateProgressBar(currentProgress);
}, 1000);
}
// Update the progress bar display
function updateProgressBar(seconds) {
const progressPercent = (seconds / PROGRESS_MAX) * 100;
$("#bitcoin-progress-inner").css("width", progressPercent + "%");
// Add glowing effect when close to completion
if (progressPercent > 80) {
$("#bitcoin-progress-inner").addClass("glow-effect");
} else {
$("#bitcoin-progress-inner").removeClass("glow-effect");
}
// Update remaining seconds text - more precise calculation
let remainingSeconds = PROGRESS_MAX - seconds;
// When we're past the expected time, show "Waiting for update..."
if (remainingSeconds <= 0) {
$("#progress-text").text("Waiting for update...");
$("#bitcoin-progress-inner").addClass("waiting-for-update");
} else {
$("#progress-text").text(remainingSeconds + "s to next update");
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
}
}
// Register Chart.js annotation plugin if available
if (window['chartjs-plugin-annotation']) {
Chart.register(window['chartjs-plugin-annotation']);
}
// SSE Connection with Error Handling and Reconnection Logic
function setupEventSource() {
console.log("Setting up EventSource connection...");
if (window.eventSource) {
console.log("Closing existing EventSource connection");
window.eventSource.close();
window.eventSource = null;
}
// Always use absolute URL with origin to ensure it works from any path
const baseUrl = window.location.origin;
const streamUrl = `${baseUrl}/stream`;
console.log("Current path:", window.location.pathname);
console.log("Using stream URL:", streamUrl);
// Clear any existing ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
// Clear any connection lost timeout
if (connectionLostTimeout) {
clearTimeout(connectionLostTimeout);
connectionLostTimeout = null;
}
try {
const eventSource = new EventSource(streamUrl);
eventSource.onopen = function(e) {
console.log("EventSource connection opened successfully");
connectionRetryCount = 0; // Reset retry count on successful connection
reconnectionDelay = 1000; // Reset reconnection delay
hideConnectionIssue();
// Start ping interval to detect dead connections
lastPingTime = Date.now();
pingInterval = setInterval(function() {
const now = Date.now();
if (now - lastPingTime > 60000) { // 60 seconds without data
console.warn("No data received for 60 seconds, reconnecting...");
showConnectionIssue("Connection stalled");
eventSource.close();
setupEventSource();
}
}, 10000); // Check every 10 seconds
};
eventSource.onmessage = function(e) {
console.log("SSE message received");
lastPingTime = Date.now(); // Update ping time on any message
try {
const data = JSON.parse(e.data);
// Handle different message types
if (data.type === "ping") {
console.log("Ping received:", data);
// Update connection count if available
if (data.connections !== undefined) {
console.log(`Active connections: ${data.connections}`);
}
return;
}
if (data.type === "timeout_warning") {
console.log(`Connection timeout warning: ${data.remaining}s remaining`);
// If less than 30 seconds remaining, prepare for reconnection
if (data.remaining < 30) {
console.log("Preparing for reconnection due to upcoming timeout");
}
return;
}
if (data.type === "timeout") {
console.log("Connection timeout from server:", data.message);
eventSource.close();
// If reconnect flag is true, reconnect immediately
if (data.reconnect) {
console.log("Server requested reconnection");
setTimeout(setupEventSource, 500);
} else {
setupEventSource();
}
return;
}
if (data.error) {
console.error("Server reported error:", data.error);
showConnectionIssue(data.error);
// If retry time provided, use it, otherwise use default
const retryTime = data.retry || 5000;
setTimeout(function() {
manualRefresh();
}, retryTime);
return;
}
// Process regular data update
latestMetrics = data;
updateUI();
hideConnectionIssue();
// Also explicitly trigger a data refresh event
$(document).trigger('dataRefreshed');
} catch (err) {
console.error("Error processing SSE data:", err);
showConnectionIssue("Data processing error");
}
};
eventSource.onerror = function(e) {
console.error("SSE connection error", e);
showConnectionIssue("Connection lost");
eventSource.close();
// Implement exponential backoff for reconnection
connectionRetryCount++;
if (connectionRetryCount > maxRetryCount) {
console.log("Maximum retry attempts reached, switching to polling mode");
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
// Switch to regular polling
showConnectionIssue("Using polling mode");
setInterval(manualRefresh, 30000); // Poll every 30 seconds
manualRefresh(); // Do an immediate refresh
return;
}
// Exponential backoff with jitter
const jitter = Math.random() * 0.3 + 0.85; // 0.85-1.15
reconnectionDelay = Math.min(30000, reconnectionDelay * 1.5 * jitter);
console.log(`Reconnecting in ${(reconnectionDelay/1000).toFixed(1)} seconds... (attempt ${connectionRetryCount}/${maxRetryCount})`);
setTimeout(setupEventSource, reconnectionDelay);
};
window.eventSource = eventSource;
console.log("EventSource setup complete");
// Set a timeout to detect if connection is established
connectionLostTimeout = setTimeout(function() {
if (eventSource.readyState !== 1) { // 1 = OPEN
console.warn("Connection not established within timeout, switching to manual refresh");
showConnectionIssue("Connection timeout");
eventSource.close();
manualRefresh();
}
}, 10000); // 10 seconds timeout to establish connection
} catch (error) {
console.error("Failed to create EventSource:", error);
showConnectionIssue("Connection setup failed");
setTimeout(setupEventSource, 5000); // Try again in 5 seconds
}
// Add page visibility change listener
// This helps reconnect when user returns to the tab after it's been inactive
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
}
// Handle page visibility changes
function handleVisibilityChange() {
if (!document.hidden) {
console.log("Page became visible, checking connection");
if (!window.eventSource || window.eventSource.readyState !== 1) {
console.log("Connection not active, reestablishing");
setupEventSource();
}
manualRefresh(); // Always refresh data when page becomes visible
}
}
// Helper function to show connection issues to the user
function showConnectionIssue(message) {
let $connectionStatus = $("#connectionStatus");
if (!$connectionStatus.length) {
$("body").append('<div id="connectionStatus" style="position: fixed; top: 10px; right: 10px; background: rgba(255,0,0,0.7); color: white; padding: 10px; border-radius: 5px; z-index: 9999;"></div>');
$connectionStatus = $("#connectionStatus");
}
$connectionStatus.html(`<i class="fas fa-exclamation-triangle"></i> ${message}`).show();
// Show manual refresh button when there are connection issues
$("#refreshButton").show();
}
// Helper function to hide connection issue message
function hideConnectionIssue() {
$("#connectionStatus").hide();
$("#refreshButton").hide();
}
// Improved manual refresh function as fallback
function manualRefresh() {
console.log("Manually refreshing data...");
$.ajax({
url: '/api/metrics',
method: 'GET',
dataType: 'json',
timeout: 15000, // 15 second timeout
success: function(data) {
console.log("Manual refresh successful");
lastPingTime = Date.now(); // Update ping time
latestMetrics = data;
updateUI();
hideConnectionIssue();
// Explicitly trigger data refresh event
$(document).trigger('dataRefreshed');
},
error: function(xhr, status, error) {
console.error("Manual refresh failed:", error);
showConnectionIssue("Manual refresh failed");
// Try again with exponential backoff
const retryDelay = Math.min(30000, 1000 * Math.pow(1.5, Math.min(5, connectionRetryCount)));
connectionRetryCount++;
setTimeout(manualRefresh, retryDelay);
}
});
}
// Initialize Chart.js with Error Handling
function initializeChart() {
try {
const ctx = document.getElementById('trendGraph').getContext('2d');
if (!ctx) {
console.error("Could not find trend graph canvas");
return null;
}
if (!window.Chart) {
console.error("Chart.js not loaded");
return null;
}
// Check if Chart.js plugin is available
const hasAnnotationPlugin = window['chartjs-plugin-annotation'] !== undefined;
return new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: '60s Hashrate Trend (TH/s)',
data: [],
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
fill: true,
tension: 0.2,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Disable animations for better performance
},
scales: {
x: { display: false },
y: {
ticks: { color: 'white' },
grid: { color: '#333' }
}
},
plugins: {
legend: { display: false },
annotation: hasAnnotationPlugin ? {
annotations: {
averageLine: {
type: 'line',
yMin: 0,
yMax: 0,
borderColor: '#f7931a',
borderWidth: 2,
borderDash: [6, 6],
label: {
enabled: true,
content: '24hr Avg: 0 TH/s',
backgroundColor: 'rgba(0,0,0,0.7)',
color: '#f7931a',
font: { weight: 'bold', size: 13 },
position: 'start'
}
}
}
} : {}
}
}
});
} catch (error) {
console.error("Error initializing chart:", error);
return null;
}
}
// Helper function to safely format numbers with commas
function numberWithCommas(x) {
if (x == null) return "N/A";
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Server time update via polling
function updateServerTime() {
$.ajax({
url: "/api/time",
method: "GET",
timeout: 5000,
success: function(data) {
serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now();
serverStartTime = new Date(data.server_start_time).getTime();
},
error: function(jqXHR, textStatus, errorThrown) {
console.error("Error fetching server time:", textStatus, errorThrown);
}
});
}
// Update uptime display
function updateUptime() {
if (serverStartTime) {
const currentServerTime = Date.now() + serverTimeOffset;
const diff = currentServerTime - serverStartTime;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
$("#uptimeTimer").html("<strong>Uptime:</strong> " + hours + "h " + minutes + "m " + seconds + "s");
}
}
// Update UI indicators (arrows)
function updateIndicators(newMetrics) {
const keys = [
"pool_total_hashrate", "hashrate_24hr", "hashrate_3hr", "hashrate_10min",
"hashrate_60sec", "block_number", "btc_price", "network_hashrate",
"difficulty", "daily_revenue", "daily_power_cost", "daily_profit_usd",
"monthly_profit_usd", "daily_mined_sats", "monthly_mined_sats", "unpaid_earnings",
"estimated_earnings_per_day_sats", "estimated_earnings_next_block_sats", "estimated_rewards_in_window_sats",
"workers_hashing"
];
keys.forEach(function(key) {
const newVal = parseFloat(newMetrics[key]);
if (isNaN(newVal)) return;
const oldVal = parseFloat(previousMetrics[key]);
if (!isNaN(oldVal)) {
if (newVal > oldVal) {
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-up bounce-up' style='color: green;'></i>";
} else if (newVal < oldVal) {
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-down bounce-down' style='color: red; position: relative; top: -2px;'></i>";
}
} else {
if (newMetrics.arrow_history && newMetrics.arrow_history[key] && newMetrics.arrow_history[key].length > 0) {
const historyArr = newMetrics.arrow_history[key];
for (let i = historyArr.length - 1; i >= 0; i--) {
if (historyArr[i].arrow !== "") {
if (historyArr[i].arrow === "↑") {
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-up bounce-up' style='color: green;'></i>";
} else if (historyArr[i].arrow === "↓") {
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-down bounce-down' style='color: red; position: relative; top: -2px;'></i>";
}
break;
}
}
}
}
const indicator = document.getElementById("indicator_" + key);
if (indicator) {
indicator.innerHTML = persistentArrows[key] || "";
}
});
previousMetrics = { ...newMetrics };
}
// Helper function to safely update element text content
function updateElementText(elementId, text) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = text;
}
}
// Helper function to safely update element HTML content
function updateElementHTML(elementId, html) {
const element = document.getElementById(elementId);
if (element) {
element.innerHTML = html;
}
}
// Update workers_hashing value from metrics, but don't try to access worker details
function updateWorkersCount() {
if (latestMetrics && latestMetrics.workers_hashing !== undefined) {
$("#workers_hashing").text(latestMetrics.workers_hashing || 0);
// Update miner status with online/offline indicator based on worker count
if (latestMetrics.workers_hashing > 0) {
updateElementHTML("miner_status", "<span class='status-green'>ONLINE</span> <span class='online-dot'></span>");
} else {
updateElementHTML("miner_status", "<span class='status-red'>OFFLINE</span> <span class='offline-dot'></span>");
}
}
}
// Check for block updates and show congratulatory messages
function checkForBlockUpdates(data) {
if (previousMetrics.last_block_height !== undefined &&
data.last_block_height !== previousMetrics.last_block_height) {
showCongrats("Congrats! New Block Found: " + data.last_block_height);
}
if (previousMetrics.blocks_found !== undefined &&
data.blocks_found !== previousMetrics.blocks_found) {
showCongrats("Congrats! Blocks Found updated: " + data.blocks_found);
}
}
// Helper function to show congratulatory messages
function showCongrats(message) {
const $congrats = $("#congratsMessage");
$congrats.text(message).fadeIn(500, function() {
setTimeout(function() {
$congrats.fadeOut(500);
}, 3000);
});
}
// Main UI update function
function updateUI() {
if (!latestMetrics) {
console.warn("No metrics data available");
return;
}
try {
const data = latestMetrics;
// If there's execution time data, log it
if (data.execution_time) {
console.log(`Server metrics fetch took ${data.execution_time.toFixed(2)}s`);
}
// Cache jQuery selectors for performance and use safe update methods
updateElementText("pool_total_hashrate",
(data.pool_total_hashrate != null ? data.pool_total_hashrate : "N/A") + " " +
(data.pool_total_hashrate_unit ? data.pool_total_hashrate_unit.slice(0,-2).toUpperCase() + data.pool_total_hashrate_unit.slice(-2) : "")
);
updateElementText("hashrate_24hr",
(data.hashrate_24hr != null ? data.hashrate_24hr : "N/A") + " " +
(data.hashrate_24hr_unit ? data.hashrate_24hr_unit.slice(0,-2).toUpperCase() + data.hashrate_24hr_unit.slice(-2) : "")
);
updateElementText("hashrate_3hr",
(data.hashrate_3hr != null ? data.hashrate_3hr : "N/A") + " " +
(data.hashrate_3hr_unit ? data.hashrate_3hr_unit.slice(0,-2).toUpperCase() + data.hashrate_3hr_unit.slice(-2) : "")
);
updateElementText("hashrate_10min",
(data.hashrate_10min != null ? data.hashrate_10min : "N/A") + " " +
(data.hashrate_10min_unit ? data.hashrate_10min_unit.slice(0,-2).toUpperCase() + data.hashrate_10min_unit.slice(-2) : "")
);
updateElementText("hashrate_60sec",
(data.hashrate_60sec != null ? data.hashrate_60sec : "N/A") + " " +
(data.hashrate_60sec_unit ? data.hashrate_60sec_unit.slice(0,-2).toUpperCase() + data.hashrate_60sec_unit.slice(-2) : "")
);
updateElementText("block_number", numberWithCommas(data.block_number));
updateElementText("btc_price",
data.btc_price != null ? "$" + numberWithCommas(parseFloat(data.btc_price).toFixed(2)) : "N/A"
);
updateElementText("network_hashrate", numberWithCommas(Math.round(data.network_hashrate)) + " EH/s");
updateElementText("difficulty", numberWithCommas(Math.round(data.difficulty)));
updateElementText("daily_revenue", "$" + numberWithCommas(data.daily_revenue.toFixed(2)));
updateElementText("daily_power_cost", "$" + numberWithCommas(data.daily_power_cost.toFixed(2)));
updateElementText("daily_profit_usd", "$" + numberWithCommas(data.daily_profit_usd.toFixed(2)));
updateElementText("monthly_profit_usd", "$" + numberWithCommas(data.monthly_profit_usd.toFixed(2)));
updateElementText("daily_mined_sats", numberWithCommas(data.daily_mined_sats) + " sats");
updateElementText("monthly_mined_sats", numberWithCommas(data.monthly_mined_sats) + " sats");
// Update worker count from metrics (just the number, not full worker data)
updateWorkersCount();
updateElementText("unpaid_earnings", data.unpaid_earnings + " BTC");
// Update payout estimation with color coding
const payoutText = data.est_time_to_payout;
updateElementText("est_time_to_payout", payoutText);
if (payoutText && payoutText.toLowerCase().includes("next block")) {
$("#est_time_to_payout").css({
"color": "#32CD32",
"animation": "glowPulse 1s infinite"
});
} else {
const days = parseFloat(payoutText);
if (!isNaN(days)) {
if (days < 4) {
$("#est_time_to_payout").css({"color": "#32CD32", "animation": "none"});
} else if (days > 20) {
$("#est_time_to_payout").css({"color": "red", "animation": "none"});
} else {
$("#est_time_to_payout").css({"color": "#ffd700", "animation": "none"});
}
} else {
$("#est_time_to_payout").css({"color": "#ffd700", "animation": "none"});
}
}
updateElementText("last_block_height", data.last_block_height || "");
updateElementText("last_block_time", data.last_block_time || "");
updateElementText("blocks_found", data.blocks_found || "0");
updateElementText("last_share", data.total_last_share || "");
// Update Estimated Earnings metrics
updateElementText("estimated_earnings_per_day_sats", numberWithCommas(data.estimated_earnings_per_day_sats) + " sats");
updateElementText("estimated_earnings_next_block_sats", numberWithCommas(data.estimated_earnings_next_block_sats) + " sats");
updateElementText("estimated_rewards_in_window_sats", numberWithCommas(data.estimated_rewards_in_window_sats) + " sats");
// Update last updated timestamp
const now = new Date(Date.now() + serverTimeOffset);
updateElementHTML("lastUpdated", "<strong>Last Updated:</strong> " + now.toLocaleString() + "<span id='terminal-cursor'></span>");
// Update chart if it exists
if (trendChart) {
try {
// Always update the 24hr average line even if we don't have data points yet
const avg24hr = parseFloat(data.hashrate_24hr || 0);
if (!isNaN(avg24hr) &&
trendChart.options.plugins.annotation &&
trendChart.options.plugins.annotation.annotations &&
trendChart.options.plugins.annotation.annotations.averageLine) {
const annotation = trendChart.options.plugins.annotation.annotations.averageLine;
annotation.yMin = avg24hr;
annotation.yMax = avg24hr;
annotation.label.content = '24hr Avg: ' + avg24hr + ' TH/s';
}
// Update data points if we have any (removed minimum length requirement)
if (data.arrow_history && data.arrow_history.hashrate_60sec) {
const historyData = data.arrow_history.hashrate_60sec;
if (historyData && historyData.length > 0) {
console.log(`Updating chart with ${historyData.length} data points`);
trendChart.data.labels = historyData.map(item => item.time);
trendChart.data.datasets[0].data = historyData.map(item => {
const val = parseFloat(item.value);
return isNaN(val) ? 0 : val;
});
} else {
console.log("No history data points available yet");
}
} else {
console.log("No hashrate_60sec history available yet");
// If there's no history data, create a starting point using current hashrate
if (data.hashrate_60sec) {
const currentTime = new Date().toLocaleTimeString('en-US', {hour12: false, hour: '2-digit', minute: '2-digit'});
trendChart.data.labels = [currentTime];
trendChart.data.datasets[0].data = [parseFloat(data.hashrate_60sec) || 0];
console.log("Created initial data point with current hashrate");
}
}
// Always update the chart, even if we just updated the average line
trendChart.update('none');
} catch (chartError) {
console.error("Error updating chart:", chartError);
}
}
// Update indicators and check for block updates
updateIndicators(data);
checkForBlockUpdates(data);
} catch (error) {
console.error("Error updating UI:", error);
}
}
// Set up refresh synchronization
function setupRefreshSync() {
// Listen for the dataRefreshed event
$(document).on('dataRefreshed', function() {
// Broadcast to any other open tabs/pages that the data has been refreshed
try {
// Store the current timestamp to localStorage
localStorage.setItem('dashboardRefreshTime', Date.now().toString());
// Create a custom event that can be detected across tabs/pages
localStorage.setItem('dashboardRefreshEvent', 'refresh-' + Date.now());
console.log("Dashboard refresh synchronized");
} catch (e) {
console.error("Error synchronizing refresh:", e);
}
});
}
// Document ready initialization
$(document).ready(function() {
// Initialize the chart
trendChart = initializeChart();
// Initialize the progress bar
initProgressBar();
// Set up direct monitoring of data refreshes
$(document).on('dataRefreshed', function() {
console.log("Data refresh event detected, resetting progress bar");
lastUpdateTime = Date.now();
currentProgress = 0;
updateProgressBar(currentProgress);
});
// Wrap the updateUI function to detect changes and trigger events
const originalUpdateUI = updateUI;
updateUI = function() {
const previousMetricsTimestamp = latestMetrics ? latestMetrics.server_timestamp : null;
// Call the original function
originalUpdateUI.apply(this, arguments);
// Check if we got new data by comparing timestamps
if (latestMetrics && latestMetrics.server_timestamp !== previousMetricsTimestamp) {
console.log("New data detected, triggering refresh event");
$(document).trigger('dataRefreshed');
}
};
// Set up event source for SSE
setupEventSource();
// Start server time polling
updateServerTime();
setInterval(updateServerTime, 30000);
// Start uptime timer
setInterval(updateUptime, 1000);
updateUptime();
// Set up refresh synchronization with workers page
setupRefreshSync();
// Add a manual refresh button for fallback
$("body").append('<button id="refreshButton" style="position: fixed; bottom: 20px; left: 20px; z-index: 1000; background: #f7931a; color: black; border: none; padding: 8px 16px; display: none; border-radius: 4px; cursor: pointer;">Refresh Data</button>');
$("#refreshButton").on("click", function() {
$(this).text("Refreshing...");
$(this).prop("disabled", true);
manualRefresh();
setTimeout(function() {
$("#refreshButton").text("Refresh Data");
$("#refreshButton").prop("disabled", false);
}, 5000);
});
// Force a data refresh when the page loads
manualRefresh();
// Add emergency refresh button functionality
$("#forceRefreshBtn").show().on("click", function() {
$(this).text("Refreshing...");
$(this).prop("disabled", true);
$.ajax({
url: '/api/force-refresh',
method: 'POST',
timeout: 15000,
success: function(data) {
console.log("Force refresh successful:", data);
manualRefresh(); // Immediately get the new data
$("#forceRefreshBtn").text("Force Refresh").prop("disabled", false);
},
error: function(xhr, status, error) {
console.error("Force refresh failed:", error);
$("#forceRefreshBtn").text("Force Refresh").prop("disabled", false);
alert("Refresh failed: " + error);
}
});
});
// Add stale data detection
setInterval(function() {
if (latestMetrics && latestMetrics.server_timestamp) {
const lastUpdate = new Date(latestMetrics.server_timestamp);
const timeSinceUpdate = Math.floor((Date.now() - lastUpdate.getTime()) / 1000);
if (timeSinceUpdate > 120) { // More than 2 minutes
showConnectionIssue(`Data stale (${timeSinceUpdate}s old). Use Force Refresh.`);
$("#forceRefreshBtn").show();
}
}
}, 30000); // Check every 30 seconds
});

238
static/js/retro-refresh.js Normal file
View File

@ -0,0 +1,238 @@
// This script integrates the retro floating refresh bar
// with the existing dashboard and workers page functionality
(function() {
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
// Create the retro terminal bar if it doesn't exist yet
if (!document.getElementById('retro-terminal-bar')) {
createRetroTerminalBar();
}
// Hide the original refresh container
const originalRefreshUptime = document.getElementById('refreshUptime');
if (originalRefreshUptime) {
originalRefreshUptime.style.visibility = 'hidden';
originalRefreshUptime.style.height = '0';
originalRefreshUptime.style.overflow = 'hidden';
// Important: We keep the original elements and just hide them
// This ensures all existing JavaScript functions still work
}
// Add extra space at the bottom of the page to prevent the floating bar from covering content
const extraSpace = document.createElement('div');
extraSpace.style.height = '100px';
document.body.appendChild(extraSpace);
});
// Function to create the retro terminal bar
function createRetroTerminalBar() {
// Get the HTML content from the shared CSS/HTML
const html = `
<div id="retro-terminal-bar">
<div class="terminal-header">
<div class="terminal-title">SYSTEM MONITOR v0.1</div>
<div class="terminal-controls">
<div class="terminal-dot minimize" title="Minimize" onclick="toggleTerminal()"></div>
<div class="terminal-dot close" title="Close" onclick="hideTerminal()"></div>
</div>
</div>
<div class="terminal-content">
<div class="status-indicators">
<div class="status-indicator">
<div class="status-dot connected"></div>
<span>LIVE</span>
</div>
<div class="status-indicator">
<span id="data-refresh-time">00:00:00</span>
</div>
</div>
<div id="refreshContainer">
<!-- Enhanced progress bar with tick marks -->
<div class="bitcoin-progress-container">
<div id="bitcoin-progress-inner">
<div class="scan-line"></div>
</div>
<div class="progress-ticks">
<span>0s</span>
<span>15s</span>
<span>30s</span>
<span>45s</span>
<span>60s</span>
</div>
<!-- Add tick marks every 5 seconds -->
<div class="tick-mark major" style="left: 0%"></div>
<div class="tick-mark" style="left: 8.33%"></div>
<div class="tick-mark" style="left: 16.67%"></div>
<div class="tick-mark major" style="left: 25%"></div>
<div class="tick-mark" style="left: 33.33%"></div>
<div class="tick-mark" style="left: 41.67%"></div>
<div class="tick-mark major" style="left: 50%"></div>
<div class="tick-mark" style="left: 58.33%"></div>
<div class="tick-mark" style="left: 66.67%"></div>
<div class="tick-mark major" style="left: 75%"></div>
<div class="tick-mark" style="left: 83.33%"></div>
<div class="tick-mark" style="left: 91.67%"></div>
<div class="tick-mark major" style="left: 100%"></div>
</div>
</div>
<!-- <div id="progress-text">60s to next update</div> -->
<div id="uptimeTimer"><strong>Uptime:</strong> 0h 0m 0s</div>
</div>
</div>
`;
// Create a container for the HTML
const container = document.createElement('div');
container.innerHTML = html;
// Append to the body
document.body.appendChild(container.firstElementChild);
// Start the clock update
updateTerminalClock();
setInterval(updateTerminalClock, 1000);
// Check if terminal should be collapsed based on previous state
const isCollapsed = localStorage.getItem('terminalCollapsed') === 'true';
if (isCollapsed) {
document.getElementById('retro-terminal-bar').classList.add('collapsed');
}
}
// Function to update the terminal clock
function updateTerminalClock() {
const clockElement = document.getElementById('data-refresh-time');
if (clockElement) {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
clockElement.textContent = `${hours}:${minutes}:${seconds}`;
}
}
// Expose these functions globally for the onclick handlers
window.toggleTerminal = function() {
const terminal = document.getElementById('retro-terminal-bar');
terminal.classList.toggle('collapsed');
// Store state in localStorage
localStorage.setItem('terminalCollapsed', terminal.classList.contains('collapsed'));
};
window.hideTerminal = function() {
document.getElementById('retro-terminal-bar').style.display = 'none';
// Create a show button that appears at the bottom right
const showButton = document.createElement('button');
showButton.id = 'show-terminal-button';
showButton.textContent = 'Show Monitor';
showButton.style.position = 'fixed';
showButton.style.bottom = '10px';
showButton.style.right = '10px';
showButton.style.zIndex = '1000';
showButton.style.backgroundColor = '#f7931a';
showButton.style.color = '#000';
showButton.style.border = 'none';
showButton.style.padding = '8px 12px';
showButton.style.cursor = 'pointer';
showButton.style.fontFamily = "'VT323', monospace";
showButton.style.fontSize = '14px';
showButton.onclick = function() {
document.getElementById('retro-terminal-bar').style.display = 'block';
this.remove();
};
document.body.appendChild(showButton);
};
// Redirect original progress bar updates to our new floating bar
// This Observer will listen for changes to the original #bitcoin-progress-inner
// and replicate them to our new floating bar version
const initProgressObserver = function() {
// Setup a MutationObserver to watch for style changes on the original progress bar
const originalProgressBar = document.querySelector('#refreshUptime #bitcoin-progress-inner');
const newProgressBar = document.querySelector('#retro-terminal-bar #bitcoin-progress-inner');
if (originalProgressBar && newProgressBar) {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'style') {
// Get the width from the original progress bar
const width = originalProgressBar.style.width;
if (width) {
// Apply it to our new progress bar
newProgressBar.style.width = width;
// Also copy any classes (like glow-effect)
if (originalProgressBar.classList.contains('glow-effect') &&
!newProgressBar.classList.contains('glow-effect')) {
newProgressBar.classList.add('glow-effect');
} else if (!originalProgressBar.classList.contains('glow-effect') &&
newProgressBar.classList.contains('glow-effect')) {
newProgressBar.classList.remove('glow-effect');
}
// Copy waiting-for-update class
if (originalProgressBar.classList.contains('waiting-for-update') &&
!newProgressBar.classList.contains('waiting-for-update')) {
newProgressBar.classList.add('waiting-for-update');
} else if (!originalProgressBar.classList.contains('waiting-for-update') &&
newProgressBar.classList.contains('waiting-for-update')) {
newProgressBar.classList.remove('waiting-for-update');
}
}
}
});
});
// Start observing
observer.observe(originalProgressBar, { attributes: true });
}
// Also watch for changes to the progress text
const originalProgressText = document.querySelector('#refreshUptime #progress-text');
const newProgressText = document.querySelector('#retro-terminal-bar #progress-text');
if (originalProgressText && newProgressText) {
const textObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
// Update the text in our new bar
newProgressText.textContent = originalProgressText.textContent;
}
});
});
// Start observing
textObserver.observe(originalProgressText, { childList: true, subtree: true });
}
// Watch for changes to the uptime timer
const originalUptimeTimer = document.querySelector('#refreshUptime #uptimeTimer');
const newUptimeTimer = document.querySelector('#retro-terminal-bar #uptimeTimer');
if (originalUptimeTimer && newUptimeTimer) {
const uptimeObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
// Update the text in our new bar
newUptimeTimer.innerHTML = originalUptimeTimer.innerHTML;
}
});
});
// Start observing
uptimeObserver.observe(originalUptimeTimer, { childList: true, subtree: true });
}
};
// Start the observer once the page is fully loaded
window.addEventListener('load', function() {
// Give a short delay to ensure all elements are rendered
setTimeout(initProgressObserver, 500);
});
})();

641
static/js/workers.js Normal file
View File

@ -0,0 +1,641 @@
"use strict";
// Global variables for workers dashboard
let workerData = null;
let refreshTimer;
let pageLoadTime = Date.now();
let currentProgress = 0;
const PROGRESS_MAX = 60; // 60 seconds for a complete cycle
let lastUpdateTime = Date.now();
let filterState = {
currentFilter: 'all',
searchTerm: ''
};
let miniChart = null;
let connectionRetryCount = 0;
// Server time variables for uptime calculation - synced with main dashboard
let serverTimeOffset = 0;
let serverStartTime = null;
// New variable to track custom refresh timing
let lastManualRefreshTime = 0;
const MIN_REFRESH_INTERVAL = 10000; // Minimum 10 seconds between refreshes
// Initialize the page
$(document).ready(function() {
// Set up initial UI
initializePage();
// Get server time for uptime calculation
updateServerTime();
// Set up refresh synchronization with main dashboard
setupRefreshSync();
// Fetch worker data immediately on page load
fetchWorkerData();
// Set up refresh timer
setInterval(updateProgressBar, 1000);
// Set up uptime timer - synced with main dashboard
setInterval(updateUptime, 1000);
// Start server time polling - same as main dashboard
setInterval(updateServerTime, 30000);
// Auto-refresh worker data - aligned with main dashboard if possible
setInterval(function() {
// Check if it's been at least PROGRESS_MAX seconds since last update
const timeSinceLastUpdate = Date.now() - lastUpdateTime;
if (timeSinceLastUpdate >= PROGRESS_MAX * 1000) {
// Check if there was a recent manual refresh
const timeSinceManualRefresh = Date.now() - lastManualRefreshTime;
if (timeSinceManualRefresh >= MIN_REFRESH_INTERVAL) {
console.log("Auto-refresh triggered after time interval");
fetchWorkerData();
}
}
}, 10000); // Check every 10 seconds to align better with main dashboard
// Set up filter button click handlers
$('.filter-button').click(function() {
$('.filter-button').removeClass('active');
$(this).addClass('active');
filterState.currentFilter = $(this).data('filter');
filterWorkers();
});
// Set up search input handler
$('#worker-search').on('input', function() {
filterState.searchTerm = $(this).val().toLowerCase();
filterWorkers();
});
});
// Set up refresh synchronization with main dashboard
function setupRefreshSync() {
// Listen for storage events (triggered by main dashboard)
window.addEventListener('storage', function(event) {
// Check if this is our dashboard refresh event
if (event.key === 'dashboardRefreshEvent') {
console.log("Detected dashboard refresh event");
// Prevent too frequent refreshes
const now = Date.now();
const timeSinceLastRefresh = now - lastUpdateTime;
if (timeSinceLastRefresh >= MIN_REFRESH_INTERVAL) {
console.log("Syncing refresh with main dashboard");
// Reset progress bar and immediately fetch
resetProgressBar();
// Refresh the worker data
fetchWorkerData();
} else {
console.log("Skipping too-frequent refresh", timeSinceLastRefresh);
// Just reset the progress bar to match main dashboard
resetProgressBar();
}
}
});
// On page load, check if we should align with main dashboard timing
try {
const lastDashboardRefresh = localStorage.getItem('dashboardRefreshTime');
if (lastDashboardRefresh) {
const lastRefreshTime = parseInt(lastDashboardRefresh);
const timeSinceLastDashboardRefresh = Date.now() - lastRefreshTime;
// If main dashboard refreshed recently, adjust our timer
if (timeSinceLastDashboardRefresh < PROGRESS_MAX * 1000) {
console.log("Adjusting timer to align with main dashboard");
currentProgress = Math.floor(timeSinceLastDashboardRefresh / 1000);
updateProgressBar(currentProgress);
// Calculate when next update will happen (roughly 60 seconds from last dashboard refresh)
const timeUntilNextRefresh = (PROGRESS_MAX * 1000) - timeSinceLastDashboardRefresh;
// Schedule a one-time check near the expected refresh time
if (timeUntilNextRefresh > 0) {
console.log(`Scheduling coordinated refresh in ${Math.floor(timeUntilNextRefresh/1000)} seconds`);
setTimeout(function() {
// Check if a refresh happened in the last few seconds via localStorage event
const newLastRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
const secondsSinceLastRefresh = (Date.now() - newLastRefresh) / 1000;
// If dashboard hasn't refreshed in the last 5 seconds, do our own refresh
if (secondsSinceLastRefresh > 5) {
console.log("Coordinated refresh time reached, fetching data");
fetchWorkerData();
} else {
console.log("Dashboard already refreshed recently, skipping coordinated refresh");
}
}, timeUntilNextRefresh);
}
}
}
} catch (e) {
console.error("Error reading dashboard refresh time:", e);
}
// Check for dashboard refresh periodically
setInterval(function() {
try {
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
const now = Date.now();
const timeSinceLastRefresh = (now - lastUpdateTime) / 1000;
const timeSinceDashboardRefresh = (now - lastDashboardRefresh) / 1000;
// If dashboard refreshed more recently than we did and we haven't refreshed in at least 10 seconds
if (lastDashboardRefresh > lastUpdateTime && timeSinceLastRefresh > 10) {
console.log("Catching up with dashboard refresh");
resetProgressBar();
fetchWorkerData();
}
} catch (e) {
console.error("Error in periodic dashboard check:", e);
}
}, 5000); // Check every 5 seconds
}
// Server time update via polling - same as main.js
function updateServerTime() {
$.ajax({
url: "/api/time",
method: "GET",
timeout: 5000,
success: function(data) {
serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now();
serverStartTime = new Date(data.server_start_time).getTime();
},
error: function(jqXHR, textStatus, errorThrown) {
console.error("Error fetching server time:", textStatus, errorThrown);
}
});
}
// Update uptime display - synced with main dashboard
function updateUptime() {
if (serverStartTime) {
const currentServerTime = Date.now() + serverTimeOffset;
const diff = currentServerTime - serverStartTime;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
$("#uptimeTimer").html("<strong>Uptime:</strong> " + hours + "h " + minutes + "m " + seconds + "s");
}
}
// Initialize page elements
function initializePage() {
// Initialize mini chart for total hashrate if the element exists
if (document.getElementById('total-hashrate-chart')) {
initializeMiniChart();
}
// Show loading state
$('#worker-grid').html('<div class="text-center p-5"><i class="fas fa-spinner fa-spin"></i> Loading worker data...</div>');
// Add retry button (hidden by default)
if (!$('#retry-button').length) {
$('body').append('<button id="retry-button" style="position: fixed; bottom: 20px; left: 20px; z-index: 1000; background: #f7931a; color: black; border: none; padding: 8px 16px; display: none; border-radius: 4px; cursor: pointer;">Retry Loading Data</button>');
$('#retry-button').on('click', function() {
$(this).text('Retrying...').prop('disabled', true);
fetchWorkerData(true);
setTimeout(() => {
$('#retry-button').text('Retry Loading Data').prop('disabled', false);
}, 3000);
});
}
}
// Fetch worker data from API
function fetchWorkerData(forceRefresh = false) {
// Track this as a manual refresh for throttling purposes
lastManualRefreshTime = Date.now();
$('#worker-grid').addClass('loading-fade');
// Update progress bar to show data is being fetched
resetProgressBar();
// Choose API URL based on whether we're forcing a refresh
const apiUrl = `/api/workers${forceRefresh ? '?force=true' : ''}`;
$.ajax({
url: apiUrl,
method: 'GET',
dataType: 'json',
timeout: 15000, // 15 second timeout
success: function(data) {
workerData = data;
lastUpdateTime = Date.now();
// Update UI with new data
updateWorkerGrid();
updateSummaryStats();
updateMiniChart();
updateLastUpdated();
// Hide retry button
$('#retry-button').hide();
// Reset connection retry count
connectionRetryCount = 0;
console.log("Worker data updated successfully");
},
error: function(xhr, status, error) {
console.error("Error fetching worker data:", error);
// Show error in worker grid
$('#worker-grid').html(`
<div class="text-center p-5 text-danger">
<i class="fas fa-exclamation-triangle"></i>
<p>Error loading worker data: ${error || 'Unknown error'}</p>
</div>
`);
// Show retry button
$('#retry-button').show();
// Implement exponential backoff for automatic retry
connectionRetryCount++;
const delay = Math.min(30000, 1000 * Math.pow(1.5, Math.min(5, connectionRetryCount)));
console.log(`Will retry in ${delay/1000} seconds (attempt ${connectionRetryCount})`);
setTimeout(() => {
fetchWorkerData(true); // Force refresh on retry
}, delay);
},
complete: function() {
$('#worker-grid').removeClass('loading-fade');
}
});
}
// Update the worker grid with data
// UPDATED FUNCTION
function updateWorkerGrid() {
if (!workerData || !workerData.workers) {
console.error("No worker data available");
return;
}
const workerGrid = $('#worker-grid');
workerGrid.empty();
// Apply current filters before rendering
const filteredWorkers = filterWorkersData(workerData.workers);
if (filteredWorkers.length === 0) {
workerGrid.html(`
<div class="text-center p-5">
<i class="fas fa-search"></i>
<p>No workers match your filter criteria</p>
</div>
`);
return;
}
// Calculate total unpaid earnings (from the dashboard)
const totalUnpaidEarnings = workerData.total_earnings || 0;
// Sum up hashrates of online workers to calculate share percentages
const totalHashrate = workerData.workers
.filter(w => w.status === 'online')
.reduce((sum, w) => sum + parseFloat(w.hashrate_3hr || 0), 0);
// Calculate share percentage for each worker
const onlineWorkers = workerData.workers.filter(w => w.status === 'online');
const offlineWorkers = workerData.workers.filter(w => w.status === 'offline');
// Allocate 95% to online workers, 5% to offline workers
const onlinePool = totalUnpaidEarnings * 0.95;
const offlinePool = totalUnpaidEarnings * 0.05;
// Generate worker cards
filteredWorkers.forEach(worker => {
// Calculate earnings share based on hashrate proportion
let earningsDisplay = worker.earnings;
// Explicitly recalculate earnings share for display consistency
if (worker.status === 'online' && totalHashrate > 0) {
const hashrateShare = parseFloat(worker.hashrate_3hr || 0) / totalHashrate;
earningsDisplay = (onlinePool * hashrateShare).toFixed(8);
} else if (worker.status === 'offline' && offlineWorkers.length > 0) {
earningsDisplay = (offlinePool / offlineWorkers.length).toFixed(8);
}
// Create worker card
const card = $('<div class="worker-card"></div>');
// Add class based on status
if (worker.status === 'online') {
card.addClass('worker-card-online');
} else {
card.addClass('worker-card-offline');
}
// Add worker type badge
card.append(`<div class="worker-type">${worker.type}</div>`);
// Add worker name
card.append(`<div class="worker-name">${worker.name}</div>`);
// Add status badge
if (worker.status === 'online') {
card.append('<div class="status-badge status-badge-online">ONLINE</div>');
} else {
card.append('<div class="status-badge status-badge-offline">OFFLINE</div>');
}
// Add hashrate bar
const maxHashrate = 200; // TH/s - adjust based on your fleet
const hashratePercent = Math.min(100, (worker.hashrate_3hr / maxHashrate) * 100);
card.append(`
<div class="worker-stats-row">
<div class="worker-stats-label">Hashrate (3hr):</div>
<div class="white-glow">${worker.hashrate_3hr} ${worker.hashrate_3hr_unit}</div>
</div>
<div class="stats-bar-container">
<div class="stats-bar" style="width: ${hashratePercent}%"></div>
</div>
`);
// Add additional stats - NOTE: Using recalculated earnings
card.append(`
<div class="worker-stats">
<div class="worker-stats-row">
<div class="worker-stats-label">Last Share:</div>
<div class="blue-glow">${worker.last_share.split(' ')[1]}</div>
</div>
<div class="worker-stats-row">
<div class="worker-stats-label">Earnings:</div>
<div class="green-glow">${earningsDisplay}</div>
</div>
<div class="worker-stats-row">
<div class="worker-stats-label">Accept Rate:</div>
<div class="white-glow">${worker.acceptance_rate}%</div>
</div>
<div class="worker-stats-row">
<div class="worker-stats-label">Temp:</div>
<div class="${worker.temperature > 65 ? 'red-glow' : 'white-glow'}">${worker.temperature > 0 ? worker.temperature + '°C' : 'N/A'}</div>
</div>
</div>
`);
// Add card to grid
workerGrid.append(card);
});
// Verify the sum of displayed earnings equals the total
console.log(`Total unpaid earnings: ${totalUnpaidEarnings} BTC`);
console.log(`Sum of worker displayed earnings: ${
filteredWorkers.reduce((sum, w) => {
if (w.status === 'online' && totalHashrate > 0) {
const hashrateShare = parseFloat(w.hashrate_3hr || 0) / totalHashrate;
return sum + parseFloat((onlinePool * hashrateShare).toFixed(8));
} else if (w.status === 'offline' && offlineWorkers.length > 0) {
return sum + parseFloat((offlinePool / offlineWorkers.length).toFixed(8));
}
return sum;
}, 0)
} BTC`);
}
// Filter worker data based on current filter state
function filterWorkersData(workers) {
if (!workers) return [];
return workers.filter(worker => {
const workerName = worker.name.toLowerCase();
const isOnline = worker.status === 'online';
const workerType = worker.type.toLowerCase();
// Check if worker matches filter
let matchesFilter = false;
if (filterState.currentFilter === 'all') {
matchesFilter = true;
} else if (filterState.currentFilter === 'online' && isOnline) {
matchesFilter = true;
} else if (filterState.currentFilter === 'offline' && !isOnline) {
matchesFilter = true;
} else if (filterState.currentFilter === 'asic' && workerType === 'asic') {
matchesFilter = true;
} else if (filterState.currentFilter === 'fpga' && workerType === 'fpga') {
matchesFilter = true;
}
// Check if worker matches search term
const matchesSearch = workerName.includes(filterState.searchTerm);
return matchesFilter && matchesSearch;
});
}
// Apply filter to rendered worker cards
function filterWorkers() {
if (!workerData || !workerData.workers) return;
// Re-render the worker grid with current filters
updateWorkerGrid();
}
// Modified updateSummaryStats function for workers.js
function updateSummaryStats() {
if (!workerData) return;
// Update worker counts
$('#workers-count').text(workerData.workers_total || 0);
$('#workers-online').text(workerData.workers_online || 0);
$('#workers-offline').text(workerData.workers_offline || 0);
// Update worker ring percentage
const onlinePercent = workerData.workers_total > 0 ?
workerData.workers_online / workerData.workers_total : 0;
$('.worker-ring').css('--online-percent', onlinePercent);
// IMPORTANT: Update total hashrate using EXACT format matching main dashboard
// This ensures the displayed value matches exactly what's on the main page
if (workerData.total_hashrate !== undefined) {
// Format with exactly 1 decimal place - matches main dashboard format
const formattedHashrate = Number(workerData.total_hashrate).toFixed(1);
$('#total-hashrate').text(`${formattedHashrate} ${workerData.hashrate_unit || 'TH/s'}`);
} else {
$('#total-hashrate').text(`0.0 ${workerData.hashrate_unit || 'TH/s'}`);
}
// Update other summary stats
$('#total-earnings').text(`${(workerData.total_earnings || 0).toFixed(8)} BTC`);
$('#daily-sats').text(`${numberWithCommas(workerData.daily_sats || 0)} sats`);
$('#avg-acceptance-rate').text(`${(workerData.avg_acceptance_rate || 0).toFixed(2)}%`);
}
// Initialize mini chart
function initializeMiniChart() {
const ctx = document.getElementById('total-hashrate-chart').getContext('2d');
// Generate some sample data to start
const labels = Array(24).fill('').map((_, i) => i);
const data = [750, 760, 755, 770, 780, 775, 760, 765, 770, 775, 780, 790, 785, 775, 770, 765, 780, 785, 775, 770, 775, 780, 775, 774.8];
miniChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: data,
borderColor: '#1137F5',
backgroundColor: 'rgba(57, 255, 20, 0.1)',
fill: true,
tension: 0.3,
borderWidth: 1.5,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { display: false },
y: {
display: false,
min: Math.min(...data) * 0.9,
max: Math.max(...data) * 1.1
}
},
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
animation: false,
elements: {
line: {
tension: 0.4
}
}
}
});
}
// Update mini chart with real data
function updateMiniChart() {
if (!miniChart || !workerData || !workerData.hashrate_history) return;
// Extract hashrate data from history
const historyData = workerData.hashrate_history;
if (!historyData || historyData.length === 0) return;
// Get the values for the chart
const values = historyData.map(item => parseFloat(item.value) || 0);
const labels = historyData.map(item => item.time);
// Update chart data
miniChart.data.labels = labels;
miniChart.data.datasets[0].data = values;
// Update y-axis range
const min = Math.min(...values);
const max = Math.max(...values);
miniChart.options.scales.y.min = min * 0.9;
miniChart.options.scales.y.max = max * 1.1;
// Update the chart
miniChart.update('none');
}
// Update progress bar
function updateProgressBar() {
if (currentProgress < PROGRESS_MAX) {
currentProgress++;
const progressPercent = (currentProgress / PROGRESS_MAX) * 100;
$("#bitcoin-progress-inner").css("width", progressPercent + "%");
// Add glowing effect when close to completion
if (progressPercent > 80) {
$("#bitcoin-progress-inner").addClass("glow-effect");
} else {
$("#bitcoin-progress-inner").removeClass("glow-effect");
}
// Update remaining seconds text
let remainingSeconds = PROGRESS_MAX - currentProgress;
if (remainingSeconds <= 0) {
$("#progress-text").text("Waiting for update...");
$("#bitcoin-progress-inner").addClass("waiting-for-update");
} else {
$("#progress-text").text(remainingSeconds + "s to next update");
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
}
// Check for main dashboard refresh near the end to ensure sync
if (currentProgress >= 55) { // When we're getting close to refresh time
try {
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
const secondsSinceDashboardRefresh = (Date.now() - lastDashboardRefresh) / 1000;
// If main dashboard just refreshed (within last 5 seconds)
if (secondsSinceDashboardRefresh <= 5) {
console.log("Detected recent dashboard refresh, syncing now");
resetProgressBar();
fetchWorkerData();
return;
}
} catch (e) {
console.error("Error checking dashboard refresh status:", e);
}
}
} else {
// Reset progress bar if it's time to refresh
// But first check if the main dashboard refreshed recently
try {
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
const secondsSinceDashboardRefresh = (Date.now() - lastDashboardRefresh) / 1000;
// If dashboard refreshed in the last 10 seconds, wait for it instead of refreshing ourselves
if (secondsSinceDashboardRefresh < 10) {
console.log("Waiting for dashboard refresh event instead of refreshing independently");
return;
}
} catch (e) {
console.error("Error checking dashboard refresh status:", e);
}
// If main dashboard hasn't refreshed recently, do our own refresh
if (Date.now() - lastUpdateTime > PROGRESS_MAX * 1000) {
console.log("Progress bar expired, fetching data");
fetchWorkerData();
}
}
}
// Reset progress bar
function resetProgressBar() {
currentProgress = 0;
$("#bitcoin-progress-inner").css("width", "0%");
$("#bitcoin-progress-inner").removeClass("glow-effect");
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
$("#progress-text").text(PROGRESS_MAX + "s to next update");
}
// Update the last updated timestamp
function updateLastUpdated() {
if (!workerData || !workerData.timestamp) return;
try {
const timestamp = new Date(workerData.timestamp);
$("#lastUpdated").html("<strong>Last Updated:</strong> " +
timestamp.toLocaleString() + "<span id='terminal-cursor'></span>");
} catch (e) {
console.error("Error formatting timestamp:", e);
}
}
// Format numbers with commas
function numberWithCommas(x) {
if (x == null) return "N/A";
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

90
templates/base.html Normal file
View File

@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Ocean.xyz Pool Mining Dashboard{% endblock %}</title>
<!-- Common fonts -->
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Common CSS -->
<link rel="stylesheet" href="/static/css/common.css">
<!-- Retro Floating Refresh Bar -->
<link rel="stylesheet" href="/static/css/retro-refresh.css">
<!-- Page-specific CSS -->
{% block css %}{% endblock %}
</head>
<body>
<div class="container-fluid">
<!-- Connection status indicator -->
<div id="connectionStatus"></div>
<!-- Top right link -->
<a href="https://x.com/DJObleezy" id="topRightLink" target="_blank" rel="noopener noreferrer">Made by @DJO₿leezy</a>
<h1 class="text-center">
<a href="/" style="text-decoration:none; color:inherit;">
{% block header %}Ocean.xyz Pool Mining Dashboard v 0.2{% endblock %}
</a>
</h1>
{% block last_updated %}
<p class="text-center" id="lastUpdated"><strong>Last Updated:</strong> {{ current_time }}<span id="terminal-cursor"></span></p>
{% endblock %}
{% block navigation %}
<div class="navigation-links">
<a href="/dashboard" class="nav-link {% block dashboard_active %}{% endblock %}">Main Dashboard</a>
<a href="/workers" class="nav-link {% block workers_active %}{% endblock %}">Workers Overview</a>
</div>
{% endblock %}
<!-- Main content area -->
{% block content %}{% endblock %}
<!-- Bitcoin-themed Progress Bar and Uptime -->
{% block refresh_bar %}
<div id="refreshUptime" class="text-center">
<div id="refreshContainer">
<div class="bitcoin-progress-container">
<div id="bitcoin-progress-inner" class="bitcoin-progress-inner">
<div class="bitcoin-icons">
<i class="fab fa-bitcoin"></i>
<i class="fab fa-bitcoin"></i>
<i class="fab fa-bitcoin"></i>
</div>
</div>
</div>
<div id="progress-text">60s to next update</div>
</div>
<div id="uptimeTimer"><strong>Uptime:</strong> 0h 0m 0s</div>
</div>
{% endblock %}
<!-- Hidden Congrats Message -->
{% block congrats_message %}
<div id="congratsMessage" style="display:none; position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; background: #f7931a; color: #000; padding: 10px; border-radius: 5px; box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);"></div>
{% endblock %}
</div>
<!-- External JavaScript libraries -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@1.1.0"></script>
<!-- Page-specific JavaScript -->
{% block javascript %}{% endblock %}
<!-- Retro Floating Refresh Bar -->
<script src="/static/js/retro-refresh.js"></script>
</body>
</html>

369
templates/boot.html Normal file
View File

@ -0,0 +1,369 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ocean.xyz Pool Miner - Initializing...</title>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/boot.css">
</head>
<body>
<button id="skip-button">SKIP</button>
<div id="debug-info"></div>
<div id="loading-message">Loading mining data...</div>
<div id="bitcoin-logo">
██████╗ ████████╗ ██████╗ ██████╗ ███████╗
██╔══██╗╚══██╔══╝██╔════╝ ██╔═══██╗██╔════╝
██████╔╝ ██║ ██║ ██║ ██║███████╗
██╔══██╗ ██║ ██║ ██║ ██║╚════██║
██████╔╝ ██║ ╚██████╗ ╚██████╔╝███████║
╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
v.21
</div>
<div id="terminal">
<div id="terminal-content">
<span id="output"></span><span class="cursor"></span>
<span id="prompt-container">
<span id="prompt-text">Initialize mining dashboard? [Y/N]:
<span class="prompt-cursor"></span>
<input type="text" id="user-input" maxlength="1" autocomplete="off" spellcheck="false" autofocus style="font-size: 16px; font-weight: bold;">
</span>
</span>
</div>
</div>
<script>
// Debug logging
function updateDebug(message) {
document.getElementById('debug-info').textContent = message;
console.log(message);
}
// Format numbers with commas
function numberWithCommas(x) {
if (x == null) return "N/A";
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Global variables
let bootMessages = [];
let dashboardData = null;
let outputElement = document.getElementById('output');
const bitcoinLogo = document.getElementById('bitcoin-logo');
const skipButton = document.getElementById('skip-button');
const loadingMessage = document.getElementById('loading-message');
const promptContainer = document.getElementById('prompt-container');
const userInput = document.getElementById('user-input');
let messageIndex = 0;
let timeoutId = null;
let waitingForUserInput = false;
let bootComplete = false;
// Safety timeout: redirect after 60 seconds if boot not complete
window.addEventListener('load', function() {
setTimeout(function() {
if (!bootComplete && !waitingForUserInput) {
console.warn("Safety timeout reached - redirecting to dashboard");
redirectToDashboard();
}
}, 60000);
});
// Redirect to dashboard
function redirectToDashboard() {
updateDebug("Boot sequence complete, redirecting...");
const baseUrl = window.location.origin;
window.location.href = baseUrl + "/dashboard";
}
// Fade in Bitcoin logo
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
bitcoinLogo.style.visibility = 'visible';
setTimeout(function() {
bitcoinLogo.style.opacity = '1';
}, 100);
}, 500);
});
// Post-confirmation messages with retro typing effect
function showPostConfirmationMessages(response) {
try {
outputElement = document.getElementById('output');
if (!outputElement) {
setTimeout(redirectToDashboard, 1000);
return;
}
const yesMessages = [
{ text: "INITIALIZING DASHBOARD...\n", html: true, delay: 400 },
{ text: "Connecting to real-time data feeds...", speed: 20, delay: 300 },
{ text: "<span class='green'>CONNECTED</span>\n", html: true, delay: 400 },
{ text: "Loading blockchain validators...", speed: 15, delay: 300 },
{ text: "<span class='green'>COMPLETE</span>\n", html: true, delay: 400 },
{ text: "Starting TX fee calculation module...", speed: 15, delay: 400 },
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 400 },
{ text: "Verifying BTC-USD exchange rates...", speed: 15, delay: 200 },
{ text: "<span class='green'>CURRENT RATE CONFIRMED</span>\n", html: true, delay: 300 },
{ text: "Calibrating hashrate telemetry...", speed: 15, delay: 200 },
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 200 },
{ text: "Loading historical mining data: [", speed: 15, delay: 100 },
{ text: "<span class='green'>████</span>", html: true, delay: 100 },
{ text: "<span class='green'>████████</span>", html: true, delay: 100 },
{ text: "<span class='green'>███████</span>", html: true, delay: 100 },
{ text: "<span class='green'>████</span>", html: true, delay: 100 },
{ text: "<span class='green'>██</span>", html: true, delay: 100 },
{ text: "<span class='green'></span>", html: true, delay: 100 },
{ text: "] <span class='green'>COMPLETE</span>\n", html: true, delay: 400 },
{ text: "Block reward calculation initialized...", speed: 15, delay: 300 },
{ text: "<span class='yellow'>HALVING SCHEDULE VERIFIED</span>\n", html: true, delay: 400 },
{ text: "Satoshi per kWh optimizer: <span class='green'>ENGAGED</span>\n", html: true, delay: 500 },
{ text: "Launching mining dashboard in 3...", speed: 80, delay: 1000 },
{ text: " 2...", speed: 80, delay: 1000 },
{ text: " 1...\n", speed: 80, delay: 1000 },
{ text: "<span class='green'>STACK SATS MODE: ACTIVATED</span>\n", html: true, delay: 500 }
];
const noMessages = [
{ text: "DASHBOARD INITIALIZATION ABORTED.\n", html: true, delay: 400 },
{ text: "Mining processes will continue in background.\n", speed: 30, delay: 500 },
{ text: "Attempting emergency recovery...\n", speed: 30, delay: 1000 },
{ text: "Bypassing authentication checks...", speed: 20, delay: 300 },
{ text: "<span class='green'>SUCCESS</span>\n", html: true, delay: 500 },
{ text: "Initializing fallback ASIC control interface...", speed: 20, delay: 300 },
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 400 },
{ text: "<span class='green'>RECOVERY SUCCESSFUL!</span>\n", html: true, delay: 400 },
{ text: "Loading minimal dashboard in safe mode: [", speed: 15, delay: 100 },
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
{ text: "] <span class='white'>READY</span>\n", html: true, delay: 400 },
{ text: "\nNOTE: REDUCED FUNCTIONALITY IN SAFE MODE\n", html: true, delay: 500 },
{ text: "Launching minimal dashboard in 3...", speed: 80, delay: 1000 },
{ text: " 2...", speed: 80, delay: 1000 },
{ text: " 1...\n", speed: 80, delay: 1000 },
{ text: "<span class='green'>BITCOIN MINING CONTINUES REGARDLESS! ;)</span>\n", html: true, delay: 500 }
];
const messages = response === 'Y' ? yesMessages : noMessages;
let msgIndex = 0;
function processNextMessage() {
if (msgIndex >= messages.length) {
bootComplete = true;
setTimeout(redirectToDashboard, 500);
return;
}
const currentMessage = messages[msgIndex];
if (currentMessage.html) {
outputElement.innerHTML += currentMessage.text;
msgIndex++;
setTimeout(processNextMessage, currentMessage.delay || 300);
} else {
let charIndex = 0;
function typeCharacter() {
if (charIndex < currentMessage.text.length) {
outputElement.innerHTML += currentMessage.text.charAt(charIndex);
charIndex++;
setTimeout(typeCharacter, currentMessage.speed || 20);
} else {
msgIndex++;
setTimeout(processNextMessage, currentMessage.delay || 300);
}
}
typeCharacter();
}
}
setTimeout(processNextMessage, 500);
} catch(err) {
setTimeout(redirectToDashboard, 1000);
}
}
// Handle Y/N prompt input
userInput.addEventListener('keydown', function(e) {
if (waitingForUserInput && e.key === 'Enter') {
e.preventDefault();
const response = userInput.value.toUpperCase();
promptContainer.style.display = 'none';
waitingForUserInput = false;
outputElement.innerHTML += response + "\n";
userInput.value = '';
showPostConfirmationMessages(response);
}
});
// Show the prompt
function showUserPrompt() {
promptContainer.style.display = 'inline';
waitingForUserInput = true;
document.querySelector('.cursor').style.display = 'none';
userInput.focus();
}
// We disable truncation so all text is visible.
function manageTerminalContent() { }
// Retro typing effect for boot messages
function typeBootMessages() {
try {
if (!outputElement) {
outputElement = document.getElementById('output');
if (!outputElement) {
skipButton.click();
return;
}
}
console.log("Processing boot message index:", messageIndex);
if (messageIndex >= bootMessages.length) { return; }
const currentMessage = bootMessages[messageIndex];
if (currentMessage.showPrompt) {
messageIndex++;
showUserPrompt();
return;
}
if (currentMessage.html) {
outputElement.innerHTML += currentMessage.text;
messageIndex++;
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
return;
}
if (!currentMessage.typingIndex) { currentMessage.typingIndex = 0; }
if (currentMessage.typingIndex < currentMessage.text.length) {
outputElement.innerHTML += currentMessage.text.charAt(currentMessage.typingIndex);
currentMessage.typingIndex++;
timeoutId = setTimeout(typeBootMessages, currentMessage.speed || 15);
} else {
messageIndex++;
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
}
} catch(err) {
messageIndex++;
timeoutId = setTimeout(typeBootMessages, 500);
}
}
// Skip button: immediately redirect
skipButton.addEventListener('click', function() {
clearTimeout(timeoutId);
redirectToDashboard();
});
// Start the typing animation (hides loading message)
function startTyping() {
loadingMessage.style.display = 'none';
setTimeout(typeBootMessages, 150);
}
// Fallback messages (used immediately)
function setupFallbackMessages() {
bootMessages = [
{ text: "BITCOIN OS - MINING SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto\n", speed: 20, delay: 250 },
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
{ text: "INITIALIZING SYSTEM...\n", speed: 25, delay: 300 },
{ text: "HARDWARE: ", speed: 25, delay: 100 },
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
{ text: "NETWORK: ", speed: 25, delay: 100 },
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
{ text: "BLOCKCHAIN: ", speed: 25, delay: 100 },
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
{ text: "MINING RIG: ", speed: 25, delay: 100 },
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
{ text: "\nSystem ready. ", speed: 25, delay: 400 },
{ showPrompt: true, delay: 0 }
];
startTyping();
}
// Initialize with fallback, then try live data
setupFallbackMessages();
updateDebug("Fetching dashboard data...");
fetch('/api/metrics')
.then(response => {
if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); }
return response.json();
})
.then(data => {
dashboardData = data;
clearTimeout(timeoutId);
messageIndex = 0;
outputElement = document.getElementById('output');
outputElement.innerHTML = "";
bootMessages = [
{ text: "BITCOIN OS - MINING CONTROL SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto & The Bitcoin Core Developers\n", speed: 20, delay: 250 },
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
{ text: "INITIALIZING SHA-256 MINING SUBSYSTEMS...\n", speed: 25, delay: 400 },
{ text: "ASIC CLUSTER STATUS: ", speed: 15, delay: 100 },
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
{ text: "CHIP TEMPERATURE: ", speed: 15, delay: 100 },
{ text: "<span class='green'>62°C - WITHIN OPTIMAL RANGE</span>\n", html: true, delay: 300 },
{ text: "COOLING SYSTEMS: ", speed: 15, delay: 100 },
{ text: "<span class='green'>OPERATIONAL</span>\n", html: true, delay: 300 },
{ text: "POWER SUPPLY HEALTH: ", speed: 15, delay: 100 },
{ text: "<span class='green'>98.7% - NOMINAL</span>\n", html: true, delay: 300 },
{ text: "\nCONNECTING TO BITCOIN NETWORK...\n", speed: 20, delay: 400 },
{ text: "BLOCKCHAIN SYNC STATUS: ", speed: 15, delay: 100 },
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
{ text: "DIFFICULTY ADJUSTMENT: ", speed: 15, delay: 100 },
{ text: "<span class='yellow'>CALCULATED</span>\n", html: true, delay: 300 },
{ text: "MEMPOOL MONITORING: ", speed: 15, delay: 100 },
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
{ text: "\nESTABLISHING POOL CONNECTION...\n", speed: 20, delay: 300 },
{ text: "CONNECTING TO OCEAN.XYZ...\n", speed: 20, delay: 300 },
{ text: "STRATUM PROTOCOL v2: ", speed: 15, delay: 100 },
{ text: "<span class='green'>INITIALIZED</span>\n", html: true, delay: 300 },
{ text: "POOL HASHRATE: ", speed: 15, delay: 100 },
{ text: "<span class='green'>VERIFIED</span>\n", html: true, delay: 300 },
{ text: "WORKER AUTHENTICATION: ", speed: 15, delay: 100 },
{ text: "<span class='green'>SUCCESSFUL</span>\n", html: true, delay: 300 },
{ text: "\nINITIALIZING METRICS COLLECTORS...\n", speed: 20, delay: 300 },
{ text: "HASHRATE MONITOR: ", speed: 15, delay: 100 },
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
{ text: "EARNINGS CALCULATOR: ", speed: 15, delay: 100 },
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 300 },
{ text: "POWER USAGE TRACKING: ", speed: 15, delay: 100 },
{ text: "<span class='green'>ENABLED</span>\n", html: true, delay: 300 },
{ text: "PAYOUT THRESHOLD MONITOR: ", speed: 15, delay: 100 },
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
{ text: "\nCURRENT NETWORK METRICS DETECTED\n", speed: 20, delay: 300 },
{ text: "BTC PRICE: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>$" + numberWithCommas((data.btc_price || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
{ text: "NETWORK DIFFICULTY: ", speed: 20, delay: 100 },
{ text: "<span class='white'>" + numberWithCommas(Math.round(data.difficulty || 0)) + "</span>\n", html: true, delay: 300 },
{ text: "NETWORK HASHRATE: ", speed: 20, delay: 100 },
{ text: "<span class='white'>" + (data.network_hashrate ? numberWithCommas(Math.round(data.network_hashrate)) : "N/A") + " EH/s</span>\n", html: true, delay: 300 },
{ text: "BLOCK HEIGHT: ", speed: 20, delay: 100 },
{ text: "<span class='white'>" + numberWithCommas(data.block_number || "N/A") + "</span>\n", html: true, delay: 300 },
{ text: "\nMINER PERFORMANCE DATA\n", speed: 20, delay: 300 },
{ text: "CURRENT HASHRATE: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>" + (data.hashrate_60sec || "N/A") + " " + (data.hashrate_60sec_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
{ text: "24HR AVG HASHRATE: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>" + (data.hashrate_24hr || "N/A") + " " + (data.hashrate_24hr_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
{ text: "ACTIVE WORKERS: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>" + (data.workers_hashing || "0") + "</span>\n", html: true, delay: 300 },
{ text: "\nFINANCIAL CALCULATIONS\n", speed: 20, delay: 300 },
{ text: "DAILY MINING REVENUE: ", speed: 20, delay: 100 },
{ text: "<span class='green'>$" + numberWithCommas((data.daily_revenue || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
{ text: "DAILY POWER COST: ", speed: 20, delay: 100 },
{ text: "<span class='red'>$" + numberWithCommas((data.daily_power_cost || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
{ text: "DAILY PROFIT: ", speed: 20, delay: 100 },
{ text: "<span class='green'>$" + numberWithCommas((data.daily_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
{ text: "PROJECTED MONTHLY PROFIT: ", speed: 20, delay: 100 },
{ text: "<span class='green'>$" + numberWithCommas((data.monthly_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
{ text: "DAILY SATOSHI YIELD: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>" + numberWithCommas(data.daily_mined_sats || 0) + " sats</span>\n", html: true, delay: 300 },
{ text: "UNPAID EARNINGS: ", speed: 20, delay: 100 },
{ text: "<span class='green'>" + (data.unpaid_earnings || "0") + " BTC</span>\n", html: true, delay: 300 },
{ text: "ESTIMATED TIME TO PAYOUT: ", speed: 20, delay: 100 },
{ text: "<span class='yellow'>" + (data.est_time_to_payout || "Unknown") + "</span>\n", html: true, delay: 300 },
{ text: "\n", speed: 25, delay: 100 },
{ text: "<span class='green'>ALL MINING PROCESSES OPERATIONAL</span>\n", html: true, delay: 400 },
{ text: "\nInitialize mining dashboard? ", speed: 25, delay: 400 },
{ showPrompt: true, delay: 0 }
];
startTyping();
})
.catch(error => {
updateDebug(`Error fetching dashboard data: ${error.message}`);
});
</script>
</body>
</html>

363
templates/dashboard.html Normal file
View File

@ -0,0 +1,363 @@
{% extends "base.html" %}
{% block title %}Ocean.xyz Pool Mining Dashboard v 0.2{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/dashboard.css">
{% endblock %}
{% block dashboard_active %}active{% endblock %}
{% block content %}
<!-- Graph Container -->
<div id="graphContainer" class="mb-2">
<canvas id="trendGraph" style="width: 100%; height: 100%; position: relative; z-index: 2;"></canvas>
</div>
<!-- Miner Status -->
<div class="row mb-2 equal-height">
<div class="col-12">
<div class="card">
<div class="card-header">Miner Status</div>
<div class="card-body">
<p>
<strong>Status:</strong>
<span id="miner_status" class="metric-value">
{% if metrics and metrics.workers_hashing and metrics.workers_hashing > 0 %}
<span class="status-green">ONLINE</span> <span class="online-dot"></span>
{% else %}
<span class="status-red">OFFLINE</span> <span class="offline-dot"></span>
{% endif %}
</span>
</p>
<p>
<strong>Workers Hashing:</strong>
<span id="workers_hashing" class="metric-value">{{ metrics.workers_hashing or 0 }}</span>
<span id="indicator_workers_hashing"></span>
</p>
<p>
<strong>Last Share:</strong>
<span id="last_share" class="metric-value">{{ metrics.total_last_share or "N/A" }}</span>
</p>
</div>
</div>
</div>
</div>
<!-- Pool Hashrates and Bitcoin Network Stats -->
<div class="row equal-height">
<div class="col-md-6">
<div class="card">
<div class="card-header">Pool Hashrates</div>
<div class="card-body">
<p>
<strong>Pool Total Hashrate:</strong>
<span id="pool_total_hashrate" class="metric-value white">
{% if metrics and metrics.pool_total_hashrate and metrics.pool_total_hashrate_unit %}
{{ metrics.pool_total_hashrate }} {{ metrics.pool_total_hashrate_unit[:-2]|upper ~ metrics.pool_total_hashrate_unit[-2:] }}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_pool_total_hashrate"></span>
</p>
<hr>
<p>
<strong>24hr Avg Hashrate:</strong>
<span id="hashrate_24hr" class="metric-value white">
{% if metrics and metrics.hashrate_24hr %}
{{ metrics.hashrate_24hr }}
{% if metrics.hashrate_24hr_unit %}
{{ metrics.hashrate_24hr_unit[:-2]|upper ~ metrics.hashrate_24hr_unit[-2:] }}
{% else %}
TH/s
{% endif %}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_hashrate_24hr"></span>
</p>
<p>
<strong>3hr Avg Hashrate:</strong>
<span id="hashrate_3hr" class="metric-value white">
{% if metrics and metrics.hashrate_3hr %}
{{ metrics.hashrate_3hr }}
{% if metrics.hashrate_3hr_unit %}
{{ metrics.hashrate_3hr_unit[:-2]|upper ~ metrics.hashrate_3hr_unit[-2:] }}
{% else %}
TH/s
{% endif %}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_hashrate_3hr"></span>
</p>
<p>
<strong>10min Avg Hashrate:</strong>
<span id="hashrate_10min" class="metric-value white">
{% if metrics and metrics.hashrate_10min %}
{{ metrics.hashrate_10min }}
{% if metrics.hashrate_10min_unit %}
{{ metrics.hashrate_10min_unit[:-2]|upper ~ metrics.hashrate_10min_unit[-2:] }}
{% else %}
TH/s
{% endif %}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_hashrate_10min"></span>
</p>
<p>
<strong>60sec Avg Hashrate:</strong>
<span id="hashrate_60sec" class="metric-value white">
{% if metrics and metrics.hashrate_60sec %}
{{ metrics.hashrate_60sec }}
{% if metrics.hashrate_60sec_unit %}
{{ metrics.hashrate_60sec_unit[:-2]|upper ~ metrics.hashrate_60sec_unit[-2:] }}
{% else %}
TH/s
{% endif %}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_hashrate_60sec"></span>
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Bitcoin Network Stats</div>
<div class="card-body">
<p>
<strong>Block Number:</strong>
<span id="block_number" class="metric-value white">
{% if metrics and metrics.block_number %}
{{ metrics.block_number|commafy }}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_block_number"></span>
</p>
<p>
<strong>BTC Price:</strong>
<span id="btc_price" class="metric-value yellow">
{% if metrics and metrics.btc_price %}
${{ "%.2f"|format(metrics.btc_price) }}
{% else %}
$0.00
{% endif %}
</span>
<span id="indicator_btc_price"></span>
</p>
<p>
<strong>Network Hashrate:</strong>
<span id="network_hashrate" class="metric-value white">
{% if metrics and metrics.network_hashrate %}
{{ metrics.network_hashrate|round|commafy }} EH/s
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_network_hashrate"></span>
</p>
<p>
<strong>Difficulty:</strong>
<span id="difficulty" class="metric-value white">
{% if metrics and metrics.difficulty %}
{{ metrics.difficulty|round|commafy }}
{% else %}
N/A
{% endif %}
</span>
<span id="indicator_difficulty"></span>
</p>
</div>
</div>
</div>
</div>
<!-- Satoshi and USD Metrics -->
<div class="row equal-height">
<div class="col-md-6">
<div class="card">
<div class="card-header">Satoshi Metrics</div>
<div class="card-body">
<p>
<strong>Daily Mined (Net):</strong>
<span id="daily_mined_sats" class="metric-value yellow">
{% if metrics and metrics.daily_mined_sats %}
{{ metrics.daily_mined_sats|commafy }} sats
{% else %}
0 sats
{% endif %}
</span>
<span id="indicator_daily_mined_sats"></span>
</p>
<p>
<strong>Monthly Mined (Net):</strong>
<span id="monthly_mined_sats" class="metric-value yellow">
{% if metrics and metrics.monthly_mined_sats %}
{{ metrics.monthly_mined_sats|commafy }} sats
{% else %}
0 sats
{% endif %}
</span>
<span id="indicator_monthly_mined_sats"></span>
</p>
<p>
<strong>Est. Earnings/Day:</strong>
<span id="estimated_earnings_per_day_sats" class="metric-value yellow">
{% if metrics and metrics.estimated_earnings_per_day_sats %}
{{ metrics.estimated_earnings_per_day_sats|commafy }} sats
{% else %}
0 sats
{% endif %}
</span>
<span id="indicator_estimated_earnings_per_day_sats"></span>
</p>
<p>
<strong>Est. Earnings/Block:</strong>
<span id="estimated_earnings_next_block_sats" class="metric-value yellow">
{% if metrics and metrics.estimated_earnings_next_block_sats %}
{{ metrics.estimated_earnings_next_block_sats|commafy }} sats
{% else %}
0 sats
{% endif %}
</span>
<span id="indicator_estimated_earnings_next_block_sats"></span>
</p>
<p>
<strong>Est. Rewards in Window:</strong>
<span id="estimated_rewards_in_window_sats" class="metric-value yellow">
{% if metrics and metrics.estimated_rewards_in_window_sats %}
{{ metrics.estimated_rewards_in_window_sats|commafy }} sats
{% else %}
0 sats
{% endif %}
</span>
<span id="indicator_estimated_rewards_in_window_sats"></span>
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">USD Metrics</div>
<div class="card-body">
<p>
<strong>Daily Revenue:</strong>
<span id="daily_revenue" class="metric-value green">
{% if metrics and metrics.daily_revenue is defined and metrics.daily_revenue is not none %}
${{ "%.2f"|format(metrics.daily_revenue) }}
{% else %}
$0.00
{% endif %}
</span>
<span id="indicator_daily_revenue"></span>
</p>
<p>
<strong>Daily Power Cost:</strong>
<span id="daily_power_cost" class="metric-value red">
{% if metrics and metrics.daily_power_cost is defined and metrics.daily_power_cost is not none %}
${{ "%.2f"|format(metrics.daily_power_cost) }}
{% else %}
$0.00
{% endif %}
</span>
<span id="indicator_daily_power_cost"></span>
</p>
<p>
<strong>Daily Profit (USD):</strong>
<span id="daily_profit_usd" class="metric-value green">
{% if metrics and metrics.daily_profit_usd is defined and metrics.daily_profit_usd is not none %}
${{ "%.2f"|format(metrics.daily_profit_usd) }}
{% else %}
$0.00
{% endif %}
</span>
<span id="indicator_daily_profit_usd"></span>
</p>
<p>
<strong>Monthly Profit (USD):</strong>
<span id="monthly_profit_usd" class="metric-value green">
{% if metrics and metrics.monthly_profit_usd is defined and metrics.monthly_profit_usd is not none %}
${{ "%.2f"|format(metrics.monthly_profit_usd) }}
{% else %}
$0.00
{% endif %}
</span>
<span id="indicator_monthly_profit_usd"></span>
</p>
</div>
</div>
</div>
</div>
<!-- Payout & Misc -->
<div class="row">
<div class="col-12">
<div class="card" id="payoutMiscCard">
<div class="card-header">Payout &amp; Misc</div>
<div class="card-body">
<p>
<strong>Unpaid Earnings:</strong>
<span id="unpaid_earnings" class="metric-value green">
{% if metrics and metrics.unpaid_earnings %}
{{ metrics.unpaid_earnings }} BTC
{% else %}
0 BTC
{% endif %}
</span>
<span id="indicator_unpaid_earnings"></span>
</p>
<p>
<strong>Last Block:</strong>
<span id="last_block_height" class="metric-value white">
{{ metrics.last_block_height if metrics and metrics.last_block_height else "N/A" }}
</span>
<span id="last_block_time" class="metric-value blue">
{{ metrics.last_block_time if metrics and metrics.last_block_time else "N/A" }}
</span>
<span class="green">
{% if metrics and metrics.last_block_earnings %}
+{{ metrics.last_block_earnings|int|commafy }} sats
{% else %}
+0 sats
{% endif %}
</span>
<span id="indicator_last_block"></span>
</p>
<p>
<strong>Est. Time to Payout:</strong>
<span id="est_time_to_payout" class="metric-value yellow">
{{ metrics.est_time_to_payout if metrics and metrics.est_time_to_payout else "N/A" }}
</span>
<span id="indicator_est_time_to_payout"></span>
</p>
<p>
<strong>Blocks Found:</strong>
<span id="blocks_found" class="metric-value white">
{{ metrics.blocks_found if metrics and metrics.blocks_found else "0" }}
</span>
<span id="indicator_blocks_found"></span>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<!-- External JavaScript file with our application logic -->
<script src="/static/js/main.js"></script>
{% endblock %}

22
templates/error.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Mining Dashboard</title>
<!-- Include both Orbitron and VT323 fonts -->
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/error.css">
</head>
<body>
<div class="container">
<div class="error-container">
<h1>ERROR</h1>
<div class="error-code">CODE: SYS_EXCEPTION_0x45</div>
<p>{{ message }}<span class="terminal-cursor"></span></p>
<a href="/" class="btn btn-primary">Return to Dashboard</a>
</div>
</div>
</body>
</html>

106
templates/workers.html Normal file
View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Workers Overview - Ocean.xyz Pool Mining Dashboard v 0.2{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/workers.css">
{% endblock %}
{% block header %}Workers Overview{% endblock %}
{% block workers_active %}active{% endblock %}
{% block content %}
<!-- Summary statistics -->
<div class="row mb-3">
<div class="col-md-12">
<div class="card">
<div class="card-header">Fleet Summary</div>
<div class="card-body">
<div class="summary-stats">
<div class="summary-stat">
<div class="worker-ring" style="--online-percent: {{ workers_online / workers_total if workers_total > 0 else 0 }}">
<div class="worker-ring-inner">
<span id="workers-count">{{ workers_total }}</span>
</div>
</div>
<div class="summary-stat-label">Workers</div>
<div>
<span class="green-glow" id="workers-online">{{ workers_online }}</span> /
<span class="red-glow" id="workers-offline">{{ workers_offline }}</span>
</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value white-glow" id="total-hashrate">
{% if total_hashrate is defined %}
{{ "%.1f"|format(total_hashrate) }} {{ hashrate_unit }}
{% else %}
N/A
{% endif %}
</div>
<div class="summary-stat-label">Total Hashrate</div>
<div class="mini-chart">
<canvas id="total-hashrate-chart"></canvas>
</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value green-glow" id="total-earnings">
{% if total_earnings is defined %}
{{ "%.8f"|format(total_earnings) }} BTC
{% else %}
N/A
{% endif %}
</div>
<div class="summary-stat-label">Lifetime Earnings</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value yellow-glow" id="daily-sats">
{% if daily_sats is defined %}
{{ daily_sats|commafy }} sats
{% else %}
N/A
{% endif %}
</div>
<div class="summary-stat-label">Daily Sats</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value white-glow" id="avg-acceptance-rate">
{% if avg_acceptance_rate is defined %}
{{ "%.2f"|format(avg_acceptance_rate) }}%
{% else %}
N/A
{% endif %}
</div>
<div class="summary-stat-label">Acceptance Rate</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Controls bar -->
<div class="controls-bar">
<input type="text" class="search-box" id="worker-search" placeholder="Search workers...">
<div class="filter-buttons">
<button class="filter-button active" data-filter="all">All Workers</button>
<button class="filter-button" data-filter="online">Online</button>
<button class="filter-button" data-filter="offline">Offline</button>
<button class="filter-button" data-filter="asic">ASIC</button>
<button class="filter-button" data-filter="fpga">FPGA</button>
</div>
</div>
<!-- Workers grid -->
<div class="worker-grid" id="worker-grid">
<!-- Worker cards will be generated here via JavaScript -->
</div>
{% endblock %}
{% block javascript %}
<script src="/static/js/workers.js"></script>
{% endblock %}

314
worker_service.py Normal file
View File

@ -0,0 +1,314 @@
"""
Worker service module for managing workers data.
"""
import logging
import random
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
class WorkerService:
"""Service for generating and managing worker data."""
def __init__(self):
"""Initialize the worker service."""
self.worker_data_cache = None
self.last_worker_data_update = None
self.WORKER_DATA_CACHE_TIMEOUT = 60 # Cache worker data for 60 seconds
def generate_default_workers_data(self):
"""
Generate default worker data when no metrics are available.
Returns:
dict: Default worker data structure
"""
return {
"workers": [],
"workers_total": 0,
"workers_online": 0,
"workers_offline": 0,
"total_hashrate": 0.0,
"hashrate_unit": "TH/s",
"total_earnings": 0.0,
"daily_sats": 0,
"avg_acceptance_rate": 0.0,
"hashrate_history": [],
"timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
def get_workers_data(self, cached_metrics, force_refresh=False):
"""
Get worker data with caching for better performance.
Args:
cached_metrics (dict): Cached metrics from the dashboard
force_refresh (bool): Whether to force a refresh of cached data
Returns:
dict: Worker data
"""
current_time = datetime.now().timestamp()
# Return cached data if it's still fresh and not forced to refresh
if not force_refresh and self.worker_data_cache and self.last_worker_data_update and \
(current_time - self.last_worker_data_update) < self.WORKER_DATA_CACHE_TIMEOUT:
logging.info("Using cached worker data")
return self.worker_data_cache
try:
# If metrics aren't available yet, return default data
if not cached_metrics:
return self.generate_default_workers_data()
# Check if we have workers_hashing information
workers_count = cached_metrics.get("workers_hashing", 0)
if workers_count <= 0:
return self.generate_default_workers_data()
# Get hashrate from cached metrics - using EXACT value
# Store this ORIGINAL value to ensure it's never changed in calculations
original_hashrate_3hr = float(cached_metrics.get("hashrate_3hr", 0) or 0)
hashrate_unit = cached_metrics.get("hashrate_3hr_unit", "TH/s")
# Generate worker data based on the number of active workers
workers_data = self.generate_workers_data(workers_count, original_hashrate_3hr, hashrate_unit)
# Calculate basic statistics
workers_online = len([w for w in workers_data if w['status'] == 'online'])
workers_offline = len(workers_data) - workers_online
# MODIFIED: Use unpaid_earnings from main dashboard instead of calculating from workers
unpaid_earnings = cached_metrics.get("unpaid_earnings", 0)
# Handle case where unpaid_earnings might be a string
if isinstance(unpaid_earnings, str):
try:
# Handle case where it might include "BTC" or other text
unpaid_earnings = float(unpaid_earnings.split()[0].replace(',', ''))
except (ValueError, IndexError):
unpaid_earnings = 0
# Use unpaid_earnings as total_earnings
total_earnings = unpaid_earnings
# Debug log
logging.info(f"Using unpaid_earnings as total_earnings: {unpaid_earnings} BTC")
avg_acceptance_rate = sum([float(w.get('acceptance_rate', 0) or 0) for w in workers_data]) / len(workers_data) if workers_data else 0
# IMPORTANT: Use the EXACT original value for total_hashrate
# Do NOT recalculate it from worker data
total_hashrate = original_hashrate_3hr
# Daily sats from main dashboard
daily_sats = cached_metrics.get("daily_mined_sats", 0)
# Create hashrate history based on arrow_history if available
hashrate_history = []
if cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
hashrate_history = cached_metrics["arrow_history"]["hashrate_3hr"]
result = {
"workers": workers_data,
"workers_total": len(workers_data),
"workers_online": workers_online,
"workers_offline": workers_offline,
"total_hashrate": total_hashrate, # EXACT value from main dashboard
"hashrate_unit": hashrate_unit,
"total_earnings": total_earnings, # Now using unpaid_earnings
"daily_sats": daily_sats,
"avg_acceptance_rate": avg_acceptance_rate,
"hashrate_history": hashrate_history,
"timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
# Update cache
self.worker_data_cache = result
self.last_worker_data_update = current_time
return result
except Exception as e:
logging.error(f"Error getting worker data: {e}")
return self.generate_default_workers_data()
def generate_workers_data(self, num_workers, total_hashrate, hashrate_unit, total_unpaid_earnings=None):
"""
Generate simulated worker data based on total hashrate, ensuring total matches exactly.
Also distributes unpaid earnings proportionally when provided.
Args:
num_workers (int): Number of workers
total_hashrate (float): Total hashrate
hashrate_unit (str): Hashrate unit
total_unpaid_earnings (float, optional): Total unpaid earnings
Returns:
list: List of worker data dictionaries
"""
# Worker model types for simulation
models = [
{"type": "ASIC", "model": "Bitmain Antminer S19 Pro", "max_hashrate": 110, "power": 3250},
{"type": "ASIC", "model": "MicroBT Whatsminer M50S", "max_hashrate": 130, "power": 3276},
{"type": "ASIC", "model": "Bitmain Antminer S19j Pro", "max_hashrate": 104, "power": 3150},
{"type": "FPGA", "model": "BitAxe FPGA Miner", "max_hashrate": 3.2, "power": 35}
]
# Worker names for simulation
prefixes = ["Antminer", "Whatsminer", "Miner", "Rig", "Node", "Worker", "BitAxe", "BTC"]
# Calculate hashrate distribution - majority of hashrate to online workers
online_count = max(1, int(num_workers * 0.8)) # At least 1 online worker
offline_count = num_workers - online_count
# Average hashrate per online worker
avg_hashrate = total_hashrate / online_count if online_count > 0 else 0
workers = []
current_time = datetime.now(ZoneInfo("America/Los_Angeles"))
# Default total unpaid earnings if not provided
if total_unpaid_earnings is None or total_unpaid_earnings <= 0:
total_unpaid_earnings = 0.001 # Default small amount
# Generate online workers
for i in range(online_count):
# Select a model based on hashrate
model_info = models[0] if avg_hashrate > 50 else models[-1] if avg_hashrate < 5 else random.choice(models)
# For Antminers and regular ASICs, use ASIC model
if i < online_count - 1 or avg_hashrate > 5:
model_idx = random.randint(0, len(models) - 2) # Exclude FPGA for most workers
else:
model_idx = len(models) - 1 # FPGA for last worker if small hashrate
model_info = models[model_idx]
# Generate hashrate with some random variation
base_hashrate = min(model_info["max_hashrate"], avg_hashrate * random.uniform(0.5, 1.5))
hashrate_60sec = round(base_hashrate * random.uniform(0.9, 1.1), 2)
hashrate_3hr = round(base_hashrate * random.uniform(0.85, 1.0), 2)
# Generate last share time (within last 5 minutes)
minutes_ago = random.randint(0, 5)
last_share = (current_time - timedelta(minutes=minutes_ago)).strftime("%Y-%m-%d %H:%M")
# Generate acceptance rate (95-100%)
acceptance_rate = round(random.uniform(95, 100), 1)
# Generate temperature (normal operating range)
temperature = random.randint(55, 70) if model_info["type"] == "ASIC" else random.randint(45, 55)
# Create a unique name
if model_info["type"] == "FPGA":
name = f"{prefixes[-1]}{random.randint(1, 99):02d}"
else:
name = f"{random.choice(prefixes[:-1])}{random.randint(1, 99):02d}"
workers.append({
"name": name,
"status": "online",
"type": model_info["type"],
"model": model_info["model"],
"hashrate_60sec": hashrate_60sec,
"hashrate_60sec_unit": hashrate_unit,
"hashrate_3hr": hashrate_3hr,
"hashrate_3hr_unit": hashrate_unit,
"efficiency": round(random.uniform(65, 95), 1),
"last_share": last_share,
"earnings": 0, # Will be set after all workers are generated
"acceptance_rate": acceptance_rate,
"power_consumption": model_info["power"],
"temperature": temperature
})
# Generate offline workers
for i in range(offline_count):
# Select a model - more likely to be FPGA for offline
if random.random() > 0.6:
model_info = models[-1] # FPGA
else:
model_info = random.choice(models[:-1]) # ASIC
# Generate last share time (0.5 to 8 hours ago)
hours_ago = random.uniform(0.5, 8)
last_share = (current_time - timedelta(hours=hours_ago)).strftime("%Y-%m-%d %H:%M")
# Generate hashrate (historical before going offline)
if model_info["type"] == "FPGA":
hashrate_3hr = round(random.uniform(1, 3), 2)
else:
hashrate_3hr = round(random.uniform(20, 90), 2)
# Create a unique name
if model_info["type"] == "FPGA":
name = f"{prefixes[-1]}{random.randint(1, 99):02d}"
else:
name = f"{random.choice(prefixes[:-1])}{random.randint(1, 99):02d}"
workers.append({
"name": name,
"status": "offline",
"type": model_info["type"],
"model": model_info["model"],
"hashrate_60sec": 0,
"hashrate_60sec_unit": hashrate_unit,
"hashrate_3hr": hashrate_3hr,
"hashrate_3hr_unit": hashrate_unit,
"efficiency": 0,
"last_share": last_share,
"earnings": 0, # Minimal earnings for offline workers
"acceptance_rate": round(random.uniform(95, 99), 1),
"power_consumption": 0,
"temperature": 0
})
# --- NEW CODE FOR HASHRATE ALIGNMENT ---
# Calculate the current sum of online worker hashrates
current_total = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
# If we have online workers and the total doesn't match, apply a scaling factor
if online_count > 0 and abs(current_total - total_hashrate) > 0.01:
scaling_factor = total_hashrate / current_total if current_total > 0 else 1
# Apply scaling to all online workers
for worker in workers:
if worker["status"] == "online":
# Scale the 3hr hashrate to exactly match total
worker["hashrate_3hr"] = round(worker["hashrate_3hr"] * scaling_factor, 2)
# Scale the 60sec hashrate proportionally
if worker["hashrate_60sec"] > 0:
worker["hashrate_60sec"] = round(worker["hashrate_60sec"] * scaling_factor, 2)
# --- NEW CODE TO DISTRIBUTE UNPAID EARNINGS PROPORTIONALLY ---
# First calculate the total effective hashrate (only from online workers)
total_effective_hashrate = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
# Reserve a small portion (5%) of earnings for offline workers
online_earnings_pool = total_unpaid_earnings * 0.95
offline_earnings_pool = total_unpaid_earnings * 0.05
# Distribute earnings based on hashrate proportion for online workers
if total_effective_hashrate > 0:
for worker in workers:
if worker["status"] == "online":
hashrate_proportion = worker["hashrate_3hr"] / total_effective_hashrate
worker["earnings"] = round(online_earnings_pool * hashrate_proportion, 8)
# Distribute minimal earnings to offline workers
if offline_count > 0:
offline_per_worker = offline_earnings_pool / offline_count
for worker in workers:
if worker["status"] == "offline":
worker["earnings"] = round(offline_per_worker, 8)
# Final verification - ensure total earnings match
current_total_earnings = sum(w["earnings"] for w in workers)
if abs(current_total_earnings - total_unpaid_earnings) > 0.00000001:
# Adjust the first worker to account for any rounding errors
adjustment = total_unpaid_earnings - current_total_earnings
for worker in workers:
if worker["status"] == "online":
worker["earnings"] = round(worker["earnings"] + adjustment, 8)
break
return workers