Add files via upload

This commit is contained in:
DJObleezy 2025-04-09 11:46:24 -07:00 committed by GitHub
parent f73a1825b6
commit 4be19833d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 13289 additions and 0 deletions

868
App.py Normal file
View File

@ -0,0 +1,868 @@
"""
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
from notification_service import NotificationService, NotificationLevel, NotificationCategory
# Import custom modules
from config import load_config, save_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)
# Initialize notification service after state_manager
notification_service = NotificationService(state_manager)
# --- 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
logging.info("Starting update_metrics_job")
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
logging.info(f"Updated last_metrics_update_time: {last_metrics_update_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:
logging.info("Fetched metrics successfully")
# First check for notifications by comparing new metrics with old cached metrics
notification_service.check_and_generate_notifications(metrics, cached_metrics)
# Then update cached metrics after comparison
cached_metrics = metrics
# Update state history (only once)
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()
logging.info(f"Updated scheduler_last_successful_run: {scheduler_last_successful_run}")
# 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
logging.info("Pruning old data")
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
logging.info("Saving graph state")
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())
logging.info("Completed update_metrics_job")
# --- 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)
# Add this new route to App.py
@app.route("/blocks")
def blocks_page():
"""Serve the blocks overview page."""
current_time = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M:%S %p")
return render_template("blocks.html", current_time=current_time)
# --- 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()
})
# --- New Config Endpoints ---
@app.route("/api/config", methods=["GET"])
def get_config():
"""API endpoint to get current configuration."""
try:
config = load_config()
return jsonify(config)
except Exception as e:
logging.error(f"Error getting configuration: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/config", methods=["POST"])
def update_config():
"""API endpoint to update configuration."""
global dashboard_service, worker_service # Add this to access the global dashboard_service
try:
# Get the request data
new_config = request.json
logging.info(f"Received config update request: {new_config}")
# Validate the configuration data
if not isinstance(new_config, dict):
logging.error("Invalid configuration format")
return jsonify({"error": "Invalid configuration format"}), 400
# Required fields and default values
defaults = {
"wallet": "yourwallethere",
"power_cost": 0.0,
"power_usage": 0.0
}
# Merge new config with defaults for any missing fields
for key, value in defaults.items():
if key not in new_config or new_config[key] is None:
new_config[key] = value
# Save the configuration
logging.info(f"Saving configuration: {new_config}")
if save_config(new_config):
# Important: Reinitialize the dashboard service with the new configuration
dashboard_service = MiningDashboardService(
new_config.get("power_cost", 0.0),
new_config.get("power_usage", 0.0),
new_config.get("wallet")
)
logging.info(f"Dashboard service reinitialized with new wallet: {new_config.get('wallet')}")
# Update worker service to use the new dashboard service (with the updated wallet)
worker_service.set_dashboard_service(dashboard_service)
logging.info(f"Worker service updated with the new dashboard service")
# Force a metrics update to reflect the new configuration
update_metrics_job(force=True)
logging.info("Forced metrics update after configuration change")
# Return success response with the saved configuration
return jsonify({
"status": "success",
"message": "Configuration saved successfully",
"config": new_config
})
else:
logging.error("Failed to save configuration")
return jsonify({"error": "Failed to save configuration"}), 500
except Exception as e:
logging.error(f"Error updating configuration: {e}")
return jsonify({"error": str(e)}), 500
# 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.route("/api/notifications")
def api_notifications():
"""API endpoint for notification data."""
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
category = request.args.get('category')
level = request.args.get('level')
notifications = notification_service.get_notifications(
limit=limit,
offset=offset,
unread_only=unread_only,
category=category,
level=level
)
unread_count = notification_service.get_unread_count()
return jsonify({
"notifications": notifications,
"unread_count": unread_count,
"total": len(notifications),
"limit": limit,
"offset": offset
})
@app.route("/api/notifications/unread_count")
def api_unread_count():
"""API endpoint for unread notification count."""
return jsonify({
"unread_count": notification_service.get_unread_count()
})
@app.route("/api/notifications/mark_read", methods=["POST"])
def api_mark_read():
"""API endpoint to mark notifications as read."""
notification_id = request.json.get('notification_id')
success = notification_service.mark_as_read(notification_id)
return jsonify({
"success": success,
"unread_count": notification_service.get_unread_count()
})
@app.route("/api/notifications/delete", methods=["POST"])
def api_delete_notification():
"""API endpoint to delete a notification."""
notification_id = request.json.get('notification_id')
if not notification_id:
return jsonify({"error": "notification_id is required"}), 400
success = notification_service.delete_notification(notification_id)
return jsonify({
"success": success,
"unread_count": notification_service.get_unread_count()
})
@app.route("/api/notifications/clear", methods=["POST"])
def api_clear_notifications():
"""API endpoint to clear notifications."""
category = request.json.get('category')
older_than_days = request.json.get('older_than_days')
cleared_count = notification_service.clear_notifications(
category=category,
older_than_days=older_than_days
)
return jsonify({
"success": True,
"cleared_count": cleared_count,
"unread_count": notification_service.get_unread_count()
})
# Add notifications page route
@app.route("/notifications")
def notifications_page():
"""Serve the notifications page."""
current_time = datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M:%S %p")
return render_template("notifications.html", current_time=current_time)
@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)
# Update this section in App.py to properly initialize services
# 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()
# Connect the services
worker_service.set_dashboard_service(dashboard_service)
# 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)

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 DJObleezy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

216
README.md Normal file
View File

@ -0,0 +1,216 @@
# Ocean.xyz Bitcoin Mining Dashboard
## A Retro Mining Monitoring Solution
This open-source dashboard provides real-time monitoring for Ocean.xyz pool miners, offering detailed insights on hashrate, profitability, worker status, and network metrics. Designed with a retro terminal aesthetic and focused on reliability, it helps miners maintain complete oversight of their operations.
---
## Gallery:
![boot](https://github.com/user-attachments/assets/52d787ab-10d9-4c36-9cba-3ed8878dfa2b)
![dashboard](https://github.com/user-attachments/assets/7b35ecbc-6775-4298-a68c-67c01f23ce69)
![workers](https://github.com/user-attachments/assets/ca66d504-9086-4413-acfb-9592d4c57f98)
![blocks](https://github.com/user-attachments/assets/66bc198d-10cc-4302-a89b-fc8c3d796680)
![notifications](https://github.com/user-attachments/assets/cb191fc5-fa85-49a6-a155-459c68008b8f)
---
## Key Features
### Real-Time Mining Metrics
- **Live Hashrate Tracking**: Monitor 60-second, 10-minute, 3-hour, and 24-hour average hashrates
- **Profitability Analysis**: View daily and monthly earnings in both BTC and USD
- **Financial Calculations**: Automatically calculate revenue, power costs, and net profit
- **Network Statistics**: Track current Bitcoin price, difficulty, and network hashrate
- **Payout Monitoring**: View unpaid balance and estimated time to next payout
### Worker Management
- **Fleet Overview**: Comprehensive view of all mining devices in one interface
- **Status Monitoring**: Real-time status indicators for online and offline devices
- **Performance Data**: Individual hashrate, temperature, and acceptance rate metrics
- **Filtering Options**: Sort and search by device type or operational status
### Bitcoin Block Explorer
- **Recent Blocks**: View the latest blocks added to the blockchain
- **Block Details**: Examine transaction counts, fees, and mining pool information
- **Visual Indicators**: Track network difficulty and block discovery times
### System Resilience
- **Connection Recovery**: Automatic reconnection after network interruptions
- **Backup Polling**: Fallback to traditional polling if real-time connection fails
- **Cross-Tab Synchronization**: Data consistency across multiple browser tabs
- **Server Health Monitoring**: Built-in watchdog processes ensure reliability
### Distinctive Design Elements
- **Retro Terminal Aesthetic**: Nostalgic interface with modern functionality
- **Boot Sequence Animation**: Engaging initialization sequence on startup
- **System Monitor**: Floating status display with uptime and refresh information
- **Responsive Interface**: Adapts to desktop and mobile devices
## Quick Start
### Installation
1. Clone the repository
```
git clone https://github.com/yourusername/bitcoin-mining-dashboard.git
cd bitcoin-mining-dashboard
```
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Run the setup script:
```
python setup.py
```
4. Configure your mining settings:
```json
{
"power_cost": 0.12,
"power_usage": 3450,
"wallet": "yourwallethere" <--- make sure to replace this value in all project files (boot.html, app.py, config.py, config.json, & setup.py)
}
```
5. Start the application:
```
python App.py
```
6. Open your browser at `http://localhost:5000`
### Docker Deployment
```bash
docker build -t bitcoin-mining-dashboard .
docker run -d -p 5000:5000 \
-e WALLET=your-wallet-address \
-e POWER_COST=0.12 \
-e POWER_USAGE=3450 \
bitcoin-mining-dashboard
```
For detailed deployment instructions with Redis persistence and Gunicorn configuration, see [deployment_steps.md](deployment_steps.md).
## Dashboard Components
### Main Dashboard
- Interactive hashrate visualization with trend analysis
- Real-time profitability metrics with cost calculations
- Network statistics with difficulty and price tracking
- Payout information with estimation timing
- Visual indicators for metric changes
### Workers Dashboard
- Fleet summary with aggregate statistics
- Individual worker cards with detailed metrics
- Status indicators with color-coded alerts
- Search and filtering functionality
- Performance trend mini-charts
### Blocks Explorer
- Recent block visualization with mining details
- Transaction statistics and fee information
- Mining pool attribution
- Block details modal with comprehensive data
### System Monitor
- Floating interface providing system statistics
- Progress indicator for data refresh cycles
- System uptime display
- Real-time connection status
## System Requirements
The application is designed for efficient resource utilization:
- **Server**: Any system capable of running Python 3.9+
- **Memory**: Minimal requirements (~100MB RAM)
- **Storage**: Less than 50MB for application files
- **Database**: Optional Redis for persistent state
- **Compatible with**: Windows, macOS, and Linux
## Technical Architecture
Built with a modern stack for reliability and performance:
- **Backend**: Flask with Server-Sent Events for real-time updates
- **Frontend**: Vanilla JavaScript with Chart.js for visualization
- **Data Processing**: Concurrent API calls with smart caching
- **Resilience**: Automatic recovery mechanisms and state persistence
- **Configuration**: Environment variables and JSON-based settings
## Project Structure
The project follows a modular architecture with clear separation of concerns:
```
bitcoin-mining-dashboard/
├── App.py # Main application entry point
├── config.py # Configuration management
├── config.json # Configuration file
├── data_service.py # Service for fetching mining data
├── models.py # Data models
├── state_manager.py # Manager for persistent state
├── worker_service.py # Service for worker data management
├── setup.py # Setup script for organizing files
├── requirements.txt # Python dependencies
├── Dockerfile # Docker configuration
├── templates/ # HTML templates
│ ├── base.html # Base template with common elements
│ ├── boot.html # Boot sequence animation
│ ├── dashboard.html # Main dashboard template
│ ├── workers.html # Workers dashboard template
│ ├── blocks.html # Bitcoin blocks template
│ └── error.html # Error page template
├── static/ # Static assets
│ ├── css/ # CSS files
│ │ ├── common.css # Shared styles across all pages
│ │ ├── dashboard.css # Main dashboard styles
│ │ ├── workers.css # Workers page styles
│ │ ├── boot.css # Boot sequence styles
│ │ ├── blocks.css # Blocks page styles
│ │ ├── error.css # Error page styles
│ │ └── retro-refresh.css # Floating refresh bar styles
│ │
│ └── js/ # JavaScript files
│ ├── main.js # Main dashboard functionality
│ ├── workers.js # Workers page functionality
│ ├── blocks.js # Blocks page functionality
│ ├── block-animation.js # Block mining animation
│ └── BitcoinProgressBar.js # System monitor functionality
├── deployment_steps.md # Deployment guide
└── project_structure.md # Additional structure documentation
```
For more detailed information on the architecture and component interactions, see [project_structure.md](project_structure.md).
## Troubleshooting
For optimal performance:
1. Ensure your wallet address is correctly configured
2. Check network connectivity for consistent updates
3. Use the system monitor to verify connection status
4. Access the health endpoint at `/api/health` for diagnostics
5. For stale data issues, use the Force Refresh function
## License
Available under the MIT License. This is an independent project not affiliated with Ocean.xyz.
## Acknowledgments
- Ocean.xyz mining pool for their service
- The open-source community for their contributions
- Bitcoin protocol developers

5
config.json Normal file
View File

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

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": "yourwallethere"
}
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)

951
data_service.py Normal file
View File

@ -0,0 +1,951 @@
"""
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, WorkerData, 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 debug_dump_table(self, table_element, max_rows=3):
"""
Helper method to dump the structure of an HTML table for debugging.
Args:
table_element: BeautifulSoup element representing the table
max_rows (int): Maximum number of rows to output
"""
if not table_element:
logging.debug("Table element is None - cannot dump structure")
return
try:
rows = table_element.find_all('tr', class_='table-row')
logging.debug(f"Found {len(rows)} rows in table")
# Dump header row if present
header_row = table_element.find_parent('table').find('thead')
if header_row:
header_cells = header_row.find_all('th')
header_texts = [cell.get_text(strip=True) for cell in header_cells]
logging.debug(f"Header: {header_texts}")
# Dump a sample of the data rows
for i, row in enumerate(rows[:max_rows]):
cells = row.find_all('td', class_='table-cell')
cell_texts = [cell.get_text(strip=True) for cell in cells]
logging.debug(f"Row {i}: {cell_texts}")
# Also look at raw HTML for problematic cells
for j, cell in enumerate(cells):
logging.debug(f"Row {i}, Cell {j} HTML: {cell}")
except Exception as e:
logging.error(f"Error dumping table structure: {e}")
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
def get_all_worker_rows(self):
"""
Iterate through wpage parameter values to collect all worker table rows.
Returns:
list: A list of BeautifulSoup row elements containing worker data.
"""
all_rows = []
page_num = 0
while True:
url = f"https://ocean.xyz/stats/{self.wallet}?wpage={page_num}#workers-fulltable"
logging.info(f"Fetching worker data from: {url}")
response = self.session.get(url, timeout=15)
if not response.ok:
logging.error(f"Error fetching page {page_num}: status code {response.status_code}")
break
soup = BeautifulSoup(response.text, 'html.parser')
workers_table = soup.find('tbody', id='workers-tablerows')
if not workers_table:
logging.debug(f"No workers table found on page {page_num}")
break
rows = workers_table.find_all("tr", class_="table-row")
if not rows:
logging.debug(f"No worker rows found on page {page_num}, stopping pagination")
break
logging.info(f"Found {len(rows)} worker rows on page {page_num}")
all_rows.extend(rows)
page_num += 1
return all_rows
def get_worker_data(self):
"""
Get worker data from Ocean.xyz using multiple parsing strategies.
Tries different approaches to handle changes in the website structure.
Validates worker names to ensure they're not status indicators.
Returns:
dict: Worker data dictionary with stats and list of workers
"""
logging.info("Attempting to get worker data from Ocean.xyz")
# First try the alternative method as it's more robust
result = self.get_worker_data_alternative()
# Check if alternative method succeeded and found workers with valid names
if result and result.get('workers') and len(result['workers']) > 0:
# Validate workers - check for invalid names
has_valid_workers = False
for worker in result['workers']:
name = worker.get('name', '').lower()
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
has_valid_workers = True
break
if has_valid_workers:
logging.info(f"Alternative worker data method successful: {len(result['workers'])} workers with valid names")
return result
else:
logging.warning("Alternative method found workers but with invalid names")
# If alternative method failed or found workers with invalid names, try the original method
logging.info("Trying original worker data method")
result = self.get_worker_data_original()
# Check if original method succeeded and found workers with valid names
if result and result.get('workers') and len(result['workers']) > 0:
# Validate workers - check for invalid names
has_valid_workers = False
for worker in result['workers']:
name = worker.get('name', '').lower()
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
has_valid_workers = True
break
if has_valid_workers:
logging.info(f"Original worker data method successful: {len(result['workers'])} workers with valid names")
return result
else:
logging.warning("Original method found workers but with invalid names")
# If both methods failed or found workers with invalid names, use fallback data
logging.warning("Both worker data fetch methods failed to get valid names, using fallback data")
# Try to get worker count from cached metrics
workers_count = 0
if hasattr(self, 'cached_metrics') and self.cached_metrics:
workers_count = self.cached_metrics.get('workers_hashing', 0)
# If no cached metrics, try to get from somewhere else
if workers_count <= 0 and result and result.get('workers_total'):
workers_count = result.get('workers_total')
# Ensure we have at least 1 worker
workers_count = max(1, workers_count)
logging.info(f"Using fallback data generation with {workers_count} workers")
return None
# Rename the original method to get_worker_data_original
def get_worker_data_original(self):
"""
Original implementation to get worker data from Ocean.xyz.
Returns:
dict: Worker data dictionary with stats and list of workers
"""
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'
}
try:
logging.info(f"Fetching worker data from {stats_url}")
response = self.session.get(stats_url, headers=headers, timeout=15)
if not response.ok:
logging.error(f"Error fetching ocean worker data: status code {response.status_code}")
return None
soup = BeautifulSoup(response.text, 'html.parser')
# Parse worker data from the workers table
workers = []
total_hashrate = 0
total_earnings = 0
workers_table = soup.find('tbody', id='workers-tablerows')
if not workers_table:
logging.error("Workers table not found in Ocean.xyz page")
return None
# Debug: Dump table structure to help diagnose parsing issues
self.debug_dump_table(workers_table)
# Find total worker counts
workers_online = 0
workers_offline = 0
avg_acceptance_rate = 95.0 # Default value
# Iterate through worker rows in the table
for row in workers_table.find_all('tr', class_='table-row'):
cells = row.find_all('td', class_='table-cell')
# Skip rows that don't have enough cells for basic info
if len(cells) < 3:
logging.warning(f"Worker row has too few cells: {len(cells)}")
continue
try:
# Extract worker name from the first cell
name_cell = cells[0]
name_text = name_cell.get_text(strip=True)
# Skip the total row
if name_text.lower() == 'total':
logging.debug("Skipping total row")
continue
logging.debug(f"Processing worker: {name_text}")
# Create worker object with safer extraction
worker = {
"name": name_text.strip(),
"status": "offline", # Default to offline
"type": "ASIC", # Default type
"model": "Unknown",
"hashrate_60sec": 0,
"hashrate_60sec_unit": "TH/s",
"hashrate_3hr": 0,
"hashrate_3hr_unit": "TH/s",
"efficiency": 90.0, # Default efficiency
"last_share": "N/A",
"earnings": 0,
"acceptance_rate": 95.0, # Default acceptance rate
"power_consumption": 0,
"temperature": 0
}
# Parse status from second cell if available
if len(cells) > 1:
status_cell = cells[1]
status_text = status_cell.get_text(strip=True).lower()
worker["status"] = "online" if "online" in status_text else "offline"
# Update counter based on status
if worker["status"] == "online":
workers_online += 1
else:
workers_offline += 1
# Parse last share time
if len(cells) > 2:
last_share_cell = cells[2]
worker["last_share"] = last_share_cell.get_text(strip=True)
# Parse 60sec hashrate if available
if len(cells) > 3:
hashrate_60s_cell = cells[3]
hashrate_60s_text = hashrate_60s_cell.get_text(strip=True)
# Parse hashrate_60sec and unit with more robust handling
try:
parts = hashrate_60s_text.split()
if parts and len(parts) > 0:
# First part should be the number
try:
numeric_value = float(parts[0])
worker["hashrate_60sec"] = numeric_value
# Second part should be the unit if it exists
if len(parts) > 1 and 'btc' not in parts[1].lower():
worker["hashrate_60sec_unit"] = parts[1]
except ValueError:
# If we can't convert to float, it might be a non-numeric value
logging.warning(f"Could not parse 60s hashrate value: {parts[0]}")
except Exception as e:
logging.error(f"Error parsing 60s hashrate '{hashrate_60s_text}': {e}")
# Parse 3hr hashrate if available
if len(cells) > 4:
hashrate_3hr_cell = cells[4]
hashrate_3hr_text = hashrate_3hr_cell.get_text(strip=True)
# Parse hashrate_3hr and unit with more robust handling
try:
parts = hashrate_3hr_text.split()
if parts and len(parts) > 0:
# First part should be the number
try:
numeric_value = float(parts[0])
worker["hashrate_3hr"] = numeric_value
# Second part should be the unit if it exists
if len(parts) > 1 and 'btc' not in parts[1].lower():
worker["hashrate_3hr_unit"] = parts[1]
# Add to total hashrate (normalized to TH/s for consistency)
total_hashrate += convert_to_ths(worker["hashrate_3hr"], worker["hashrate_3hr_unit"])
except ValueError:
# If we can't convert to float, it might be a non-numeric value
logging.warning(f"Could not parse 3hr hashrate value: {parts[0]}")
except Exception as e:
logging.error(f"Error parsing 3hr hashrate '{hashrate_3hr_text}': {e}")
# Parse earnings if available
if len(cells) > 5:
earnings_cell = cells[5]
earnings_text = earnings_cell.get_text(strip=True)
# Parse earnings with more robust handling
try:
# Remove BTC or other text, keep only the number
earnings_value = earnings_text.replace('BTC', '').strip()
try:
worker["earnings"] = float(earnings_value)
total_earnings += worker["earnings"]
except ValueError:
logging.warning(f"Could not parse earnings value: {earnings_value}")
except Exception as e:
logging.error(f"Error parsing earnings '{earnings_text}': {e}")
# Set worker type based on name (if it can be inferred)
lower_name = worker["name"].lower()
if 'antminer' in lower_name:
worker["type"] = 'ASIC'
worker["model"] = 'Bitmain Antminer'
elif 'whatsminer' in lower_name:
worker["type"] = 'ASIC'
worker["model"] = 'MicroBT Whatsminer'
elif 'bitaxe' in lower_name or 'nerdqaxe' in lower_name:
worker["type"] = 'Bitaxe'
worker["model"] = 'BitAxe Gamma 601'
workers.append(worker)
except Exception as e:
logging.error(f"Error parsing worker row: {e}")
continue
# Get daily sats from the ocean data
daily_sats = 0
try:
# Try to get this from the payoutsnap card
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 and "earnings per day" in label_div.get_text(strip=True).lower():
value_span = label_div.find_next('span')
if value_span:
value_text = value_span.get_text(strip=True)
try:
btc_per_day = float(value_text.split()[0])
daily_sats = int(btc_per_day * self.sats_per_btc)
except (ValueError, IndexError):
pass
except Exception as e:
logging.error(f"Error parsing daily sats: {e}")
# Check if we found any workers
if not workers:
logging.warning("No workers found in the table, possibly a parsing issue")
return None
# Return worker stats dictionary
result = {
'workers': workers,
'total_hashrate': total_hashrate,
'hashrate_unit': 'TH/s', # Always use TH/s for consistent display
'workers_total': len(workers),
'workers_online': workers_online,
'workers_offline': workers_offline,
'total_earnings': total_earnings,
'avg_acceptance_rate': avg_acceptance_rate,
'daily_sats': daily_sats,
'timestamp': datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
logging.info(f"Successfully retrieved worker data: {len(workers)} workers")
return result
except Exception as e:
logging.error(f"Error fetching Ocean worker data: {e}")
import traceback
logging.error(traceback.format_exc())
return None
def get_worker_data_alternative(self):
"""
Alternative implementation to get worker data from Ocean.xyz.
This version consolidates worker rows from all pages using the wpage parameter.
Returns:
dict: Worker data dictionary with stats and list of workers.
"""
try:
logging.info("Fetching worker data across multiple pages (alternative method)")
# Get all worker rows from every page
rows = self.get_all_worker_rows()
if not rows:
logging.error("No worker rows found across any pages")
return None
workers = []
total_hashrate = 0
total_earnings = 0
workers_online = 0
workers_offline = 0
invalid_names = ['online', 'offline', 'status', 'worker', 'total']
# Process each row from all pages
for row_idx, row in enumerate(rows):
cells = row.find_all(['td', 'th'])
if not cells or len(cells) < 3:
continue
first_cell_text = cells[0].get_text(strip=True)
if first_cell_text.lower() in invalid_names:
continue
try:
worker_name = first_cell_text or f"Worker_{row_idx+1}"
worker = {
"name": worker_name,
"status": "online", # Default assumption
"type": "ASIC",
"model": "Unknown",
"hashrate_60sec": 0,
"hashrate_60sec_unit": "TH/s",
"hashrate_3hr": 0,
"hashrate_3hr_unit": "TH/s",
"efficiency": 90.0,
"last_share": "N/A",
"earnings": 0,
"acceptance_rate": 95.0,
"power_consumption": 0,
"temperature": 0
}
# Extract status from second cell if available
if len(cells) > 1:
status_text = cells[1].get_text(strip=True).lower()
worker["status"] = "online" if "online" in status_text else "offline"
if worker["status"] == "online":
workers_online += 1
else:
workers_offline += 1
# Parse last share from third cell if available
if len(cells) > 2:
worker["last_share"] = cells[2].get_text(strip=True)
# Parse 60sec hashrate from fourth cell if available
if len(cells) > 3:
hashrate_60s_text = cells[3].get_text(strip=True)
try:
parts = hashrate_60s_text.split()
if parts:
worker["hashrate_60sec"] = float(parts[0])
if len(parts) > 1:
worker["hashrate_60sec_unit"] = parts[1]
except ValueError:
logging.warning(f"Could not parse 60-sec hashrate: {hashrate_60s_text}")
# Parse 3hr hashrate from fifth cell if available
if len(cells) > 4:
hashrate_3hr_text = cells[4].get_text(strip=True)
try:
parts = hashrate_3hr_text.split()
if parts:
worker["hashrate_3hr"] = float(parts[0])
if len(parts) > 1:
worker["hashrate_3hr_unit"] = parts[1]
# Normalize and add to total hashrate (using your convert_to_ths helper)
total_hashrate += convert_to_ths(worker["hashrate_3hr"], worker["hashrate_3hr_unit"])
except ValueError:
logging.warning(f"Could not parse 3hr hashrate: {hashrate_3hr_text}")
# Look for earnings in any cell containing 'btc'
for cell in cells:
cell_text = cell.get_text(strip=True)
if "btc" in cell_text.lower():
try:
earnings_match = re.search(r'([\d\.]+)', cell_text)
if earnings_match:
worker["earnings"] = float(earnings_match.group(1))
total_earnings += worker["earnings"]
except Exception:
pass
# Set worker type based on name
lower_name = worker["name"].lower()
if 'antminer' in lower_name:
worker["type"] = 'ASIC'
worker["model"] = 'Bitmain Antminer'
elif 'whatsminer' in lower_name:
worker["type"] = 'ASIC'
worker["model"] = 'MicroBT Whatsminer'
elif 'bitaxe' in lower_name or 'nerdqaxe' in lower_name:
worker["type"] = 'Bitaxe'
worker["model"] = 'BitAxe Gamma 601'
if worker["name"].lower() not in invalid_names:
workers.append(worker)
except Exception as e:
logging.error(f"Error parsing worker row: {e}")
continue
if not workers:
logging.error("No valid worker data parsed")
return None
result = {
'workers': workers,
'total_hashrate': total_hashrate,
'hashrate_unit': 'TH/s',
'workers_total': len(workers),
'workers_online': workers_online,
'workers_offline': workers_offline,
'total_earnings': total_earnings,
'avg_acceptance_rate': 99.0,
'timestamp': datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
logging.info(f"Successfully retrieved {len(workers)} workers across multiple pages")
return result
except Exception as e:
logging.error(f"Error in alternative worker data fetch: {e}")
return None

384
deployment_steps.md Normal file
View File

@ -0,0 +1,384 @@
# Deployment Guide
This guide provides comprehensive instructions for deploying the Bitcoin Mining Dashboard application in various environments, from development to production.
## Prerequisites
- Python 3.9 or higher
- Redis server (optional, for persistent state and improved reliability)
- Docker and Docker Compose (optional, for containerized deployment)
- Network access to Ocean.xyz API endpoints
- Modern web browser (Chrome, Firefox, Edge recommended)
## Installation Options
### Option 1: Standard Installation (Development)
1. Clone the repository:
```bash
git clone https://github.com/yourusername/bitcoin-mining-dashboard.git
cd bitcoin-mining-dashboard
```
2. Create a virtual environment (recommended):
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Run the setup script to organize files:
```bash
python setup.py
```
5. Configure your mining parameters in `config.json`:
```json
{
"power_cost": 0.12, // Cost of electricity per kWh
"power_usage": 3450, // Power consumption in watts
"wallet": "your-wallet-address" // Your Ocean.xyz wallet
}
```
6. Start the application:
```bash
python App.py
```
7. Access the dashboard at `http://localhost:5000`
### Option 2: Production Deployment with Gunicorn
For better performance and reliability in production environments:
1. Follow steps 1-5 from standard installation
2. Install Gunicorn if not already installed:
```bash
pip install gunicorn
```
3. Start with Gunicorn:
```bash
gunicorn -b 0.0.0.0:5000 App:app --workers=1 --threads=12 --timeout=600 --keep-alive=5
```
> **Important**: Use only 1 worker to maintain shared state. Use threads for concurrency.
4. For a more robust setup, create a systemd service:
```bash
sudo nano /etc/systemd/system/mining-dashboard.service
```
Add the following content:
```
[Unit]
Description=Bitcoin Mining Dashboard
After=network.target
[Service]
User=your_username
WorkingDirectory=/path/to/bitcoin-mining-dashboard
ExecStart=/path/to/venv/bin/gunicorn -b 0.0.0.0:5000 App:app --workers=1 --threads=12 --timeout=600 --keep-alive=5
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
5. Enable and start the service:
```bash
sudo systemctl enable mining-dashboard
sudo systemctl start mining-dashboard
```
### Option 3: Docker Deployment
1. Build the Docker image:
```bash
docker build -t bitcoin-mining-dashboard .
```
2. Run the container:
```bash
docker run -d -p 5000:5000 \
-e WALLET=your-wallet-address \
-e POWER_COST=0.12 \
-e POWER_USAGE=3450 \
-v $(pwd)/logs:/app/logs \
--name mining-dashboard \
bitcoin-mining-dashboard
```
3. Access the dashboard at `http://localhost:5000`
### Option 4: Docker Compose with Redis Persistence
1. Create a `docker-compose.yml` file:
```yaml
version: '3'
services:
redis:
image: redis:alpine
restart: unless-stopped
volumes:
- redis_data:/data
dashboard:
build: .
restart: unless-stopped
ports:
- "5000:5000"
environment:
- REDIS_URL=redis://redis:6379
- WALLET=your-wallet-address
- POWER_COST=0.12
- POWER_USAGE=3450
volumes:
- ./logs:/app/logs
depends_on:
- redis
volumes:
redis_data:
```
2. Launch the services:
```bash
docker-compose up -d
```
3. Access the dashboard at `http://localhost:5000`
## Environment Variables
The application can be configured using environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `REDIS_URL` | Redis connection URL for persistent state | None |
| `WALLET` | Ocean.xyz wallet address | From config.json |
| `POWER_COST` | Electricity cost per kWh | From config.json |
| `POWER_USAGE` | Power consumption in watts | From config.json |
| `FLASK_ENV` | Application environment | development |
| `LOG_LEVEL` | Logging level | INFO |
| `PORT` | Application port | 5000 |
## Reverse Proxy Configuration
For production deployments, it's recommended to use a reverse proxy like Nginx:
1. Install Nginx:
```bash
sudo apt update
sudo apt install nginx
```
2. Create a configuration file:
```bash
sudo nano /etc/nginx/sites-available/mining-dashboard
```
3. Add the following configuration:
```
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
}
```
4. Create a symbolic link:
```bash
sudo ln -s /etc/nginx/sites-available/mining-dashboard /etc/nginx/sites-enabled/
```
5. Test and restart Nginx:
```bash
sudo nginx -t
sudo systemctl restart nginx
```
6. (Optional) Add SSL with Certbot:
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
```
## Maintenance
### Logs
Logs are stored in the `logs` directory by default. Monitor these logs for errors and warnings:
```bash
tail -f logs/dashboard.log
```
Common log patterns to watch for:
- `ERROR fetching metrics` - Indicates issues with Ocean.xyz API
- `Failed to connect to Redis` - Redis connection problems
- `Scheduler stopped unexpectedly` - Background job issues
### Health Monitoring
#### Health Check Endpoint
A health check endpoint is available at `/api/health` that returns:
- Application status (healthy, degraded, unhealthy)
- Uptime information
- Memory usage
- Data freshness
- Redis connection status
- Scheduler status
Example health check command:
```bash
curl http://localhost:5000/api/health | jq
```
#### Scheduler Health
To monitor the scheduler:
```bash
curl http://localhost:5000/api/scheduler-health | jq
```
### Performance Tuning
1. **Redis Configuration**: For high-traffic deployments, tune Redis:
```
maxmemory 256mb
maxmemory-policy allkeys-lru
```
2. **Gunicorn Threads**: Adjust thread count based on CPU cores:
```
--threads=$(( 2 * $(nproc) ))
```
3. **Browser Cache Headers**: Already optimized in the application
## Troubleshooting
### Common Issues
1. **Application not updating data**:
- Check network connectivity to Ocean.xyz
- Verify scheduler health:
```bash
curl http://localhost:5000/api/scheduler-health
```
- Force a data refresh:
```bash
curl -X POST http://localhost:5000/api/force-refresh
```
2. **High memory usage**:
- Check for memory leaks in log files
- Restart the application
- Enable Redis for better state management
3. **Scheduler failures**:
- Fix the scheduler:
```bash
curl -X POST http://localhost:5000/api/fix-scheduler
```
4. **Workers not showing**:
- Verify your wallet address is correct
- Check worker data:
```bash
curl http://localhost:5000/api/workers
```
### Recovery Procedures
If the application becomes unresponsive:
1. Check the logs for error messages
2. Restart the application:
```bash
sudo systemctl restart mining-dashboard
```
3. If Redis is used and may be corrupted:
```bash
sudo systemctl restart redis
```
4. For Docker deployments:
```bash
docker-compose restart
```
## Updating
To update the application:
1. Pull the latest changes:
```bash
git pull origin main
```
2. Update dependencies:
```bash
pip install -r requirements.txt --upgrade
```
3. Run the setup script:
```bash
python setup.py
```
4. Restart the application:
```bash
sudo systemctl restart mining-dashboard
```
### Docker Update Procedure
1. Pull the latest changes:
```bash
git pull origin main
```
2. Rebuild and restart:
```bash
docker-compose build
docker-compose up -d
```
## Backup Strategy
1. **Configuration**: Regularly backup your `config.json` file
2. **Redis Data**: If using Redis, set up regular RDB snapshots
3. **Logs**: Implement log rotation and archiving
## Security Recommendations
1. **Run as Non-Root User**: Always run the application as a non-root user
2. **Firewall Configuration**: Restrict access to ports 5000 and 6379 (Redis)
3. **Redis Authentication**: Enable Redis password authentication:
```
requirepass your_strong_password
```
4. **HTTPS**: Use SSL/TLS for all production deployments
5. **Regular Updates**: Keep all dependencies updated

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
version: '3'
services:
redis:
image: redis:alpine
restart: unless-stopped
volumes:
- redis_data:/data
ports:
- "6379:6379"
dashboard:
build: .
restart: unless-stopped
ports:
- "5000:5000"
environment:
- REDIS_URL=redis://redis:6379
- WALLET=bc1py5zmrtssheq3shd8cptpl5l5m3txxr5afynyg2gyvam6w78s4dlqqnt4v9
- POWER_COST=0
- POWER_USAGE=0
- LOG_LEVEL=INFO
volumes:
- ./logs:/app/logs
depends_on:
- redis
volumes:
redis_data:

69
dockerfile Normal file
View File

@ -0,0 +1,69 @@
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
# Run the minifier to process HTML templates
RUN python minify.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"]

33
minify.py Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
import os
import jsmin
def minify_js_files():
"""Minify JavaScript files."""
js_dir = 'static/js'
min_dir = os.path.join(js_dir, 'min')
os.makedirs(min_dir, exist_ok=True)
minified_count = 0
for js_file in os.listdir(js_dir):
if js_file.endswith('.js') and not js_file.endswith('.min.js'):
input_path = os.path.join(js_dir, js_file)
output_path = os.path.join(min_dir, js_file.replace('.js', '.min.js'))
with open(input_path, 'r') as f:
js_content = f.read()
# Minify the content
minified = jsmin.jsmin(js_content)
# Write minified content
with open(output_path, 'w') as f:
f.write(minified)
minified_count += 1
print(f"Minified {js_file}")
print(f"Total files minified: {minified_count}")
if __name__ == "__main__":
minify_js_files()

90
models.py Normal file
View File

@ -0,0 +1,90 @@
"""
Data models for the Bitcoin Mining Dashboard.
"""
from dataclasses import dataclass
import logging
@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
@dataclass
class WorkerData:
"""Data structure for individual worker information."""
name: str = None
status: str = "offline"
type: str = "ASIC" # ASIC or Bitaxe
model: str = "Unknown"
hashrate_60sec: float = 0
hashrate_60sec_unit: str = "TH/s"
hashrate_3hr: float = 0
hashrate_3hr_unit: str = "TH/s"
efficiency: float = 0
last_share: str = "N/A"
earnings: float = 0
acceptance_rate: float = 0
power_consumption: float = 0
temperature: float = 0
def convert_to_ths(value, unit):
"""
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
"""
if value is None or value == 0:
return 0
try:
unit = unit.lower() if unit else 'th/s'
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 'kh/s' in unit:
return value / 1000000000 # 1 TH/s = 1,000,000,000 KH/s
elif 'h/s' in unit and not any(prefix in unit for prefix in ['th/s', 'ph/s', 'eh/s', 'gh/s', 'mh/s', 'kh/s']):
return value / 1000000000000 # 1 TH/s = 1,000,000,000,000 H/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
except Exception as e:
logging.error(f"Error in convert_to_ths: {e}")
return value # Return original value as fallback

513
notification_service.py Normal file
View File

@ -0,0 +1,513 @@
# notification_service.py
import logging
import json
import time
import uuid
from datetime import datetime, timedelta
from enum import Enum
from collections import deque
class NotificationLevel(Enum):
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
class NotificationCategory(Enum):
HASHRATE = "hashrate"
BLOCK = "block"
WORKER = "worker"
EARNINGS = "earnings"
SYSTEM = "system"
class NotificationService:
"""Service for managing mining dashboard notifications."""
def __init__(self, state_manager):
"""Initialize with state manager for persistence."""
self.state_manager = state_manager
self.notifications = []
self.daily_stats_time = "00:00:00" # When to post daily stats (midnight)
self.last_daily_stats = None
self.max_notifications = 100 # Maximum number to store
self.last_block_height = None # Track the last seen block height
self.last_payout_notification_time = None # Track the last payout notification time
self.last_estimated_payout_time = None # Track the last estimated payout time
# Load existing notifications from state
self._load_notifications()
# Load last block height from state
self._load_last_block_height()
def _load_notifications(self):
"""Load notifications from persistent storage."""
try:
stored_notifications = self.state_manager.get_notifications()
if stored_notifications:
self.notifications = stored_notifications
logging.info(f"Loaded {len(self.notifications)} notifications from storage")
except Exception as e:
logging.error(f"Error loading notifications: {e}")
def _load_last_block_height(self):
"""Load last block height from persistent storage."""
try:
if hasattr(self.state_manager, 'redis_client') and self.state_manager.redis_client:
# Use Redis if available
last_height = self.state_manager.redis_client.get("last_block_height")
if last_height:
self.last_block_height = last_height.decode('utf-8')
logging.info(f"Loaded last block height from storage: {self.last_block_height}")
else:
logging.info("Redis not available, starting with no last block height")
except Exception as e:
logging.error(f"Error loading last block height: {e}")
def _save_last_block_height(self):
"""Save last block height to persistent storage."""
try:
if hasattr(self.state_manager, 'redis_client') and self.state_manager.redis_client and self.last_block_height:
self.state_manager.redis_client.set("last_block_height", str(self.last_block_height))
logging.info(f"Saved last block height to storage: {self.last_block_height}")
except Exception as e:
logging.error(f"Error saving last block height: {e}")
def _save_notifications(self):
"""Save notifications to persistent storage."""
try:
# Prune to max size before saving
if len(self.notifications) > self.max_notifications:
self.notifications = self.notifications[-self.max_notifications:]
self.state_manager.save_notifications(self.notifications)
except Exception as e:
logging.error(f"Error saving notifications: {e}")
def add_notification(self, message, level=NotificationLevel.INFO, category=NotificationCategory.SYSTEM, data=None):
"""
Add a new notification.
Args:
message (str): Notification message text
level (NotificationLevel): Severity level
category (NotificationCategory): Classification category
data (dict, optional): Additional data for the notification
Returns:
dict: The created notification
"""
notification = {
"id": str(uuid.uuid4()),
"timestamp": datetime.now().isoformat(),
"message": message,
"level": level.value,
"category": category.value,
"read": False
}
if data:
notification["data"] = data
self.notifications.append(notification)
self._save_notifications()
logging.info(f"Added notification: {message}")
return notification
def get_notifications(self, limit=50, offset=0, unread_only=False, category=None, level=None):
"""
Get filtered notifications.
Args:
limit (int): Maximum number to return
offset (int): Starting offset for pagination
unread_only (bool): Only return unread notifications
category (str): Filter by category
level (str): Filter by level
Returns:
list: Filtered notifications
"""
filtered = self.notifications
# Apply filters
if unread_only:
filtered = [n for n in filtered if not n.get("read", False)]
if category:
filtered = [n for n in filtered if n.get("category") == category]
if level:
filtered = [n for n in filtered if n.get("level") == level]
# Sort by timestamp (newest first)
filtered = sorted(filtered, key=lambda n: n.get("timestamp", ""), reverse=True)
# Apply pagination
paginated = filtered[offset:offset + limit]
return paginated
def get_unread_count(self):
"""Get count of unread notifications."""
return sum(1 for n in self.notifications if not n.get("read", False))
def mark_as_read(self, notification_id=None):
"""
Mark notification(s) as read.
Args:
notification_id (str, optional): ID of specific notification to mark read,
or None to mark all as read
Returns:
bool: True if successful
"""
if notification_id:
# Mark specific notification as read
for n in self.notifications:
if n.get("id") == notification_id:
n["read"] = True
break
else:
# Mark all as read
for n in self.notifications:
n["read"] = True
self._save_notifications()
return True
def delete_notification(self, notification_id):
"""
Delete a specific notification.
Args:
notification_id (str): ID of notification to delete
Returns:
bool: True if successful
"""
self.notifications = [n for n in self.notifications if n.get("id") != notification_id]
self._save_notifications()
return True
def clear_notifications(self, category=None, older_than_days=None):
"""
Clear notifications.
Args:
category (str, optional): Only clear specific category
older_than_days (int, optional): Only clear notifications older than this
Returns:
int: Number of notifications cleared
"""
original_count = len(self.notifications)
if category and older_than_days:
cutoff_date = datetime.now() - timedelta(days=older_than_days)
self.notifications = [
n for n in self.notifications
if n.get("category") != category or
datetime.fromisoformat(n.get("timestamp", datetime.now().isoformat())) >= cutoff_date
]
elif category:
self.notifications = [n for n in self.notifications if n.get("category") != category]
elif older_than_days:
cutoff_date = datetime.now() - timedelta(days=older_than_days)
self.notifications = [
n for n in self.notifications
if datetime.fromisoformat(n.get("timestamp", datetime.now().isoformat())) >= cutoff_date
]
else:
self.notifications = []
self._save_notifications()
return original_count - len(self.notifications)
def check_and_generate_notifications(self, current_metrics, previous_metrics):
"""
Check metrics and generate notifications for significant events.
"""
new_notifications = []
try:
# Skip if no metrics
if not current_metrics:
logging.warning("No current metrics available, skipping notification checks")
return new_notifications
# Check for block updates (using persistent storage)
last_block_height = current_metrics.get("last_block_height")
if last_block_height and last_block_height != "N/A":
if self.last_block_height is not None and self.last_block_height != last_block_height:
logging.info(f"Block change detected: {self.last_block_height} -> {last_block_height}")
block_notification = self._generate_block_notification(current_metrics)
if block_notification:
new_notifications.append(block_notification)
# Always update the stored last block height when it changes
if self.last_block_height != last_block_height:
self.last_block_height = last_block_height
self._save_last_block_height()
# Regular comparison with previous metrics
if previous_metrics:
# Check for daily stats
if self._should_post_daily_stats():
stats_notification = self._generate_daily_stats(current_metrics)
if stats_notification:
new_notifications.append(stats_notification)
# Check for significant hashrate drop
hashrate_notification = self._check_hashrate_change(current_metrics, previous_metrics)
if hashrate_notification:
new_notifications.append(hashrate_notification)
# Check for earnings and payout progress
earnings_notification = self._check_earnings_progress(current_metrics, previous_metrics)
if earnings_notification:
new_notifications.append(earnings_notification)
return new_notifications
except Exception as e:
logging.error(f"Error generating notifications: {e}")
error_notification = self.add_notification(
f"Error generating notifications: {str(e)}",
level=NotificationLevel.ERROR,
category=NotificationCategory.SYSTEM
)
return [error_notification]
def _should_post_daily_stats(self):
"""Check if it's time to post daily stats based on a time window approach."""
now = datetime.now()
# Target hours for daily stats
target_hours = [0, 6, 12, 18]
current_hour = now.hour
current_minute = now.minute
# If we have a last_daily_stats timestamp
if self.last_daily_stats:
# Check time elapsed since last stats
time_since_last_stats = now - self.last_daily_stats
# Require at least 5 hours between posts (to prevent duplicates)
if time_since_last_stats.total_seconds() < 5 * 3600:
return False
# Check if we're in a target hour and within the first 5 minutes of that hour
if current_hour in target_hours and current_minute < 5:
# Ensure we haven't already posted for this window
last_stats_hour = self.last_daily_stats.hour
if current_hour != last_stats_hour or (now.date() > self.last_daily_stats.date()):
logging.info(f"Posting daily stats at {current_hour}:{current_minute}")
self.last_daily_stats = now
return True
else:
# First time - post if we're near one of our target hours
if current_hour in target_hours and current_minute < 5:
logging.info(f"First time posting daily stats at {current_hour}:{current_minute}")
self.last_daily_stats = now
return True
return False
def _generate_daily_stats(self, metrics):
"""Generate daily stats notification."""
try:
if not metrics:
logging.warning("No metrics available for daily stats")
return None
# Format hashrate with appropriate unit
hashrate_24hr = metrics.get("hashrate_24hr", 0)
hashrate_unit = metrics.get("hashrate_24hr_unit", "TH/s")
# Format daily earnings
daily_mined_sats = metrics.get("daily_mined_sats", 0)
daily_profit_usd = metrics.get("daily_profit_usd", 0)
# Build message
message = f"Daily Mining Summary: {hashrate_24hr} {hashrate_unit} average hashrate, {daily_mined_sats} SATS mined (${daily_profit_usd:.2f})"
# Add notification
logging.info(f"Generating daily stats notification: {message}")
return self.add_notification(
message,
level=NotificationLevel.INFO,
category=NotificationCategory.HASHRATE,
data={
"hashrate": hashrate_24hr,
"unit": hashrate_unit,
"daily_sats": daily_mined_sats,
"daily_profit": daily_profit_usd
}
)
except Exception as e:
logging.error(f"Error generating daily stats notification: {e}")
return None
def _generate_block_notification(self, metrics):
"""Generate notification for a new block found."""
try:
last_block_height = metrics.get("last_block_height", "Unknown")
last_block_earnings = metrics.get("last_block_earnings", "0")
logging.info(f"Generating block notification: height={last_block_height}, earnings={last_block_earnings}")
message = f"New block found by the pool! Block #{last_block_height}, earnings: {last_block_earnings} SATS"
return self.add_notification(
message,
level=NotificationLevel.SUCCESS,
category=NotificationCategory.BLOCK,
data={
"block_height": last_block_height,
"earnings": last_block_earnings
}
)
except Exception as e:
logging.error(f"Error generating block notification: {e}")
return None
def _check_hashrate_change(self, current, previous):
"""Check for significant hashrate changes using 10-minute average."""
try:
# Change from 3hr to 10min hashrate values
current_10min = current.get("hashrate_10min", 0)
previous_10min = previous.get("hashrate_10min", 0)
# Log what we're comparing
logging.debug(f"Comparing 10min hashrates - current: {current_10min}, previous: {previous_10min}")
# Skip if values are missing
if not current_10min or not previous_10min:
logging.debug("Skipping hashrate check - missing values")
return None
# Handle strings with units (e.g., "10.5 TH/s")
if isinstance(current_10min, str):
current_10min = float(current_10min.split()[0])
else:
current_10min = float(current_10min)
if isinstance(previous_10min, str):
previous_10min = float(previous_10min.split()[0])
else:
previous_10min = float(previous_10min)
logging.debug(f"Converted 10min hashrates - current: {current_10min}, previous: {previous_10min}")
# Skip if previous was zero (prevents division by zero)
if previous_10min == 0:
logging.debug("Skipping hashrate check - previous was zero")
return None
# Calculate percentage change
percent_change = ((current_10min - previous_10min) / previous_10min) * 100
logging.debug(f"10min hashrate change: {percent_change:.1f}%")
# Significant decrease (more than 25%)
if percent_change <= -25:
message = f"Significant 10min hashrate drop detected: {abs(percent_change):.1f}% decrease"
logging.info(f"Generating hashrate notification: {message}")
return self.add_notification(
message,
level=NotificationLevel.WARNING,
category=NotificationCategory.HASHRATE,
data={
"previous": previous_10min,
"current": current_10min,
"change": percent_change,
"timeframe": "10min" # Add timeframe to the data
}
)
# Significant increase (more than 25%)
elif percent_change >= 25:
message = f"10min hashrate increase detected: {percent_change:.1f}% increase"
logging.info(f"Generating hashrate notification: {message}")
return self.add_notification(
message,
level=NotificationLevel.SUCCESS,
category=NotificationCategory.HASHRATE,
data={
"previous": previous_10min,
"current": current_10min,
"change": percent_change,
"timeframe": "10min" # Add timeframe to the data
}
)
return None
except Exception as e:
logging.error(f"Error checking hashrate change: {e}")
return None
def _check_earnings_progress(self, current, previous):
"""Check for significant earnings progress or payout approach."""
try:
current_unpaid = float(current.get("unpaid_earnings", "0").split()[0]) if isinstance(current.get("unpaid_earnings"), str) else current.get("unpaid_earnings", 0)
# Check if approaching payout
if current.get("est_time_to_payout"):
est_time = current.get("est_time_to_payout")
# If estimated time is a number of days
if est_time.isdigit() or (est_time[0] == '-' and est_time[1:].isdigit()):
days = int(est_time)
if 0 < days <= 1:
if self._should_send_payout_notification():
message = f"Payout approaching! Estimated within 1 day"
self.last_payout_notification_time = datetime.now()
return self.add_notification(
message,
level=NotificationLevel.SUCCESS,
category=NotificationCategory.EARNINGS,
data={"days_to_payout": days}
)
# If it says "next block"
elif "next block" in est_time.lower():
if self._should_send_payout_notification():
message = f"Payout expected with next block!"
self.last_payout_notification_time = datetime.now()
return self.add_notification(
message,
level=NotificationLevel.SUCCESS,
category=NotificationCategory.EARNINGS,
data={"payout_imminent": True}
)
# Check for payout (unpaid balance reset)
if previous.get("unpaid_earnings"):
previous_unpaid = float(previous.get("unpaid_earnings", "0").split()[0]) if isinstance(previous.get("unpaid_earnings"), str) else previous.get("unpaid_earnings", 0)
# If balance significantly decreased, likely a payout occurred
if previous_unpaid > 0 and current_unpaid < previous_unpaid * 0.5:
message = f"Payout received! Unpaid balance reset from {previous_unpaid} to {current_unpaid} BTC"
return self.add_notification(
message,
level=NotificationLevel.SUCCESS,
category=NotificationCategory.EARNINGS,
data={
"previous_balance": previous_unpaid,
"current_balance": current_unpaid,
"payout_amount": previous_unpaid - current_unpaid
}
)
return None
except Exception as e:
logging.error(f"Error checking earnings progress: {e}")
return None
def _should_send_payout_notification(self):
"""Check if enough time has passed since the last payout notification."""
if self.last_payout_notification_time is None:
return True
time_since_last_notification = datetime.now() - self.last_payout_notification_time
return time_since_last_notification.total_seconds() > 86400 # 1 Day

271
project_structure.md Normal file
View File

@ -0,0 +1,271 @@
# Enhanced Project Structure Documentation
This document provides a comprehensive overview of the Bitcoin Mining Dashboard project architecture, component relationships, and technical design decisions.
## Directory Structure
```
bitcoin-mining-dashboard/
├── App.py # Main application entry point and Flask routes
├── config.py # Configuration management utilities
├── config.json # User configuration file
├── data_service.py # Service for fetching mining/market data
├── models.py # Data models and conversion utilities
├── state_manager.py # Manager for persistent state and history
├── worker_service.py # Service for worker data management
├── templates/ # HTML templates
│ ├── base.html # Base template with common elements
│ ├── boot.html # Boot sequence animation
│ ├── dashboard.html # Main dashboard template
│ ├── workers.html # Workers overview template
│ ├── blocks.html # Bitcoin blocks template
│ └── error.html # Error page template
├── static/ # Static assets
│ ├── css/ # CSS files
│ │ ├── common.css # Shared styles across all pages
│ │ ├── dashboard.css # Main dashboard styles
│ │ ├── workers.css # Workers page styles
│ │ ├── boot.css # Boot sequence styles
│ │ ├── blocks.css # Blocks page styles
│ │ ├── error.css # Error page styles
│ │ └── retro-refresh.css # Floating system monitor styles
│ │
│ └── js/ # JavaScript files
│ ├── main.js # Main dashboard functionality
│ ├── workers.js # Workers page functionality
│ ├── blocks.js # Blocks page functionality
│ ├── block-animation.js # Block mining animation
│ └── BitcoinProgressBar.js # System monitor implementation
├── logs/ # Application logs directory
├── requirements.txt # Python dependencies
├── Dockerfile # Docker configuration
├── setup.py # Setup script for organizing files
├── deployment_steps.md # Deployment documentation
├── project_structure.md # This document
└── README.md # Project overview and instructions
```
## Core Components
### Backend Services
#### App.py
The main Flask application that serves as the entry point. It:
- Initializes the application and its components
- Configures routes and middleware
- Sets up the background scheduler for data updates
- Manages Server-Sent Events (SSE) connections
- Handles error recovery and graceful shutdown
Key features:
- Custom middleware for error handling
- Connection limiting for SSE to prevent resource exhaustion
- Watchdog process for scheduler health
- Metrics caching with controlled update frequency
#### data_service.py
Service responsible for fetching data from external sources:
- Retrieves mining statistics from Ocean.xyz
- Collects Bitcoin network data (price, difficulty, hashrate)
- Calculates profitability metrics
- Handles connection issues and retries
Notable implementations:
- Concurrent API requests using ThreadPoolExecutor
- Multiple parsing strategies for resilience against HTML changes
- Intelligent caching to reduce API load
- Unit normalization for consistent display
#### worker_service.py
Service for managing worker data:
- Fetches worker statistics from Ocean.xyz
- Simulates worker data when real data is unavailable
- Provides filtering and search capabilities
- Tracks worker status and performance
Key features:
- Fallback data generation for testing or connectivity issues
- Smart worker count synchronization
- Hashrate normalization across different units
#### state_manager.py
Manager for application state and history:
- Maintains hashrate history and metrics over time
- Provides persistence via Redis (optional)
- Implements data pruning to prevent memory growth
- Records indicator arrows for value changes
Implementation details:
- Thread-safe collections with locking
- Optimized storage format for Redis
- Data compression techniques for large state objects
- Automatic recovery of critical state
### Frontend Components
#### Templates
The application uses Jinja2 templates with a retro-themed design:
- **base.html**: Defines the common layout, navigation, and includes shared assets
- **dashboard.html**: Main metrics display with hashrate chart and financial calculations
- **workers.html**: Grid layout of worker cards with filtering controls
- **blocks.html**: Bitcoin block explorer with detailed information
- **boot.html**: Animated terminal boot sequence
- **error.html**: Styled error page with technical information
#### JavaScript Modules
Client-side functionality is organized into modular JavaScript files:
- **main.js**: Dashboard functionality, real-time updates, and chart rendering
- **workers.js**: Worker grid rendering, filtering, and mini-chart creation
- **blocks.js**: Block explorer with data fetching from mempool.space
- **block-animation.js**: Interactive block mining animation
- **BitcoinProgressBar.js**: Floating system monitor with uptime and connection status
Key client-side features:
- Real-time data updates via Server-Sent Events (SSE)
- Automatic reconnection with exponential backoff
- Cross-tab synchronization using localStorage
- Data normalization for consistent unit display
- Animated UI elements for status changes
## Architecture Overview
### Data Flow
1. **Data Acquisition**:
- `data_service.py` fetches data from Ocean.xyz and blockchain sources
- Data is normalized, converted, and enriched with calculated metrics
- Results are cached in memory
2. **State Management**:
- `state_manager.py` tracks historical data points
- Maintains arrow indicators for value changes
- Optionally persists state to Redis
3. **Background Updates**:
- Scheduler runs periodic updates (typically once per minute)
- Updates are throttled to prevent API overload
- Watchdog monitors scheduler health
4. **Real-time Distribution**:
- New data is pushed to clients via Server-Sent Events
- Clients process and render updates without page reloads
- Connection management prevents resource exhaustion
5. **Client Rendering**:
- Browser receives and processes JSON updates
- Chart.js visualizes hashrate trends
- DOM updates show changes with visual indicators
- BitcoinProgressBar shows system status
### System Resilience
The application implements multiple resilience mechanisms:
#### Server-Side Resilience
- **Scheduler Recovery**: Auto-detects and restarts failed schedulers
- **Memory Management**: Prunes old data to prevent memory growth
- **Connection Limiting**: Caps maximum concurrent SSE connections
- **Graceful Degradation**: Falls back to simpler data when sources are unavailable
- **Adaptive Parsing**: Multiple strategies to handle API and HTML changes
#### Client-Side Resilience
- **Connection Recovery**: Automatic reconnection with exponential backoff
- **Fallback Polling**: Switches to traditional AJAX if SSE fails
- **Local Storage Synchronization**: Shares data across browser tabs
- **Visibility Handling**: Optimizes updates based on page visibility
### Technical Design Decisions
#### Server-Sent Events vs WebSockets
The application uses SSE instead of WebSockets because:
- Data flow is primarily one-directional (server to client)
- SSE has better reconnection handling
- Simpler implementation without additional dependencies
- Better compatibility with proxy servers
#### Single Worker Model
The application uses a single Gunicorn worker with multiple threads because:
- Shared in-memory state is simpler than distributed state
- Reduces complexity of synchronization
- Most operations are I/O bound, making threads effective
- Typical deployments have moderate user counts
#### Optional Redis Integration
Redis usage is optional because:
- Small deployments don't require persistence
- Makes local development simpler
- Allows for flexible deployment options
#### Hashrate Normalization
All hashrates are normalized to TH/s internally because:
- Provides consistent basis for comparisons
- Simplifies trend calculations and charts
- Allows for unit conversion on display
## Component Interactions
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Ocean.xyz API │ │ blockchain.info │ │ mempool.space │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ data_service.py │
└────────────────────────────────┬───────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ App.py │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ worker_service │ │ state_manager │ │ Background Jobs │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└───────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ Flask Routes & SSE │
└───────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Browser Tab 1 │ │ Browser Tab N │
└─────────────────┘ └─────────────────┘
```
## Performance Considerations
### Memory Usage
- Arrow history is pruned to prevent unbounded growth
- Older data points are stored at reduced resolution
- Regular garbage collection cycles are scheduled
- Memory usage is logged for monitoring
### Network Optimization
- Data is cached to reduce API calls
- Updates are throttled to reasonable frequencies
- SSE connections have a maximum lifetime
- Failed connections use exponential backoff
### Browser Performance
- Charts use optimized rendering with limited animation
- DOM updates are batched where possible
- Data is processed in small chunks
- CSS transitions are used for smooth animations
## Future Enhancement Areas
1. **Database Integration**: Option for SQL database for long-term metrics storage
2. **User Authentication**: Multi-user support with separate configurations
3. **Mining Pool Expansion**: Support for additional mining pools beyond Ocean.xyz
4. **Mobile App**: Dedicated mobile application with push notifications
5. **Advanced Analytics**: Profitability projections and historical analysis

22
requirements.txt Normal file
View File

@ -0,0 +1,22 @@
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
jsmin==3.0.1

444
setup.py Normal file
View File

@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""
Enhanced setup script for Bitcoin Mining Dashboard.
This script prepares the project structure, installs dependencies,
verifies configuration, and provides system checks for optimal operation.
"""
import os
import sys
import shutil
import logging
import argparse
import subprocess
import json
import re
from pathlib import Path
# Configure logging with color support
try:
import colorlog
handler = colorlog.StreamHandler()
handler.setFormatter(colorlog.ColoredFormatter(
'%(log_color)s%(asctime)s - %(levelname)s - %(message)s',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
))
logger = colorlog.getLogger()
logger.addHandler(handler)
logger.setLevel(logging.INFO)
except ImportError:
# Fallback to standard logging if colorlog is not available
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger()
# Directory structure to create
DIRECTORIES = [
'static/css',
'static/js',
'static/img',
'templates',
'logs',
'data' # For temporary data storage
]
# Files to move to their correct locations
FILE_MAPPINGS = {
# CSS files
'common.css': 'static/css/common.css',
'dashboard.css': 'static/css/dashboard.css',
'workers.css': 'static/css/workers.css',
'boot.css': 'static/css/boot.css',
'error.css': 'static/css/error.css',
'retro-refresh.css': 'static/css/retro-refresh.css',
'blocks.css': 'static/css/blocks.css',
# JS files
'main.js': 'static/js/main.js',
'workers.js': 'static/js/workers.js',
'blocks.js': 'static/js/blocks.js',
'block-animation.js': 'static/js/block-animation.js',
'BitcoinProgressBar.js': 'static/js/BitcoinProgressBar.js',
# Template files
'base.html': 'templates/base.html',
'dashboard.html': 'templates/dashboard.html',
'workers.html': 'templates/workers.html',
'boot.html': 'templates/boot.html',
'error.html': 'templates/error.html',
'blocks.html': 'templates/blocks.html',
}
# Default configuration
DEFAULT_CONFIG = {
"power_cost": 0.0,
"power_usage": 0.0,
"wallet": "yourwallethere"
}
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description='Setup the Bitcoin Mining Dashboard')
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
parser.add_argument('--wallet', type=str, help='Set your Ocean.xyz wallet address')
parser.add_argument('--power-cost', type=float, help='Set your electricity cost per kWh')
parser.add_argument('--power-usage', type=float, help='Set your power consumption in watts')
parser.add_argument('--skip-checks', action='store_true', help='Skip dependency checks')
parser.add_argument('--force', action='store_true', help='Force file overwrite')
parser.add_argument('--config', type=str, help='Path to custom config.json')
return parser.parse_args()
def create_directory_structure():
"""Create the necessary directory structure."""
logger.info("Creating directory structure...")
success = True
for directory in DIRECTORIES:
try:
os.makedirs(directory, exist_ok=True)
logger.debug(f"Created directory: {directory}")
except Exception as e:
logger.error(f"Failed to create directory {directory}: {str(e)}")
success = False
if success:
logger.info("✓ Directory structure created successfully")
else:
logger.warning("⚠ Some directories could not be created")
return success
def move_files(force=False):
"""
Move files to their correct locations.
Args:
force (bool): Force overwriting of existing files
"""
logger.info("Moving files to their correct locations...")
success = True
moved_count = 0
skipped_count = 0
missing_count = 0
for source, destination in FILE_MAPPINGS.items():
if os.path.exists(source):
# Create the directory if it doesn't exist
os.makedirs(os.path.dirname(destination), exist_ok=True)
# Check if destination exists and handle according to force flag
if os.path.exists(destination) and not force:
logger.debug(f"Skipped {source} (destination already exists)")
skipped_count += 1
continue
try:
# Copy the file to its destination
shutil.copy2(source, destination)
logger.debug(f"Moved {source} to {destination}")
moved_count += 1
except Exception as e:
logger.error(f"Failed to copy {source} to {destination}: {str(e)}")
success = False
else:
logger.warning(f"Source file not found: {source}")
missing_count += 1
if success:
logger.info(f"✓ File movement completed: {moved_count} moved, {skipped_count} skipped, {missing_count} missing")
else:
logger.warning("⚠ Some files could not be moved")
return success
def validate_wallet_address(wallet):
"""
Validate Bitcoin wallet address format.
Args:
wallet (str): Bitcoin wallet address
Returns:
bool: True if valid, False otherwise
"""
# Basic validation patterns for different Bitcoin address formats
patterns = [
r'^1[a-km-zA-HJ-NP-Z1-9]{25,34}$', # Legacy
r'^3[a-km-zA-HJ-NP-Z1-9]{25,34}$', # P2SH
r'^bc1[a-zA-Z0-9]{39,59}$', # Bech32
r'^bc1p[a-zA-Z0-9]{39,59}$', # Taproot
r'^bc1p[a-z0-9]{73,107}$' # Longform Taproot
]
# Check if the wallet matches any of the patterns
for pattern in patterns:
if re.match(pattern, wallet):
return True
return False
def create_config(args):
"""
Create or update config.json file.
Args:
args: Command line arguments
"""
config_file = args.config if args.config else 'config.json'
config = DEFAULT_CONFIG.copy()
# Load existing config if available
if os.path.exists(config_file):
try:
with open(config_file, 'r') as f:
existing_config = json.load(f)
config.update(existing_config)
logger.info(f"Loaded existing configuration from {config_file}")
except json.JSONDecodeError:
logger.warning(f"Invalid JSON in {config_file}, using default configuration")
except Exception as e:
logger.error(f"Error reading {config_file}: {str(e)}")
# Update config from command line arguments
if args.wallet:
if validate_wallet_address(args.wallet):
config["wallet"] = args.wallet
else:
logger.warning(f"Invalid wallet address format: {args.wallet}")
logger.warning("Using default or existing wallet address")
if args.power_cost is not None:
if args.power_cost >= 0:
config["power_cost"] = args.power_cost
else:
logger.warning("Power cost cannot be negative, using default or existing value")
if args.power_usage is not None:
if args.power_usage >= 0:
config["power_usage"] = args.power_usage
else:
logger.warning("Power usage cannot be negative, using default or existing value")
# Save the configuration
try:
with open(config_file, 'w') as f:
json.dump(config, f, indent=2, sort_keys=True)
logger.info(f"✓ Configuration saved to {config_file}")
except Exception as e:
logger.error(f"Failed to save configuration: {str(e)}")
return False
# Print current configuration
logger.info("Current configuration:")
logger.info(f" ├── Wallet address: {config['wallet']}")
logger.info(f" ├── Power cost: ${config['power_cost']} per kWh")
logger.info(f" └── Power usage: {config['power_usage']} watts")
return True
def check_dependencies(skip=False):
"""
Check if required Python dependencies are installed.
Args:
skip (bool): Skip the dependency check
"""
if skip:
logger.info("Skipping dependency check")
return True
logger.info("Checking dependencies...")
try:
# Check if pip is available
subprocess.run([sys.executable, "-m", "pip", "--version"],
check=True, capture_output=True, text=True)
except Exception as e:
logger.error(f"Pip is not available: {str(e)}")
logger.error("Please install pip before continuing")
return False
# Check if requirements.txt exists
if not os.path.exists('requirements.txt'):
logger.error("requirements.txt not found")
return False
# Check currently installed packages
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "freeze"],
check=True, capture_output=True, text=True
)
installed_output = result.stdout
installed_packages = {
line.split('==')[0].lower(): line.split('==')[1] if '==' in line else ''
for line in installed_output.splitlines()
}
except Exception as e:
logger.error(f"Failed to check installed packages: {str(e)}")
installed_packages = {}
# Read requirements
try:
with open('requirements.txt', 'r') as f:
requirements = f.read().splitlines()
except Exception as e:
logger.error(f"Failed to read requirements.txt: {str(e)}")
return False
# Check each requirement
missing_packages = []
for req in requirements:
if req and not req.startswith('#'):
package = req.split('==')[0].lower()
if package not in installed_packages:
missing_packages.append(req)
if missing_packages:
logger.warning(f"Missing {len(missing_packages)} required packages")
logger.info("Installing missing packages...")
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
check=True, capture_output=True, text=True
)
logger.info("✓ Dependencies installed successfully")
except Exception as e:
logger.error(f"Failed to install dependencies: {str(e)}")
logger.error("Please run: pip install -r requirements.txt")
return False
else:
logger.info("✓ All required packages are installed")
return True
def check_redis():
"""Check if Redis is available."""
logger.info("Checking Redis availability...")
redis_url = os.environ.get("REDIS_URL")
if not redis_url:
logger.info("⚠ Redis URL not configured (REDIS_URL environment variable not set)")
logger.info(" └── The dashboard will run without persistent state")
logger.info(" └── Set REDIS_URL for better reliability")
return True
try:
import redis
client = redis.Redis.from_url(redis_url)
client.ping()
logger.info(f"✓ Successfully connected to Redis at {redis_url}")
return True
except ImportError:
logger.warning("Redis Python package not installed")
logger.info(" └── Run: pip install redis")
return False
except Exception as e:
logger.warning(f"Failed to connect to Redis: {str(e)}")
logger.info(f" └── Check that Redis is running and accessible at {redis_url}")
return False
def perform_system_checks():
"""Perform system checks and provide recommendations."""
logger.info("Performing system checks...")
# Check Python version
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 9):
logger.warning(f"⚠ Python version {python_version} is below recommended (3.9+)")
else:
logger.info(f"✓ Python version {python_version} is compatible")
# Check available memory
try:
import psutil
memory = psutil.virtual_memory()
memory_gb = memory.total / (1024**3)
if memory_gb < 1:
logger.warning(f"⚠ Low system memory: {memory_gb:.2f} GB (recommended: 1+ GB)")
else:
logger.info(f"✓ System memory: {memory_gb:.2f} GB")
except ImportError:
logger.debug("psutil not available, skipping memory check")
# Check write permissions
log_dir = 'logs'
try:
test_file = os.path.join(log_dir, 'test_write.tmp')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
logger.info(f"✓ Write permissions for logs directory")
except Exception as e:
logger.warning(f"⚠ Cannot write to logs directory: {str(e)}")
# Check port availability
port = 5000 # Default port
try:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('localhost', port))
s.close()
logger.info(f"✓ Port {port} is available")
except Exception:
logger.warning(f"⚠ Port {port} is already in use")
logger.info("System checks completed")
def main():
"""Main setup function."""
args = parse_arguments()
# Set logging level
if args.debug:
logger.setLevel(logging.DEBUG)
logger.debug("Debug logging enabled")
logger.info("=== Bitcoin Mining Dashboard Setup ===")
# Check dependencies
if not check_dependencies(args.skip_checks):
logger.error("Dependency check failed. Please install required packages and retry.")
return 1
# Create directory structure
if not create_directory_structure():
logger.error("Failed to create directory structure.")
return 1
# Move files to their correct locations
if not move_files(args.force):
logger.warning("Some files could not be moved, but continuing...")
# Create or update configuration
if not create_config(args):
logger.error("Failed to create configuration file.")
return 1
# Check Redis if available
check_redis()
# Perform system checks
if not args.skip_checks:
perform_system_checks()
logger.info("=== Setup completed successfully ===")
logger.info("")
logger.info("Next steps:")
logger.info("1. Verify configuration in config.json")
logger.info("2. Start the application with: python App.py")
logger.info("3. Access the dashboard at: http://localhost:5000")
return 0
if __name__ == "__main__":
exit_code = main()
sys.exit(exit_code)

468
state_manager.py Normal file
View File

@ -0,0 +1,468 @@
"""
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": entry.get("a", "")} # Use saved arrow value
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[-180:] if len(values) > 180 else values
# Use shorter field names and preserve arrow directions
compact_arrow_history[key] = [
{"t": entry["time"], "v": entry["value"], "a": entry["arrow"]}
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 = ""
# Get the corresponding unit key if available
unit_key = f"{key}_unit"
current_unit = metrics.get(unit_key, "")
if key in arrow_history and arrow_history[key]:
try:
previous_val = arrow_history[key][-1]["value"]
previous_unit = arrow_history[key][-1].get("unit", "")
previous_arrow = arrow_history[key][-1].get("arrow", "") # Get previous arrow
# Use the convert_to_ths function to normalize both values before comparison
if key.startswith("hashrate") and current_unit:
from models import convert_to_ths
norm_curr_val = convert_to_ths(float(current_val), current_unit)
norm_prev_val = convert_to_ths(float(previous_val), previous_unit if previous_unit else "th/s")
# Lower the threshold to 0.05% for more sensitivity
if norm_curr_val > norm_prev_val * 1.0001:
arrow = ""
elif norm_curr_val < norm_prev_val * 0.9999:
arrow = ""
else:
arrow = previous_arrow # Preserve previous arrow if change is insignificant
else:
# For non-hashrate values or when units are missing
# Try to convert to float for comparison
try:
curr_float = float(current_val)
prev_float = float(previous_val)
# Lower the threshold to 0.05% for more sensitivity
if curr_float > prev_float * 1.0001:
arrow = ""
elif curr_float < prev_float * 0.9999:
arrow = ""
else:
arrow = previous_arrow # Preserve previous arrow
except (ValueError, TypeError):
# If values can't be converted to float, compare directly
if current_val != previous_val:
arrow = "" if current_val > previous_val else ""
else:
arrow = previous_arrow # Preserve previous arrow
except Exception as e:
logging.error(f"Error calculating arrow for {key}: {e}")
# Keep previous arrow on error instead of empty string
if arrow_history[key] and arrow_history[key][-1].get("arrow"):
arrow = arrow_history[key][-1]["arrow"]
if key not in arrow_history:
arrow_history[key] = []
if not arrow_history[key] or arrow_history[key][-1]["time"] != current_second:
# Create new entry
entry = {
"time": current_second,
"value": current_val,
"arrow": arrow,
}
# Add unit information if available
if current_unit:
entry["unit"] = current_unit
arrow_history[key].append(entry)
else:
# Update existing entry
arrow_history[key][-1]["value"] = current_val
# Only update arrow if it's not empty - this preserves arrows between changes
if arrow:
arrow_history[key][-1]["arrow"] = arrow
# Update unit if available
if current_unit:
arrow_history[key][-1]["unit"] = current_unit
# 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
# Sort by time to ensure chronological order
aggregated_history[key] = sorted(list(minute_groups.values()),
key=lambda x: x["time"])
# Only keep the most recent 60 data points for the graph display
aggregated_history[key] = aggregated_history[key][-60:] if len(aggregated_history[key]) > 60 else aggregated_history[key]
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:]
def save_notifications(self, notifications):
"""Save notifications to persistent storage."""
try:
# If we have Redis, use it
if self.redis_client:
notifications_json = json.dumps(notifications)
self.redis_client.set("dashboard_notifications", notifications_json)
return True
else:
# Otherwise just keep in memory
return True
except Exception as e:
logging.error(f"Error saving notifications: {e}")
return False
def get_notifications(self):
"""Retrieve notifications from persistent storage."""
try:
# If we have Redis, use it
if self.redis_client:
notifications_json = self.redis_client.get("dashboard_notifications")
if notifications_json:
return json.loads(notifications_json)
# Return empty list if not found or no Redis
return []
except Exception as e:
logging.error(f"Error retrieving notifications: {e}")
return []

380
static/css/blocks.css Normal file
View File

@ -0,0 +1,380 @@
/* Styles specific to the blocks page */
/* Block controls */
.block-controls {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.block-control-item {
display: flex;
align-items: center;
gap: 10px;
}
.block-input {
background-color: var(--bg-color) !important;
border: 1px solid var(--primary-color) !important;
color: var(--text-color);
padding: 5px 10px;
font-family: var(--terminal-font);
width: 150px;
}
.block-input:focus {
outline: none;
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
}
.block-button {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 5px 15px;
font-family: var(--terminal-font);
cursor: pointer;
transition: all 0.2s ease;
}
.block-button:hover {
background-color: var(--primary-color);
color: var(--bg-color);
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
/* Latest block stats */
.latest-block-stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-item strong {
color: #f7931a; /* Use the Bitcoin orange color for labels */
}
/* Blocks grid */
.blocks-container {
overflow-x: auto;
}
.blocks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 15px;
}
.block-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: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.block-card:hover {
box-shadow: 0 0 15px rgba(247, 147, 26, 0.5);
transform: translateY(-2px);
}
.block-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;
}
.block-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.block-height {
font-size: 1.2rem;
font-weight: bold;
color: var(--primary-color);
text-shadow: 0 0 5px rgba(247, 147, 26, 0.5);
}
.block-time {
font-size: 0.9rem;
color: #00dfff;
}
.block-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 15px;
}
.block-info-item {
display: flex;
flex-direction: column;
}
.block-info-label {
font-size: 0.8rem;
color: #aaa;
}
.block-info-value {
font-size: 0.9rem;
}
.block-info-value.yellow {
color: #ffd700;
text-shadow: 0 0 8px rgba(255, 215, 0, 0.6);
}
.block-info-value.green {
color: #32CD32;
text-shadow: 0 0 6px rgba(50, 205, 50, 0.6);
}
.block-info-value.blue {
color: #00dfff;
text-shadow: 0 0 6px rgba(0, 223, 255, 0.6);
}
.block-info-value.white {
color: #ffffff;
text-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
}
.block-info-value.red {
color: #ff5555;
text-shadow: 0 0 6px rgba(255, 85, 85, 0.6);
}
/* Loader */
.loader {
text-align: center;
padding: 20px;
grid-column: 1 / -1;
}
.loader-text {
display: inline-block;
margin-right: 5px;
}
/* Modal styles */
.block-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.8);
}
.block-modal-content {
background-color: var(--bg-color);
margin: 5% auto;
border: 1px solid var(--primary-color);
box-shadow: 0 0 20px rgba(247, 147, 26, 0.5);
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.block-modal-content::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: 0;
}
.block-modal-header {
background-color: #000;
color: var(--primary-color);
font-weight: bold;
padding: 0.5rem 1rem;
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);
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.block-modal-close {
color: var(--primary-color);
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.block-modal-close:hover,
.block-modal-close:focus {
color: #ffa500;
text-shadow: 0 0 10px rgba(255, 165, 0, 0.8);
}
.block-modal-body {
padding: 1rem;
position: relative;
z-index: 1;
}
#block-details {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.block-detail-section {
margin-bottom: 15px;
}
.block-detail-title {
font-size: 1.1rem;
color: var(--primary-color);
margin-bottom: 10px;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.5);
font-weight: bold;
}
.block-detail-item {
margin-bottom: 8px;
}
.block-detail-label {
font-size: 0.9rem;
color: #aaa;
}
.block-detail-value {
font-size: 0.9rem;
word-break: break-all;
}
.block-hash {
font-family: monospace;
font-size: 0.8rem;
color: #00dfff;
word-break: break-all;
}
.transaction-data {
display: flex;
flex-direction: column;
gap: 5px;
}
.fee-bar-container {
height: 5px;
width: 100%;
background-color: rgba(255, 255, 255, 0.1);
margin-top: 5px;
position: relative;
overflow: hidden;
}
.fee-bar {
height: 100%;
width: 0;
background: linear-gradient(90deg, #32CD32, #ffd700);
transition: width 0.5s ease;
}
/* Mining Animation Container */
.mining-animation-container {
padding: 0;
background-color: #0a0a0a;
overflow: hidden;
position: relative;
width: 100%;
}
.mining-animation-container::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;
}
#svg-container {
width: 100%;
height: 300px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center; /* Add this to center vertically if needed */
}
svg {
max-width: 100%;
height: auto;
display: block; /* Ensures proper centering */
}
/* Make sure the SVG itself takes more width */
#block-mining-animation {
width: 100%;
height: 300px;
/* Fixed height but full width */
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.latest-block-stats {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.blocks-grid {
grid-template-columns: 1fr;
}
.block-modal-content {
width: 95%;
margin: 10% auto;
}
#block-details {
grid-template-columns: 1fr;
}
#svg-container {
height: 250px;
}
}

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

@ -0,0 +1,216 @@
/* 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: 35px;
height: 33px;
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;
}

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

@ -0,0 +1,438 @@
/* 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;
--text-transform: uppercase;
}
@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-transform: uppercase;
}
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;
text-transform: uppercase;
}
/* 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: -1px;
animation: glow 3s 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;
position: relative;
top: -1px;
animation: glow 3s 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;
}
.red-glow, .status-red {
color: #ff2d2d !important;
text-shadow: 0 0 2px #ff2d2d, 0 0 2px #ff2d2d;
}
.yellow-glow {
color: #ffd700 !important;
text-shadow: 0 0 2px #ffd700, 0 0 2px #ffd700;
}
.blue-glow {
color: #00dfff !important;
text-shadow: 0 0 2px #00dfff, 0 0 2px #00dfff;
}
.white-glow {
color: #ffffff !important;
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff;
}
/* Basic color classes for backward compatibility */
.green {
color: #39ff14 !important;
text-shadow: 0 0 2px #39ff14, 0 0 2px #39ff14;
}
.blue {
color: #00dfff !important;
text-shadow: 0 0 2px #00dfff, 0 0 2px #00dfff;
}
.yellow {
color: #ffd700 !important;
text-shadow: 0 0 2px #ffd700, 0 0 2px #ffd700;
}
.white {
color: #ffffff !important;
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff;
}
.red {
color: #ff2d2d !important;
text-shadow: 0 0 2px #ff2d2d, 0 0 2px #ff2d2d;
}
.magenta {
color: #ff2d95 !important;
text-shadow: 0 0 2px #ff2d95, 0 0 2px #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;
}
}
/* Navigation badges for notifications */
.nav-badge {
background-color: var(--primary-color);
color: var(--bg-color);
border-radius: 10px;
font-size: 0.7rem;
padding: 1px 5px;
min-width: 16px;
text-align: center;
display: none;
margin-left: 5px;
vertical-align: middle;
}

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

@ -0,0 +1,216 @@
/* 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;
}
/* 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 #32cd32;
}
/* 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);
text-transform: uppercase;
}
/* Add bottom padding to accommodate minimized system monitor */
.container-fluid {
padding-bottom: 60px !important; /* Enough space for minimized monitor */
}
/* Add these styles to dashboard.css */
@keyframes pulse-block-marker {
0% {
transform: translate(-50%, -50%) rotate(45deg) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) rotate(45deg) scale(1.3);
opacity: 0.8;
}
100% {
transform: translate(-50%, -50%) rotate(45deg) scale(1);
opacity: 1;
}
}
.chart-container-relative {
position: relative;
}

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,327 @@
/* notifications.css */
/* Notification Controls */
.notification-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.full-timestamp {
font-size: 0.8em;
color: #888;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.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;
transition: all 0.3s ease;
text-transform: uppercase;
}
.filter-button:hover {
background-color: rgba(247, 147, 26, 0.2);
}
.filter-button.active {
background-color: var(--primary-color);
color: var(--bg-color);
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
.notification-actions {
display: flex;
gap: 5px;
align-items: center;
text-transform: uppercase;
}
.action-button {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 6px 12px;
font-family: var(--terminal-font);
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px; /* Set a minimum width to prevent text cutoff */
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9rem; /* Slightly smaller font */
line-height: 1;
text-transform: uppercase;
}
.action-button:hover {
background-color: rgba(247, 147, 26, 0.2);
}
.action-button.danger {
border-color: #ff5555;
color: #ff5555;
}
.action-button.danger:hover {
background-color: rgba(255, 85, 85, 0.2);
}
/* Card header with unread badge */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.unread-badge {
background-color: var(--primary-color);
color: var(--bg-color);
padding: 2px 8px;
border-radius: 10px;
font-size: 0.8rem;
min-width: 25px;
text-align: center;
}
.unread-badge:empty {
display: none;
}
/* Notifications Container */
#notifications-container {
min-height: 200px;
position: relative;
}
.loading-message {
text-align: center;
padding: 20px;
color: #888;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #888;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}
/* Notification Item */
.notification-item {
display: flex;
padding: 12px;
border-bottom: 1px solid rgba(247, 147, 26, 0.2);
transition: background-color 0.2s ease;
position: relative;
background-color: rgba(0, 0, 0, 0.15);
}
.notification-item:hover {
background-color: rgba(247, 147, 26, 0.05);
}
.notification-item[data-read="true"] {
opacity: 0.6;
}
.notification-item[data-level="success"] {
border-left: 3px solid #32CD32;
}
.notification-item[data-level="info"] {
border-left: 3px solid #00dfff;
}
.notification-item[data-level="warning"] {
border-left: 3px solid #ffd700;
}
.notification-item[data-level="error"] {
border-left: 3px solid #ff5555;
}
.notification-icon {
flex: 0 0 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.notification-item[data-level="success"] .notification-icon i {
color: #32CD32;
}
.notification-item[data-level="info"] .notification-icon i {
color: #00dfff;
}
.notification-item[data-level="warning"] .notification-icon i {
color: #ffd700;
}
.notification-item[data-level="error"] .notification-icon i {
color: #ff5555;
}
.notification-content {
flex: 1;
padding: 0 15px;
}
.notification-message {
margin-bottom: 5px;
word-break: break-word;
color: white;
}
.notification-meta {
font-size: 0.8rem;
color: #888;
display: flex;
gap: 15px;
}
.notification-category {
text-transform: uppercase;
font-size: 0.7rem;
color: #aaa;
}
.notification-actions {
flex: 0 0 80px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.notification-actions button {
background: none;
border: none;
color: #888;
cursor: pointer;
transition: color 0.2s ease;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
.mark-read-button:hover {
color: #32CD32;
background-color: rgba(50, 205, 50, 0.1);
}
.delete-button:hover {
color: #ff5555;
background-color: rgba(255, 85, 85, 0.1);
}
/* Pagination */
.pagination-controls {
margin-top: 15px;
text-align: center;
}
.load-more-button {
background-color: var(--bg-color);
border: 1px solid var(--primary-color);
color: var(--primary-color);
padding: 5px 15px;
font-family: var(--terminal-font);
cursor: pointer;
transition: all 0.3s ease;
}
.load-more-button:hover {
background-color: rgba(247, 147, 26, 0.2);
}
.load-more-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Notification Animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notification-item {
animation: fadeIn 0.3s ease-out;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.notification-actions {
flex-direction: column;
gap: 8px;
}
.action-button {
width: 100%; /* Full width on small screens */
padding: 8px 12px;
font-size: 1rem;
}
.notification-controls {
flex-direction: column;
align-items: stretch;
}
.filter-buttons {
overflow-x: auto;
padding-bottom: 5px;
margin-bottom: 5px;
white-space: nowrap;
display: flex;
flex-wrap: nowrap;
}
.notification-actions {
justify-content: flex-end;
}
.notification-item {
padding: 8px;
}
.notification-icon {
flex: 0 0 30px;
}
.notification-content {
padding: 0 8px;
}
.notification-actions {
flex: 0 0 60px;
}
}

View File

@ -0,0 +1,441 @@
/* 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: 12px;
}
.terminal-dot {
width: 6px;
height: 6px;
}
#show-terminal-button {
padding: 6px 10px;
font-size: 12px;
}
}
/* Add these styles to retro-refresh.css to make the progress bar transitions smoother */
/* Smooth transition for progress bar width */
#retro-terminal-bar #bitcoin-progress-inner {
transition: width 0.3s ease-out;
}
/* Add a will-change property to optimize the animation */
#retro-terminal-bar .bitcoin-progress-container {
will-change: contents;
}
/* Smooth transition when changing from waiting state */
#retro-terminal-bar #bitcoin-progress-inner.waiting-for-update {
transition: width 0.3s ease-out, box-shadow 1s ease;
}
/* Ensure the scan line stays smooth during transitions */
#retro-terminal-bar .scan-line {
will-change: transform;
}
/* Improve mobile centering for collapsed system monitor */
@media (max-width: 767px) {
/* Target both possible selectors to ensure we catch the right one */
#retro-terminal-bar.collapsed,
.bitcoin-terminal.collapsed,
.retro-terminal-bar.collapsed,
div[id*="terminal"].collapsed {
left: 50% !important;
right: auto !important;
transform: translateX(-50%) !important;
width: auto !important;
max-width: 300px !important; /* Smaller max-width for mobile */
}
/* Ensure consistent height for minimized view */
.terminal-minimized {
height: 40px;
display: flex;
align-items: center;
}
}
/* Make the terminal draggable in desktop view */
@media (min-width: 768px) {
/* Target both possible selectors to handle all cases */
#bitcoin-terminal,
.bitcoin-terminal,
#retro-terminal-bar {
cursor: grab; /* Show a grab cursor to indicate draggability */
user-select: none; /* Prevent text selection during drag */
}
/* Change cursor during active dragging */
#bitcoin-terminal.dragging,
.bitcoin-terminal.dragging,
#retro-terminal-bar.dragging {
cursor: grabbing;
}
/* Style for drag handle in the header */
.terminal-header {
cursor: grab;
}
.terminal-header::before {
content: "⋮⋮"; /* Add drag indicator */
position: absolute;
left: 10px;
opacity: 0.5;
}
}

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

@ -0,0 +1,344 @@
/* 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, #ff2d2d, #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;
}
}
/* Add extra padding at bottom of worker grid to avoid overlap */
.worker-grid {
margin-bottom: 120px;
}
/* Ensure summary stats have proper spacing on mobile */
@media (max-width: 576px) {
.summary-stats {
margin-bottom: 60px;
}
}

View File

@ -0,0 +1,168 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 300" width="100%" height="100%">
<!-- Background with scanlines -->
<defs>
<pattern id="scanlines" patternUnits="userSpaceOnUse" width="4" height="4">
<rect width="4" height="2" fill="#000" fill-opacity="0.1"/>
<rect y="2" width="4" height="2" fill="none"/>
</pattern>
<radialGradient id="glowEffect" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stop-color="#f7931a" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#f7931a" stop-opacity="0"/>
</radialGradient>
<filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
<linearGradient id="bitcoinGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f7931a"/>
<stop offset="100%" stop-color="#ffc04a"/>
</linearGradient>
<linearGradient id="transactionGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#39ff14"/>
<stop offset="100%" stop-color="#00ffaa"/>
</linearGradient>
<filter id="pixelate" x="0%" y="0%" width="100%" height="100%">
<feFlood x="4" y="4" height="2" width="2"/>
<feComposite width="10" height="10"/>
</filter>
</defs>
<!-- Main background -->
<rect width="500" height="300" fill="#0a0a0a"/>
<!-- Scanline effect -->
<rect width="500" height="300" fill="url(#scanlines)" opacity="0.15"/>
<!-- Blockchain (rows of connected blocks) -->
<g id="blockchain" transform="translate(20, 270)">
<!-- Previous blocks (part of the chain) -->
<g id="previous-blocks">
<rect x="0" y="-30" width="25" height="25" rx="3" fill="#232323" stroke="#f7931a" stroke-width="1"/>
<rect x="35" y="-30" width="25" height="25" rx="3" fill="#232323" stroke="#f7931a" stroke-width="1"/>
<rect x="70" y="-30" width="25" height="25" rx="3" fill="#232323" stroke="#f7931a" stroke-width="1"/>
<rect x="105" y="-30" width="25" height="25" rx="3" fill="#232323" stroke="#f7931a" stroke-width="1"/>
<!-- Connecting lines -->
<line x1="25" y1="-17.5" x2="35" y2="-17.5" stroke="#f7931a" stroke-width="1"/>
<line x1="60" y1="-17.5" x2="70" y2="-17.5" stroke="#f7931a" stroke-width="1"/>
<line x1="95" y1="-17.5" x2="105" y2="-17.5" stroke="#f7931a" stroke-width="1"/>
<line x1="130" y1="-17.5" x2="140" y2="-17.5" stroke="#f7931a" stroke-width="1" stroke-dasharray="2,2">
<animate attributeName="stroke-dashoffset" from="0" to="4" dur="1s" repeatCount="indefinite"/>
</line>
</g>
<!-- Bitcoin logo -->
<g id="bitcoin-logo" transform="translate(250, -60)">
<circle cx="25" cy="25" r="20" fill="#0a0a0a" stroke="url(#bitcoinGradient)" stroke-width="2" filter="url(#neonGlow)"/>
<text id="bitcoin-symbol" x="25" y="25" font-family="sans-serif" font-size="22" font-weight="bold" fill="url(#bitcoinGradient)" text-anchor="middle" dy=".35em" filter="url(#neonGlow)"></text>
<!-- Rotating glow effect -->
<circle cx="25" cy="25" r="28" fill="none" stroke="url(#bitcoinGradient)" stroke-width="1" opacity="0.5">
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite"/>
<animate attributeName="r" values="28;30;28" dur="3s" repeatCount="indefinite"/>
</circle>
</g>
</g>
<!-- Current block being mined -->
<g id="current-block" transform="translate(140, 240)">
<!-- Block outline -->
<rect id="block-outline" x="0" y="-30" width="70" height="60" rx="3" fill="#131313" stroke="#f7931a" stroke-width="2">
<animate attributeName="stroke-opacity" values="1;0.5;1" dur="2s" repeatCount="indefinite"/>
</rect>
<!-- Block header data -->
<text x="35" y="-20" font-family="monospace" font-size="6" fill="#00dfff" text-anchor="middle" filter="url(#neonGlow)">Block #<tspan id="block-height">000000</tspan></text>
<!-- Block transactions -->
<g id="transactions">
<rect class="transaction" x="5" y="-15" width="60" height="5" rx="1" fill="url(#transactionGradient)" opacity="0.7">
<animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" begin="0s"/>
</rect>
<rect class="transaction" x="5" y="-8" width="60" height="5" rx="1" fill="url(#transactionGradient)" opacity="0.7">
<animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" begin="0.5s"/>
</rect>
<rect class="transaction" x="5" y="-1" width="60" height="5" rx="1" fill="url(#transactionGradient)" opacity="0.7">
<animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" begin="1s"/>
</rect>
<rect class="transaction" x="5" y="6" width="60" height="5" rx="1" fill="url(#transactionGradient)" opacity="0.7">
<animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect class="transaction" x="5" y="13" width="60" height="5" rx="1" fill="url(#transactionGradient)" opacity="0.7">
<animate attributeName="opacity" values="0.7;1;0.7" dur="3s" repeatCount="indefinite" begin="2s"/>
</rect>
<text id="tx-count" x="35" y="23" font-family="monospace" font-size="6" fill="#ffffff" text-anchor="middle">Txs: <tspan id="transaction-count">0000</tspan></text>
</g>
</g>
<!-- Mining animation -->
<g id="mining-animation" transform="translate(250, 180)">
<!-- Mining text -->
<text x="0" y="0" font-family="monospace" font-size="12" fill="#f7931a" filter="url(#neonGlow)">Mining hash:</text>
<text id="mining-hash" x="0" y="15" font-family="monospace" font-size="10" fill="#ffffff">0000...0000</text>
<!-- Nonce counter -->
<text x="0" y="35" font-family="monospace" font-size="10" fill="#00dfff" filter="url(#neonGlow)">Nonce: <tspan id="nonce-value">0000000000</tspan></text>
<!-- Difficulty target -->
<text x="0" y="50" font-family="monospace" font-size="10" fill="#ffd700" filter="url(#neonGlow)">Difficulty: <tspan id="difficulty-value">0000000</tspan></text>
<!-- Mining status -->
<text id="mining-status" x="0" y="70" font-family="monospace" font-size="12" fill="#39ff14" filter="url(#neonGlow)">Mining in progress...</text>
<!-- Hash calculation animation (tiny dots moving) -->
<g id="hash-calculation">
<circle cx="10" cy="85" r="2" fill="#f7931a">
<animate attributeName="cx" values="10;180;10" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0.5;1" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="30" cy="85" r="2" fill="#f7931a">
<animate attributeName="cx" values="30;200;30" dur="3s" repeatCount="indefinite" begin="0.5s"/>
<animate attributeName="opacity" values="1;0.5;1" dur="3s" repeatCount="indefinite" begin="0.5s"/>
</circle>
<circle cx="50" cy="85" r="2" fill="#f7931a">
<animate attributeName="cx" values="50;220;50" dur="3s" repeatCount="indefinite" begin="1s"/>
<animate attributeName="opacity" values="1;0.5;1" dur="3s" repeatCount="indefinite" begin="1s"/>
</circle>
</g>
</g>
<!-- Status display -->
<g id="status-display" transform="translate(20, 20)">
<rect width="460" height="20" fill="#0a0a0a" stroke="#f7931a" stroke-width="1"/>
<text id="status-text" x="10" y="14" font-family="monospace" font-size="12" fill="#ffffff">MINING BLOCK #<tspan id="status-height">000000</tspan> | POOL: <tspan id="mining-pool">Unknown</tspan></text>
<!-- Indicator lights -->
<circle cx="440" cy="10" r="5" fill="#39ff14" filter="url(#neonGlow)">
<animate attributeName="opacity" values="1;0.5;1" dur="2s" repeatCount="indefinite"/>
</circle>
</g>
<!-- Timestamp display -->
<g id="timestamp-display" transform="translate(20, 50)">
<text id="time-text" x="0" y="0" font-family="monospace" font-size="10" fill="#00dfff">Time: <tspan id="block-time">0000-00-00 00:00:00</tspan></text>
</g>
<!-- Animation triggers -->
<script type="text/javascript"><![CDATA[
// This script will be replaced with actual JS implementation
// It will update the animation with real data from the API
]]></script>
<!-- CRT flicker animation for the entire SVG -->
<rect width="500" height="300" fill="none" opacity="0.03">
<animate attributeName="opacity" values="0.03;0.05;0.03" dur="0.5s" repeatCount="indefinite"/>
</rect>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1,770 @@
/**
* BitcoinMinuteRefresh.js - Simplified Bitcoin-themed floating uptime monitor
*
* This module creates a Bitcoin-themed terminal that shows server uptime.
*/
const BitcoinMinuteRefresh = (function () {
// Constants
const STORAGE_KEY = 'bitcoin_last_refresh_time'; // For cross-page sync
// Private variables
let terminalElement = null;
let uptimeElement = null;
let serverTimeOffset = 0;
let serverStartTime = null;
let uptimeInterval = null;
let isInitialized = false;
let refreshCallback = null;
/**
* Add dragging functionality to the terminal
*/
function addDraggingBehavior() {
// Find the terminal element
const terminal = document.getElementById('bitcoin-terminal') ||
document.querySelector('.bitcoin-terminal') ||
document.getElementById('retro-terminal-bar');
if (!terminal) {
console.warn('Terminal element not found for drag behavior');
return;
}
let isDragging = false;
let startX = 0;
let startLeft = 0;
// Function to handle mouse down (drag start)
function handleMouseDown(e) {
// Only enable dragging in desktop view
if (window.innerWidth < 768) return;
// Don't handle drag if clicking on controls
if (e.target.closest('.terminal-dot')) return;
isDragging = true;
terminal.classList.add('dragging');
// Calculate start position
startX = e.clientX;
// Get current left position accounting for different possible styles
const style = window.getComputedStyle(terminal);
if (style.left !== 'auto') {
startLeft = parseInt(style.left) || 0;
} else {
// Calculate from right if left is not set
startLeft = window.innerWidth -
(parseInt(style.right) || 0) -
terminal.offsetWidth;
}
e.preventDefault(); // Prevent text selection
}
// Function to handle mouse move (dragging)
function handleMouseMove(e) {
if (!isDragging) return;
// Calculate the horizontal movement - vertical stays fixed
const deltaX = e.clientX - startX;
let newLeft = startLeft + deltaX;
// Constrain to window boundaries
const maxLeft = window.innerWidth - terminal.offsetWidth;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
// Update position - only horizontally along bottom
terminal.style.left = newLeft + 'px';
terminal.style.right = 'auto'; // Remove right positioning
terminal.style.transform = 'none'; // Remove transformations
}
// Function to handle mouse up (drag end)
function handleMouseUp() {
if (isDragging) {
isDragging = false;
terminal.classList.remove('dragging');
}
}
// Find the terminal header for dragging
const terminalHeader = terminal.querySelector('.terminal-header');
if (terminalHeader) {
terminalHeader.addEventListener('mousedown', handleMouseDown);
} else {
// If no header found, make the whole terminal draggable
terminal.addEventListener('mousedown', handleMouseDown);
}
// Add mousemove and mouseup listeners to document
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Handle window resize to keep terminal visible
window.addEventListener('resize', function () {
if (window.innerWidth < 768) {
// Reset position for mobile view
terminal.style.left = '50%';
terminal.style.right = 'auto';
terminal.style.transform = 'translateX(-50%)';
} else {
// Ensure terminal stays visible in desktop view
const maxLeft = window.innerWidth - terminal.offsetWidth;
const currentLeft = parseInt(window.getComputedStyle(terminal).left) || 0;
if (currentLeft > maxLeft) {
terminal.style.left = maxLeft + 'px';
}
}
});
}
/**
* Create and inject the retro terminal element into the DOM
*/
function createTerminalElement() {
// Container element
terminalElement = document.createElement('div');
terminalElement.id = 'bitcoin-terminal';
terminalElement.className = 'bitcoin-terminal';
// Terminal content - simplified for uptime-only
terminalElement.innerHTML = `
<div class="terminal-header">
<div class="terminal-title">SYSTEM MONITOR v.3</div>
<div class="terminal-controls">
<div class="terminal-dot minimize" title="Minimize" onclick="BitcoinMinuteRefresh.toggleTerminal()"></div>
<div class="terminal-dot close" title="Close" onclick="BitcoinMinuteRefresh.hideTerminal()"></div>
</div>
</div>
<div class="terminal-content">
<div class="status-row">
<div class="status-indicator">
<div class="status-dot connected"></div>
<span>LIVE</span>
</div>
<span id="terminal-clock" class="terminal-clock">00:00:00</span>
</div>
<div id="uptime-timer" class="uptime-timer">
<div class="uptime-title">UPTIME</div>
<div class="uptime-display">
<div class="uptime-value">
<span id="uptime-hours" class="uptime-number">00</span>
<span class="uptime-label">H</span>
</div>
<div class="uptime-separator">:</div>
<div class="uptime-value">
<span id="uptime-minutes" class="uptime-number">00</span>
<span class="uptime-label">M</span>
</div>
<div class="uptime-separator">:</div>
<div class="uptime-value">
<span id="uptime-seconds" class="uptime-number">00</span>
<span class="uptime-label">S</span>
</div>
</div>
</div>
</div>
<div class="terminal-minimized">
<div class="minimized-uptime">
<span class="mini-uptime-label">UPTIME</span>
<span id="minimized-uptime-value">00:00:00</span>
</div>
<div class="minimized-status-dot connected"></div>
</div>
`;
// Append to body
document.body.appendChild(terminalElement);
// Add dragging behavior
addDraggingBehavior();
// Cache element references
uptimeElement = document.getElementById('uptime-timer');
// Check if terminal was previously collapsed
if (localStorage.getItem('bitcoin_terminal_collapsed') === 'true') {
terminalElement.classList.add('collapsed');
}
// Add custom styles if not already present
if (!document.getElementById('bitcoin-terminal-styles')) {
addStyles();
}
}
/**
* Add CSS styles for the terminal
*/
function addStyles() {
const styleElement = document.createElement('style');
styleElement.id = 'bitcoin-terminal-styles';
styleElement.textContent = `
/* Terminal Container */
.bitcoin-terminal {
position: fixed;
bottom: 20px;
right: 20px;
width: 230px;
background-color: #000000;
border: 1px solid #f7931a;
color: #f7931a;
font-family: 'VT323', monospace;
z-index: 9999;
overflow: hidden;
padding: 8px;
transition: all 0.3s ease;
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
}
/* Terminal Header */
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f7931a;
padding-bottom: 5px;
margin-bottom: 8px;
}
.terminal-title {
color: #f7931a;
font-weight: bold;
font-size: 1.1rem;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
animation: terminal-flicker 4s infinite;
}
/* Control Dots */
.terminal-controls {
display: flex;
gap: 5px;
margin-left: 5px;
}
.terminal-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #555;
cursor: pointer;
transition: background-color 0.3s;
}
.terminal-dot.minimize:hover {
background-color: #ffcc00;
}
.terminal-dot.close:hover {
background-color: #ff3b30;
}
/* Terminal Content */
.terminal-content {
position: relative;
}
/* Status Row */
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
/* Status Indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.connected {
background-color: #32CD32;
box-shadow: 0 0 5px #32CD32;
animation: pulse 2s infinite;
}
.terminal-clock {
font-size: 1rem;
font-weight: bold;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.5);
}
/* Uptime Display - Modern Digital Clock Style (Horizontal) */
.uptime-timer {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
background-color: #111;
border: 1px solid rgba(247, 147, 26, 0.5);
margin-top: 5px;
}
.uptime-display {
display: flex;
justify-content: center;
align-items: center;
gap: 2px;
margin-top: 5px;
}
.uptime-value {
display: flex;
align-items: baseline;
}
.uptime-number {
font-size: 1.4rem;
font-weight: bold;
background-color: #000;
padding: 2px 5px;
border-radius: 3px;
min-width: 32px;
display: inline-block;
text-align: center;
letter-spacing: 2px;
text-shadow: 0 0 8px rgba(247, 147, 26, 0.8);
color: #dee2e6;
}
.uptime-label {
font-size: 0.7rem;
opacity: 0.7;
margin-left: 2px;
}
.uptime-separator {
font-size: 1.4rem;
font-weight: bold;
padding: 0 2px;
text-shadow: 0 0 8px rgba(247, 147, 26, 0.8);
}
.uptime-title {
font-size: 0.7rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
margin-bottom: 3px;
}
/* Show button */
#bitcoin-terminal-show {
position: fixed;
bottom: 10px;
right: 10px;
background-color: #f7931a;
color: #000;
border: none;
padding: 8px 12px;
font-family: 'VT323', monospace;
cursor: pointer;
z-index: 9999;
display: none;
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
/* CRT scanline effect */
.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;
}
/* Minimized view styling */
.terminal-minimized {
display: none;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background-color: #000;
position: relative;
}
.terminal-minimized::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;
}
.minimized-uptime {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
}
.mini-uptime-label {
font-size: 0.6rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.7;
margin-left: 45px;
color: #f7931a;
}
#minimized-uptime-value {
font-size: 0.9rem;
font-weight: bold;
text-shadow: 0 0 5px rgba(247, 147, 26, 0.5);
margin-left: 45px;
color: #dee2e6;
}
.minimized-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: 10px;
position: relative;
z-index: 2;
}
/* Collapsed state */
.bitcoin-terminal.collapsed {
width: auto;
max-width: 500px;
height: auto;
padding: 5px;
}
.bitcoin-terminal.collapsed .terminal-content {
display: none;
}
.bitcoin-terminal.collapsed .terminal-minimized {
display: flex;
}
.bitcoin-terminal.collapsed .terminal-header {
border-bottom: none;
margin-bottom: 2px;
padding-bottom: 2px;
}
/* Animations */
@keyframes pulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
@keyframes terminal-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; }
}
/* Media Queries */
@media (max-width: 768px) {
.bitcoin-terminal {
left: 50%;
right: auto;
transform: translateX(-50%);
width: 90%;
max-width: 320px;
bottom: 10px;
}
.bitcoin-terminal.collapsed {
width: auto;
max-width: 300px;
left: 50%;
right: auto;
transform: translateX(-50%);
}
}
`;
document.head.appendChild(styleElement);
}
/**
* Update the terminal clock
*/
function updateClock() {
try {
const now = new Date(Date.now() + (serverTimeOffset || 0));
let hours = now.getHours();
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
const timeString = `${String(hours).padStart(2, '0')}:${minutes}:${seconds} ${ampm}`;
// Update clock in normal view
const clockElement = document.getElementById('terminal-clock');
if (clockElement) {
clockElement.textContent = timeString;
}
} catch (e) {
console.error("BitcoinMinuteRefresh: Error updating clock:", e);
}
}
/**
* Update the uptime display
*/
function updateUptime() {
if (serverStartTime) {
try {
const currentServerTime = Date.now() + serverTimeOffset;
const diff = currentServerTime - serverStartTime;
// Calculate hours, minutes, seconds
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);
// Update the main uptime display with digital clock style
const uptimeHoursElement = document.getElementById('uptime-hours');
const uptimeMinutesElement = document.getElementById('uptime-minutes');
const uptimeSecondsElement = document.getElementById('uptime-seconds');
if (uptimeHoursElement) {
uptimeHoursElement.textContent = String(hours).padStart(2, '0');
}
if (uptimeMinutesElement) {
uptimeMinutesElement.textContent = String(minutes).padStart(2, '0');
}
if (uptimeSecondsElement) {
uptimeSecondsElement.textContent = String(seconds).padStart(2, '0');
}
// Update the minimized uptime display
const minimizedUptimeElement = document.getElementById('minimized-uptime-value');
if (minimizedUptimeElement) {
minimizedUptimeElement.textContent = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
} catch (e) {
console.error("BitcoinMinuteRefresh: Error updating uptime:", e);
}
}
}
/**
* Notify other tabs that data has been refreshed
*/
function notifyRefresh() {
const now = Date.now();
localStorage.setItem(STORAGE_KEY, now.toString());
localStorage.setItem('bitcoin_refresh_event', 'refresh-' + now);
console.log("BitcoinMinuteRefresh: Notified other tabs of refresh at " + new Date(now).toISOString());
}
/**
* Initialize the uptime monitor
*/
function initialize(refreshFunc) {
// Store the refresh callback
refreshCallback = refreshFunc;
// Create the terminal element if it doesn't exist
if (!document.getElementById('bitcoin-terminal')) {
createTerminalElement();
} else {
// Get references to existing elements
terminalElement = document.getElementById('bitcoin-terminal');
uptimeElement = document.getElementById('uptime-timer');
}
// Try to get stored server time information
try {
serverTimeOffset = parseFloat(localStorage.getItem('serverTimeOffset') || '0');
serverStartTime = parseFloat(localStorage.getItem('serverStartTime') || '0');
} catch (e) {
console.error("BitcoinMinuteRefresh: Error reading server time from localStorage:", e);
}
// Clear any existing intervals
if (uptimeInterval) {
clearInterval(uptimeInterval);
}
// Set up interval for updating clock and uptime display
uptimeInterval = setInterval(function () {
updateClock();
updateUptime();
}, 1000); // Update every second is sufficient for uptime display
// Listen for storage events to sync across tabs
window.removeEventListener('storage', handleStorageChange);
window.addEventListener('storage', handleStorageChange);
// Handle visibility changes
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('visibilitychange', handleVisibilityChange);
// Mark as initialized
isInitialized = true;
console.log("BitcoinMinuteRefresh: Initialized");
}
/**
* Handle storage changes for cross-tab synchronization
*/
function handleStorageChange(event) {
if (event.key === 'bitcoin_refresh_event') {
console.log("BitcoinMinuteRefresh: Detected refresh from another tab");
// If another tab refreshed, consider refreshing this one too
// But don't refresh if it was just refreshed recently (5 seconds)
const lastRefreshTime = parseInt(localStorage.getItem(STORAGE_KEY) || '0');
if (typeof refreshCallback === 'function' && Date.now() - lastRefreshTime > 5000) {
refreshCallback();
}
} else if (event.key === 'serverTimeOffset' || event.key === 'serverStartTime') {
try {
serverTimeOffset = parseFloat(localStorage.getItem('serverTimeOffset') || '0');
serverStartTime = parseFloat(localStorage.getItem('serverStartTime') || '0');
} catch (e) {
console.error("BitcoinMinuteRefresh: Error reading updated server time:", e);
}
}
}
/**
* Handle visibility changes
*/
function handleVisibilityChange() {
if (!document.hidden) {
console.log("BitcoinMinuteRefresh: Page became visible, updating");
// Update immediately when page becomes visible
updateClock();
updateUptime();
// Check if we need to do a refresh based on time elapsed
if (typeof refreshCallback === 'function') {
const lastRefreshTime = parseInt(localStorage.getItem(STORAGE_KEY) || '0');
if (Date.now() - lastRefreshTime > 60000) { // More than a minute since last refresh
refreshCallback();
}
}
}
}
/**
* Update server time information
*/
function updateServerTime(timeOffset, startTime) {
serverTimeOffset = timeOffset;
serverStartTime = startTime;
// Store in localStorage for cross-page sharing
localStorage.setItem('serverTimeOffset', serverTimeOffset.toString());
localStorage.setItem('serverStartTime', serverStartTime.toString());
// Update the uptime immediately
updateUptime();
console.log("BitcoinMinuteRefresh: Server time updated - offset:", serverTimeOffset, "ms");
}
/**
* Toggle terminal collapsed state
*/
function toggleTerminal() {
if (!terminalElement) return;
terminalElement.classList.toggle('collapsed');
localStorage.setItem('bitcoin_terminal_collapsed', terminalElement.classList.contains('collapsed'));
}
/**
* Hide the terminal and show the restore button
*/
function hideTerminal() {
if (!terminalElement) return;
terminalElement.style.display = 'none';
// Create show button if it doesn't exist
if (!document.getElementById('bitcoin-terminal-show')) {
const showButton = document.createElement('button');
showButton.id = 'bitcoin-terminal-show';
showButton.textContent = 'Show Monitor';
showButton.onclick = showTerminal;
document.body.appendChild(showButton);
}
document.getElementById('bitcoin-terminal-show').style.display = 'block';
}
/**
* Show the terminal and hide the restore button
*/
function showTerminal() {
if (!terminalElement) return;
terminalElement.style.display = 'block';
document.getElementById('bitcoin-terminal-show').style.display = 'none';
}
// Public API
return {
initialize: initialize,
notifyRefresh: notifyRefresh,
updateServerTime: updateServerTime,
toggleTerminal: toggleTerminal,
hideTerminal: hideTerminal,
showTerminal: showTerminal
};
})();
// Auto-initialize when document is ready if a refresh function is available in the global scope
document.addEventListener('DOMContentLoaded', function () {
// Check if manualRefresh function exists in global scope
if (typeof window.manualRefresh === 'function') {
BitcoinMinuteRefresh.initialize(window.manualRefresh);
} else {
console.log("BitcoinMinuteRefresh: No refresh function found, will need to be initialized manually");
}
});

822
static/js/blocks.js Normal file
View File

@ -0,0 +1,822 @@
"use strict";
// Global variables
let currentStartHeight = null;
const mempoolBaseUrl = "https://mempool.space";
let blocksCache = {};
let isLoading = false;
// DOM ready initialization
$(document).ready(function() {
console.log("Blocks page initialized");
// Initialize notification badge
initNotificationBadge();
// Load the latest blocks on page load
loadLatestBlocks();
// Set up event listeners
$("#load-blocks").on("click", function() {
const height = $("#block-height").val();
if (height && !isNaN(height)) {
loadBlocksFromHeight(height);
} else {
showToast("Please enter a valid block height");
}
});
$("#latest-blocks").on("click", loadLatestBlocks);
// Handle Enter key on the block height input
$("#block-height").on("keypress", function(e) {
if (e.which === 13) {
const height = $(this).val();
if (height && !isNaN(height)) {
loadBlocksFromHeight(height);
} else {
showToast("Please enter a valid block height");
}
}
});
// Close the modal when clicking the X or outside the modal
$(".block-modal-close").on("click", closeModal);
$(window).on("click", function(event) {
if ($(event.target).hasClass("block-modal")) {
closeModal();
}
});
// Initialize BitcoinMinuteRefresh if available
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.initialize) {
BitcoinMinuteRefresh.initialize(loadLatestBlocks);
console.log("BitcoinMinuteRefresh initialized with refresh function");
}
});
// Update unread notifications badge in navigation
function updateNotificationBadge() {
$.ajax({
url: "/api/notifications/unread_count",
method: "GET",
success: function (data) {
const unreadCount = data.unread_count;
const badge = $("#nav-unread-badge");
if (unreadCount > 0) {
badge.text(unreadCount).show();
} else {
badge.hide();
}
}
});
}
// Initialize notification badge checking
function initNotificationBadge() {
// Update immediately
updateNotificationBadge();
// Update every 60 seconds
setInterval(updateNotificationBadge, 60000);
}
// Helper function to format timestamps as readable dates
function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
};
return date.toLocaleString('en-US', options);
}
// Helper function to format numbers with commas
function numberWithCommas(x) {
if (x == null) return "N/A";
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Helper function to format file sizes
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + " B";
else if (bytes < 1048576) return (bytes / 1024).toFixed(2) + " KB";
else return (bytes / 1048576).toFixed(2) + " MB";
}
// Helper function to show toast messages
function showToast(message) {
// Check if we already have a toast container
let toastContainer = $(".toast-container");
if (toastContainer.length === 0) {
// Create a new toast container
toastContainer = $("<div>", {
class: "toast-container",
css: {
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: 9999
}
}).appendTo("body");
}
// Create a new toast
const toast = $("<div>", {
class: "toast",
text: message,
css: {
backgroundColor: "#f7931a",
color: "#000",
padding: "10px 15px",
borderRadius: "5px",
marginTop: "10px",
boxShadow: "0 0 10px rgba(247, 147, 26, 0.5)",
fontFamily: "var(--terminal-font)",
opacity: 0,
transition: "opacity 0.3s ease"
}
}).appendTo(toastContainer);
// Show the toast
setTimeout(() => {
toast.css("opacity", 1);
// Hide and remove the toast after 3 seconds
setTimeout(() => {
toast.css("opacity", 0);
setTimeout(() => toast.remove(), 300);
}, 3000);
}, 100);
}
// Function to load blocks from a specific height
function loadBlocksFromHeight(height) {
if (isLoading) return;
// Convert to integer
height = parseInt(height);
if (isNaN(height) || height < 0) {
showToast("Please enter a valid block height");
return;
}
isLoading = true;
currentStartHeight = height;
// Check if we already have this data in cache
if (blocksCache[height]) {
displayBlocks(blocksCache[height]);
isLoading = false;
return;
}
// Show loading state
$("#blocks-grid").html('<div class="loader"><span class="loader-text">Loading blocks from height ' + height + '<span class="terminal-cursor"></span></span></div>');
// Fetch blocks from the API
$.ajax({
url: `${mempoolBaseUrl}/api/v1/blocks/${height}`,
method: "GET",
dataType: "json",
timeout: 10000,
success: function(data) {
// Cache the data
blocksCache[height] = data;
// Display the blocks
displayBlocks(data);
// Update latest block stats
if (data.length > 0) {
updateLatestBlockStats(data[0]);
}
},
error: function(xhr, status, error) {
console.error("Error fetching blocks:", error);
$("#blocks-grid").html('<div class="error">Error fetching blocks. Please try again later.</div>');
// Show error toast
showToast("Failed to load blocks. Please try again later.");
},
complete: function() {
isLoading = false;
}
});
}
// Function to load the latest blocks and return a promise with the latest block height
function loadLatestBlocks() {
if (isLoading) return Promise.resolve(null);
isLoading = true;
// Show loading state
$("#blocks-grid").html('<div class="loader"><span class="loader-text">Loading latest blocks<span class="terminal-cursor"></span></span></div>');
// Fetch the latest blocks from the API
return $.ajax({
url: `${mempoolBaseUrl}/api/v1/blocks`,
method: "GET",
dataType: "json",
timeout: 10000,
success: function (data) {
// Cache the data (use the first block's height as the key)
if (data.length > 0) {
currentStartHeight = data[0].height;
blocksCache[currentStartHeight] = data;
// Update the block height input with the latest height
$("#block-height").val(currentStartHeight);
// Update latest block stats
updateLatestBlockStats(data[0]);
}
// Display the blocks
displayBlocks(data);
},
error: function (xhr, status, error) {
console.error("Error fetching latest blocks:", error);
$("#blocks-grid").html('<div class="error">Error fetching blocks. Please try again later.</div>');
// Show error toast
showToast("Failed to load latest blocks. Please try again later.");
},
complete: function () {
isLoading = false;
}
}).then(data => data.length > 0 ? data[0].height : null);
}
// Refresh blocks page every 60 seconds if there are new blocks
setInterval(function () {
console.log("Checking for new blocks at " + new Date().toLocaleTimeString());
loadLatestBlocks().then(latestHeight => {
if (latestHeight && latestHeight > currentStartHeight) {
console.log("New blocks detected, refreshing the page");
location.reload();
} else {
console.log("No new blocks detected");
}
});
}, 60000);
// Function to update the latest block stats section
function updateLatestBlockStats(block) {
if (!block) return;
$("#latest-height").text(block.height);
$("#latest-time").text(formatTimestamp(block.timestamp));
$("#latest-tx-count").text(numberWithCommas(block.tx_count));
$("#latest-size").text(formatFileSize(block.size));
$("#latest-difficulty").text(numberWithCommas(Math.round(block.difficulty)));
// Pool info
if (block.extras && block.extras.pool) {
$("#latest-pool").text(block.extras.pool.name);
} else {
$("#latest-pool").text("Unknown");
}
// Average Fee Rate
if (block.extras && block.extras.avgFeeRate) {
$("#latest-fee-rate").text(block.extras.avgFeeRate + " sat/vB");
} else {
$("#latest-fee-rate").text("N/A");
}
}
// Function to display the blocks in the grid
function displayBlocks(blocks) {
const blocksGrid = $("#blocks-grid");
// Clear the grid
blocksGrid.empty();
if (!blocks || blocks.length === 0) {
blocksGrid.html('<div class="no-blocks">No blocks found</div>');
return;
}
// Create a card for each block
blocks.forEach(function(block) {
const blockCard = createBlockCard(block);
blocksGrid.append(blockCard);
});
// Add navigation controls if needed
addNavigationControls(blocks);
}
// Function to create a block card
function createBlockCard(block) {
const timestamp = formatTimestamp(block.timestamp);
const formattedSize = formatFileSize(block.size);
const formattedTxCount = numberWithCommas(block.tx_count);
// Get the pool name or "Unknown"
const poolName = block.extras && block.extras.pool ? block.extras.pool.name : "Unknown";
// Calculate total fees in BTC
const totalFees = block.extras ? (block.extras.totalFees / 100000000).toFixed(8) : "N/A";
// Create the block card
const blockCard = $("<div>", {
class: "block-card",
"data-height": block.height,
"data-hash": block.id
});
// Create the block header
const blockHeader = $("<div>", {
class: "block-header"
});
blockHeader.append($("<div>", {
class: "block-height",
text: "#" + block.height
}));
blockHeader.append($("<div>", {
class: "block-time",
text: timestamp
}));
blockCard.append(blockHeader);
// Create the block info section
const blockInfo = $("<div>", {
class: "block-info"
});
// Add transaction count with conditional coloring based on count
const txCountItem = $("<div>", {
class: "block-info-item"
});
txCountItem.append($("<div>", {
class: "block-info-label",
text: "Transactions"
}));
// Determine transaction count color based on thresholds
let txCountClass = "green"; // Default for high transaction counts (2000+)
if (block.tx_count < 500) {
txCountClass = "red"; // Less than 500 transactions
} else if (block.tx_count < 2000) {
txCountClass = "yellow"; // Between 500 and 1999 transactions
}
txCountItem.append($("<div>", {
class: `block-info-value ${txCountClass}`,
text: formattedTxCount
}));
blockInfo.append(txCountItem);
// Add size
const sizeItem = $("<div>", {
class: "block-info-item"
});
sizeItem.append($("<div>", {
class: "block-info-label",
text: "Size"
}));
sizeItem.append($("<div>", {
class: "block-info-value white",
text: formattedSize
}));
blockInfo.append(sizeItem);
// Add miner/pool
const minerItem = $("<div>", {
class: "block-info-item"
});
minerItem.append($("<div>", {
class: "block-info-label",
text: "Miner"
}));
minerItem.append($("<div>", {
class: "block-info-value green",
text: poolName
}));
blockInfo.append(minerItem);
// Replace it with this code for Avg Fee Rate:
const feesItem = $("<div>", {
class: "block-info-item"
});
feesItem.append($("<div>", {
class: "block-info-label",
text: "Avg Fee Rate"
}));
feesItem.append($("<div>", {
class: "block-info-value yellow",
text: block.extras && block.extras.avgFeeRate ? block.extras.avgFeeRate + " sat/vB" : "N/A"
}));
blockInfo.append(feesItem);
blockCard.append(blockInfo);
// Add event listener for clicking on the block card
blockCard.on("click", function() {
showBlockDetails(block);
});
return blockCard;
}
// Function to add navigation controls to the blocks grid
function addNavigationControls(blocks) {
// Get the height of the first and last block in the current view
const firstBlockHeight = blocks[0].height;
const lastBlockHeight = blocks[blocks.length - 1].height;
// Create navigation controls
const navControls = $("<div>", {
class: "block-navigation"
});
// Newer blocks button (if not already at the latest blocks)
if (firstBlockHeight !== currentStartHeight) {
const newerButton = $("<button>", {
class: "block-button",
text: "Newer Blocks"
});
newerButton.on("click", function() {
loadBlocksFromHeight(firstBlockHeight + 15);
});
navControls.append(newerButton);
}
// Older blocks button
const olderButton = $("<button>", {
class: "block-button",
text: "Older Blocks"
});
olderButton.on("click", function() {
loadBlocksFromHeight(lastBlockHeight - 1);
});
navControls.append(olderButton);
// Add the navigation controls to the blocks grid
$("#blocks-grid").append(navControls);
}
// Function to show block details in a modal
function showBlockDetails(block) {
const modal = $("#block-modal");
const blockDetails = $("#block-details");
// Clear the details
blockDetails.empty();
// Format the timestamp
const timestamp = formatTimestamp(block.timestamp);
// Create the block header section
const headerSection = $("<div>", {
class: "block-detail-section"
});
headerSection.append($("<div>", {
class: "block-detail-title",
text: "Block #" + block.height
}));
// Add block hash
const hashItem = $("<div>", {
class: "block-detail-item"
});
hashItem.append($("<div>", {
class: "block-detail-label",
text: "Block Hash"
}));
hashItem.append($("<div>", {
class: "block-hash",
text: block.id
}));
headerSection.append(hashItem);
// Add timestamp
const timeItem = $("<div>", {
class: "block-detail-item"
});
timeItem.append($("<div>", {
class: "block-detail-label",
text: "Timestamp"
}));
timeItem.append($("<div>", {
class: "block-detail-value",
text: timestamp
}));
headerSection.append(timeItem);
// Add merkle root
const merkleItem = $("<div>", {
class: "block-detail-item"
});
merkleItem.append($("<div>", {
class: "block-detail-label",
text: "Merkle Root"
}));
merkleItem.append($("<div>", {
class: "block-hash",
text: block.merkle_root
}));
headerSection.append(merkleItem);
// Add previous block hash
const prevHashItem = $("<div>", {
class: "block-detail-item"
});
prevHashItem.append($("<div>", {
class: "block-detail-label",
text: "Previous Block"
}));
prevHashItem.append($("<div>", {
class: "block-hash",
text: block.previousblockhash
}));
headerSection.append(prevHashItem);
blockDetails.append(headerSection);
// Create the mining section
const miningSection = $("<div>", {
class: "block-detail-section"
});
miningSection.append($("<div>", {
class: "block-detail-title",
text: "Mining Details"
}));
// Add miner/pool
const minerItem = $("<div>", {
class: "block-detail-item"
});
minerItem.append($("<div>", {
class: "block-detail-label",
text: "Miner"
}));
const poolName = block.extras && block.extras.pool ? block.extras.pool.name : "Unknown";
minerItem.append($("<div>", {
class: "block-detail-value",
text: poolName
}));
miningSection.append(minerItem);
// Add difficulty
const difficultyItem = $("<div>", {
class: "block-detail-item"
});
difficultyItem.append($("<div>", {
class: "block-detail-label",
text: "Difficulty"
}));
difficultyItem.append($("<div>", {
class: "block-detail-value",
text: numberWithCommas(Math.round(block.difficulty))
}));
miningSection.append(difficultyItem);
// Add nonce
const nonceItem = $("<div>", {
class: "block-detail-item"
});
nonceItem.append($("<div>", {
class: "block-detail-label",
text: "Nonce"
}));
nonceItem.append($("<div>", {
class: "block-detail-value",
text: numberWithCommas(block.nonce)
}));
miningSection.append(nonceItem);
// Add bits
const bitsItem = $("<div>", {
class: "block-detail-item"
});
bitsItem.append($("<div>", {
class: "block-detail-label",
text: "Bits"
}));
bitsItem.append($("<div>", {
class: "block-detail-value",
text: block.bits
}));
miningSection.append(bitsItem);
// Add version
const versionItem = $("<div>", {
class: "block-detail-item"
});
versionItem.append($("<div>", {
class: "block-detail-label",
text: "Version"
}));
versionItem.append($("<div>", {
class: "block-detail-value",
text: "0x" + block.version.toString(16)
}));
miningSection.append(versionItem);
blockDetails.append(miningSection);
// Create the transaction section
const txSection = $("<div>", {
class: "block-detail-section"
});
txSection.append($("<div>", {
class: "block-detail-title",
text: "Transaction Details"
}));
// Add transaction count
const txCountItem = $("<div>", {
class: "block-detail-item"
});
txCountItem.append($("<div>", {
class: "block-detail-label",
text: "Transaction Count"
}));
txCountItem.append($("<div>", {
class: "block-detail-value",
text: numberWithCommas(block.tx_count)
}));
txSection.append(txCountItem);
// Add size
const sizeItem = $("<div>", {
class: "block-detail-item"
});
sizeItem.append($("<div>", {
class: "block-detail-label",
text: "Size"
}));
sizeItem.append($("<div>", {
class: "block-detail-value",
text: formatFileSize(block.size)
}));
txSection.append(sizeItem);
// Add weight
const weightItem = $("<div>", {
class: "block-detail-item"
});
weightItem.append($("<div>", {
class: "block-detail-label",
text: "Weight"
}));
weightItem.append($("<div>", {
class: "block-detail-value",
text: numberWithCommas(block.weight) + " WU"
}));
txSection.append(weightItem);
blockDetails.append(txSection);
// Create the fee section if available
if (block.extras) {
const feeSection = $("<div>", {
class: "block-detail-section"
});
feeSection.append($("<div>", {
class: "block-detail-title",
text: "Fee Details"
}));
// Add total fees
const totalFeesItem = $("<div>", {
class: "block-detail-item"
});
totalFeesItem.append($("<div>", {
class: "block-detail-label",
text: "Total Fees"
}));
const totalFees = (block.extras.totalFees / 100000000).toFixed(8);
totalFeesItem.append($("<div>", {
class: "block-detail-value",
text: totalFees + " BTC"
}));
feeSection.append(totalFeesItem);
// Add reward
const rewardItem = $("<div>", {
class: "block-detail-item"
});
rewardItem.append($("<div>", {
class: "block-detail-label",
text: "Block Reward"
}));
const reward = (block.extras.reward / 100000000).toFixed(8);
rewardItem.append($("<div>", {
class: "block-detail-value",
text: reward + " BTC"
}));
feeSection.append(rewardItem);
// Add median fee
const medianFeeItem = $("<div>", {
class: "block-detail-item"
});
medianFeeItem.append($("<div>", {
class: "block-detail-label",
text: "Median Fee Rate"
}));
medianFeeItem.append($("<div>", {
class: "block-detail-value",
text: block.extras.medianFee + " sat/vB"
}));
feeSection.append(medianFeeItem);
// Add average fee
const avgFeeItem = $("<div>", {
class: "block-detail-item"
});
avgFeeItem.append($("<div>", {
class: "block-detail-label",
text: "Average Fee"
}));
avgFeeItem.append($("<div>", {
class: "block-detail-value",
text: numberWithCommas(block.extras.avgFee) + " sat"
}));
feeSection.append(avgFeeItem);
// Add average fee rate
const avgFeeRateItem = $("<div>", {
class: "block-detail-item"
});
avgFeeRateItem.append($("<div>", {
class: "block-detail-label",
text: "Average Fee Rate"
}));
avgFeeRateItem.append($("<div>", {
class: "block-detail-value",
text: block.extras.avgFeeRate + " sat/vB"
}));
feeSection.append(avgFeeRateItem);
// Add fee range with visual representation
if (block.extras.feeRange && block.extras.feeRange.length > 0) {
const feeRangeItem = $("<div>", {
class: "block-detail-item transaction-data"
});
feeRangeItem.append($("<div>", {
class: "block-detail-label",
text: "Fee Rate Percentiles (sat/vB)"
}));
const feeRangeText = $("<div>", {
class: "block-detail-value",
text: block.extras.feeRange.join(", ")
});
feeRangeItem.append(feeRangeText);
// Add visual fee bar
const feeBarContainer = $("<div>", {
class: "fee-bar-container"
});
const feeBar = $("<div>", {
class: "fee-bar"
});
feeBarContainer.append(feeBar);
feeRangeItem.append(feeBarContainer);
// Animate the fee bar
setTimeout(() => {
feeBar.css("width", "100%");
}, 100);
feeSection.append(feeRangeItem);
}
blockDetails.append(feeSection);
}
// Show the modal
modal.css("display", "block");
}
// Function to close the modal
function closeModal() {
$("#block-modal").css("display", "none");
}

1400
static/js/main.js Normal file

File diff suppressed because it is too large Load Diff

415
static/js/notifications.js Normal file
View File

@ -0,0 +1,415 @@
"use strict";
// Global variables
let currentFilter = "all";
let currentOffset = 0;
const pageSize = 20;
let hasMoreNotifications = true;
let isLoading = false;
// Initialize when document is ready
$(document).ready(() => {
console.log("Notification page initializing...");
// Set up filter buttons
$('.filter-button').click(function () {
$('.filter-button').removeClass('active');
$(this).addClass('active');
currentFilter = $(this).data('filter');
resetAndLoadNotifications();
});
// Set up action buttons
$('#mark-all-read').click(markAllAsRead);
$('#clear-read').click(clearReadNotifications);
$('#clear-all').click(clearAllNotifications);
$('#load-more').click(loadMoreNotifications);
// Initial load of notifications
loadNotifications();
// Start polling for unread count
startUnreadCountPolling();
// Initialize BitcoinMinuteRefresh if available
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.initialize) {
BitcoinMinuteRefresh.initialize(refreshNotifications);
console.log("BitcoinMinuteRefresh initialized with refresh function");
}
// Start periodic update of notification timestamps every 30 seconds
setInterval(updateNotificationTimestamps, 30000);
});
// Load notifications with current filter
function loadNotifications() {
if (isLoading) return;
isLoading = true;
showLoading();
const params = {
limit: pageSize,
offset: currentOffset
};
if (currentFilter !== "all") {
params.category = currentFilter;
}
$.ajax({
url: `/api/notifications?${$.param(params)}`,
method: "GET",
dataType: "json",
success: (data) => {
renderNotifications(data.notifications, currentOffset === 0);
updateUnreadBadge(data.unread_count);
// Update load more button state
hasMoreNotifications = data.notifications.length === pageSize;
$('#load-more').prop('disabled', !hasMoreNotifications);
isLoading = false;
},
error: (xhr, status, error) => {
console.error("Error loading notifications:", error);
showError("Failed to load notifications. Please try again.");
isLoading = false;
}
});
}
// Reset offset and load notifications
function resetAndLoadNotifications() {
currentOffset = 0;
loadNotifications();
}
// Load more notifications
function loadMoreNotifications() {
if (!hasMoreNotifications || isLoading) return;
currentOffset += pageSize;
loadNotifications();
}
// Refresh notifications (for periodic updates)
function refreshNotifications() {
// Only refresh if we're on the first page
if (currentOffset === 0) {
resetAndLoadNotifications();
} else {
// Just update the unread count
updateUnreadCount();
}
}
// Update notification timestamps to relative time
function updateNotificationTimestamps() {
$('.notification-item').each(function () {
const timestampStr = $(this).attr('data-timestamp');
if (timestampStr) {
const timestamp = new Date(timestampStr);
const relativeTime = formatTimestamp(timestamp);
$(this).find('.notification-time').text(relativeTime);
}
});
}
// Show loading indicator
function showLoading() {
if (currentOffset === 0) {
// First page load, show loading message
$('#notifications-container').html('<div class="loading-message">Loading notifications<span class="terminal-cursor"></span></div>');
} else {
// Pagination load, show loading below
$('#load-more').prop('disabled', true).text('Loading...');
}
}
// Show error message
function showError(message) {
$('#notifications-container').html(`<div class="error-message">${message}</div>`);
$('#load-more').hide();
}
// Render notifications in the container
function renderNotifications(notifications, isFirstPage) {
const container = $('#notifications-container');
// If first page and no notifications
if (isFirstPage && (!notifications || notifications.length === 0)) {
container.html($('#empty-template').html());
$('#load-more').hide();
return;
}
// If first page, clear container
if (isFirstPage) {
container.empty();
}
// Render each notification
notifications.forEach(notification => {
const notificationElement = createNotificationElement(notification);
container.append(notificationElement);
});
// Show/hide load more button
$('#load-more').show().prop('disabled', !hasMoreNotifications);
}
// Create notification element from template
function createNotificationElement(notification) {
const template = $('#notification-template').html();
const element = $(template);
// Set data attributes
element.attr('data-id', notification.id)
.attr('data-level', notification.level)
.attr('data-category', notification.category)
.attr('data-read', notification.read)
.attr('data-timestamp', notification.timestamp);
// Set icon based on level
const iconElement = element.find('.notification-icon i');
switch (notification.level) {
case 'success':
iconElement.addClass('fa-check-circle');
break;
case 'info':
iconElement.addClass('fa-info-circle');
break;
case 'warning':
iconElement.addClass('fa-exclamation-triangle');
break;
case 'error':
iconElement.addClass('fa-times-circle');
break;
default:
iconElement.addClass('fa-bell');
}
// Append "Z" to indicate UTC if not present
let utcTimestampStr = notification.timestamp;
if (!utcTimestampStr.endsWith('Z')) {
utcTimestampStr += 'Z';
}
const utcDate = new Date(utcTimestampStr);
// Convert UTC date to Los Angeles time with a timezone name for clarity
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
};
const fullTimestamp = utcDate.toLocaleString('en-US', options);
// Append the full timestamp to the notification message
const messageWithTimestamp = `${notification.message}<br><span class="full-timestamp">${fullTimestamp}</span>`;
element.find('.notification-message').html(messageWithTimestamp);
// Set metadata for relative time display
element.find('.notification-time').text(formatTimestamp(utcDate));
element.find('.notification-category').text(notification.category);
// Set up action buttons
element.find('.mark-read-button').on('click', (e) => {
e.stopPropagation();
markAsRead(notification.id);
});
element.find('.delete-button').on('click', (e) => {
e.stopPropagation();
deleteNotification(notification.id);
});
// Hide mark as read button if already read
if (notification.read) {
element.find('.mark-read-button').hide();
}
return element;
}
// Format timestamp as relative time
function formatTimestamp(timestamp) {
const now = new Date();
const diffMs = now - timestamp;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return "just now";
} else if (diffMin < 60) {
return `${diffMin}m ago`;
} else if (diffHour < 24) {
return `${diffHour}h ago`;
} else if (diffDay < 30) {
return `${diffDay}d ago`;
} else {
// Format as date for older notifications
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
};
return timestamp.toLocaleDateString('en-US', options);
}
}
// Mark a notification as read
function markAsRead(notificationId) {
$.ajax({
url: "/api/notifications/mark_read",
method: "POST",
data: JSON.stringify({ notification_id: notificationId }),
contentType: "application/json",
success: (data) => {
// Update UI
$(`[data-id="${notificationId}"]`).attr('data-read', 'true');
$(`[data-id="${notificationId}"]`).find('.mark-read-button').hide();
// Update unread badge
updateUnreadBadge(data.unread_count);
},
error: (xhr, status, error) => {
console.error("Error marking notification as read:", error);
}
});
}
// Mark all notifications as read
function markAllAsRead() {
$.ajax({
url: "/api/notifications/mark_read",
method: "POST",
data: JSON.stringify({}),
contentType: "application/json",
success: (data) => {
// Update UI
$('.notification-item').attr('data-read', 'true');
$('.mark-read-button').hide();
// Update unread badge
updateUnreadBadge(0);
},
error: (xhr, status, error) => {
console.error("Error marking all notifications as read:", error);
}
});
}
// Delete a notification
function deleteNotification(notificationId) {
$.ajax({
url: "/api/notifications/delete",
method: "POST",
data: JSON.stringify({ notification_id: notificationId }),
contentType: "application/json",
success: (data) => {
// Remove from UI with animation
$(`[data-id="${notificationId}"]`).fadeOut(300, function () {
$(this).remove();
// Check if container is empty now
if ($('#notifications-container').children().length === 0) {
$('#notifications-container').html($('#empty-template').html());
$('#load-more').hide();
}
});
// Update unread badge
updateUnreadBadge(data.unread_count);
},
error: (xhr, status, error) => {
console.error("Error deleting notification:", error);
}
});
}
// Clear read notifications
function clearReadNotifications() {
if (!confirm("Are you sure you want to clear all read notifications?")) {
return;
}
$.ajax({
url: "/api/notifications/clear",
method: "POST",
data: JSON.stringify({
// Special parameter to clear only read notifications
read_only: true
}),
contentType: "application/json",
success: () => {
// Reload notifications
resetAndLoadNotifications();
},
error: (xhr, status, error) => {
console.error("Error clearing read notifications:", error);
}
});
}
// Clear all notifications
function clearAllNotifications() {
if (!confirm("Are you sure you want to clear ALL notifications? This cannot be undone.")) {
return;
}
$.ajax({
url: "/api/notifications/clear",
method: "POST",
data: JSON.stringify({}),
contentType: "application/json",
success: () => {
// Reload notifications
resetAndLoadNotifications();
},
error: (xhr, status, error) => {
console.error("Error clearing all notifications:", error);
}
});
}
// Update unread badge
function updateUnreadBadge(count) {
$('#unread-badge').text(count);
// Add special styling if unread
if (count > 0) {
$('#unread-badge').addClass('has-unread');
} else {
$('#unread-badge').removeClass('has-unread');
}
}
// Update unread count from API
function updateUnreadCount() {
$.ajax({
url: "/api/notifications/unread_count",
method: "GET",
success: (data) => {
updateUnreadBadge(data.unread_count);
},
error: (xhr, status, error) => {
console.error("Error updating unread count:", error);
}
});
}
// Start polling for unread count
function startUnreadCountPolling() {
// Update every 30 seconds
setInterval(updateUnreadCount, 30000);
}

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

@ -0,0 +1,497 @@
"use strict";
// Global variables for workers dashboard
let workerData = null;
let refreshTimer;
const pageLoadTime = Date.now();
let lastManualRefreshTime = 0;
const 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
const MIN_REFRESH_INTERVAL = 10000; // Minimum 10 seconds between refreshes
// Hashrate Normalization Utilities
// Helper function to normalize hashrate to TH/s for consistent graphing
function normalizeHashrate(value, unit = 'th/s') {
if (!value || isNaN(value)) return 0;
unit = unit.toLowerCase();
const unitConversion = {
'ph/s': 1000,
'eh/s': 1000000,
'gh/s': 1 / 1000,
'mh/s': 1 / 1000000,
'kh/s': 1 / 1000000000,
'h/s': 1 / 1000000000000
};
return unitConversion[unit] !== undefined ? value * unitConversion[unit] : value;
}
// Helper function to format hashrate values for display
function formatHashrateForDisplay(value, unit) {
if (isNaN(value) || value === null || value === undefined) return "N/A";
const normalizedValue = unit ? normalizeHashrate(value, unit) : value;
const unitRanges = [
{ threshold: 1000000, unit: 'EH/s', divisor: 1000000 },
{ threshold: 1000, unit: 'PH/s', divisor: 1000 },
{ threshold: 1, unit: 'TH/s', divisor: 1 },
{ threshold: 0.001, unit: 'GH/s', divisor: 1 / 1000 },
{ threshold: 0, unit: 'MH/s', divisor: 1 / 1000000 }
];
for (const range of unitRanges) {
if (normalizedValue >= range.threshold) {
return (normalizedValue / range.divisor).toFixed(2) + ' ' + range.unit;
}
}
return (normalizedValue * 1000000).toFixed(2) + ' MH/s';
}
// Initialize the page
$(document).ready(function () {
console.log("Worker page initializing...");
initNotificationBadge();
initializePage();
updateServerTime();
window.manualRefresh = fetchWorkerData;
setTimeout(() => {
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.initialize) {
BitcoinMinuteRefresh.initialize(window.manualRefresh);
console.log("BitcoinMinuteRefresh initialized with refresh function");
} else {
console.warn("BitcoinMinuteRefresh not available");
}
}, 500);
fetchWorkerData();
$('.filter-button').click(function () {
$('.filter-button').removeClass('active');
$(this).addClass('active');
filterState.currentFilter = $(this).data('filter');
filterWorkers();
});
$('#worker-search').on('input', function () {
filterState.searchTerm = $(this).val().toLowerCase();
filterWorkers();
});
});
// Initialize page elements
function initializePage() {
console.log("Initializing page elements...");
if (document.getElementById('total-hashrate-chart')) {
initializeMiniChart();
}
$('#worker-grid').html('<div class="text-center p-5"><i class="fas fa-spinner fa-spin"></i> Loading worker data...</div>');
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);
});
}
}
// Update unread notifications badge in navigation
function updateNotificationBadge() {
$.ajax({
url: "/api/notifications/unread_count",
method: "GET",
success: function (data) {
const unreadCount = data.unread_count;
const badge = $("#nav-unread-badge");
if (unreadCount > 0) {
badge.text(unreadCount).show();
} else {
badge.hide();
}
}
});
}
// Initialize notification badge checking
function initNotificationBadge() {
updateNotificationBadge();
setInterval(updateNotificationBadge, 60000);
}
// Server time update via polling - enhanced to use shared storage
function updateServerTime() {
console.log("Updating server time...");
try {
const storedOffset = localStorage.getItem('serverTimeOffset');
const storedStartTime = localStorage.getItem('serverStartTime');
if (storedOffset && storedStartTime) {
serverTimeOffset = parseFloat(storedOffset);
serverStartTime = parseFloat(storedStartTime);
console.log("Using stored server time offset:", serverTimeOffset, "ms");
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.updateServerTime) {
BitcoinMinuteRefresh.updateServerTime(serverTimeOffset, serverStartTime);
}
return;
}
} catch (e) {
console.error("Error reading stored server time:", e);
}
$.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();
localStorage.setItem('serverTimeOffset', serverTimeOffset.toString());
localStorage.setItem('serverStartTime', serverStartTime.toString());
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.updateServerTime) {
BitcoinMinuteRefresh.updateServerTime(serverTimeOffset, serverStartTime);
}
console.log("Server time synchronized. Offset:", serverTimeOffset, "ms");
},
error: function (jqXHR, textStatus, errorThrown) {
console.error("Error fetching server time:", textStatus, errorThrown);
}
});
}
// Utility functions to show/hide loader
function showLoader() {
$("#loader").show();
}
function hideLoader() {
$("#loader").hide();
}
// Fetch worker data from API with pagination, limiting to 10 pages
function fetchWorkerData(forceRefresh = false) {
console.log("Fetching worker data...");
lastManualRefreshTime = Date.now();
$('#worker-grid').addClass('loading-fade');
showLoader();
const maxPages = 10;
const requests = [];
// Create requests for pages 1 through maxPages concurrently
for (let page = 1; page <= maxPages; page++) {
const apiUrl = `/api/workers?page=${page}${forceRefresh ? '&force=true' : ''}`;
requests.push($.ajax({
url: apiUrl,
method: 'GET',
dataType: 'json',
timeout: 15000
}));
}
// Process all requests concurrently
Promise.all(requests)
.then(pages => {
let allWorkers = [];
let aggregatedData = null;
pages.forEach((data, i) => {
if (data && data.workers && data.workers.length > 0) {
allWorkers = allWorkers.concat(data.workers);
if (i === 0) {
aggregatedData = data; // preserve stats from first page
}
} else {
console.warn(`No workers found on page ${i + 1}`);
}
});
// Deduplicate workers if necessary (using worker.name as unique key)
const uniqueWorkers = allWorkers.filter((worker, index, self) =>
index === self.findIndex((w) => w.name === worker.name)
);
workerData = aggregatedData || {};
workerData.workers = uniqueWorkers;
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.notifyRefresh) {
BitcoinMinuteRefresh.notifyRefresh();
}
updateWorkerGrid();
updateSummaryStats();
updateMiniChart();
updateLastUpdated();
$('#retry-button').hide();
connectionRetryCount = 0;
console.log("Worker data updated successfully");
$('#worker-grid').removeClass('loading-fade');
})
.catch(error => {
console.error("Error fetching worker data:", error);
})
.finally(() => {
hideLoader();
});
}
// Refresh worker data every 60 seconds
setInterval(function () {
console.log("Refreshing worker data at " + new Date().toLocaleTimeString());
fetchWorkerData();
}, 60000);
// Update the worker grid with data
function updateWorkerGrid() {
console.log("Updating worker grid...");
if (!workerData || !workerData.workers) {
console.error("No worker data available");
return;
}
const workerGrid = $('#worker-grid');
workerGrid.empty();
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;
}
filteredWorkers.forEach(worker => {
const card = createWorkerCard(worker);
workerGrid.append(card);
});
}
// Create worker card element
function createWorkerCard(worker) {
const card = $('<div class="worker-card"></div>');
card.addClass(worker.status === 'online' ? 'worker-card-online' : 'worker-card-offline');
card.append(`<div class="worker-type">${worker.type}</div>`);
card.append(`<div class="worker-name">${worker.name}</div>`);
card.append(`<div class="status-badge ${worker.status === 'online' ? 'status-badge-online' : 'status-badge-offline'}">${worker.status.toUpperCase()}</div>`);
const maxHashrate = 125; // TH/s - adjust based on your fleet
const normalizedHashrate = normalizeHashrate(worker.hashrate_3hr, worker.hashrate_3hr_unit || 'th/s');
const hashratePercent = Math.min(100, (normalizedHashrate / maxHashrate) * 100);
const formattedHashrate = formatHashrateForDisplay(worker.hashrate_3hr, worker.hashrate_3hr_unit || 'th/s');
card.append(`
<div class="worker-stats-row">
<div class="worker-stats-label">Hashrate (3hr):</div>
<div class="white-glow">${formattedHashrate}</div>
</div>
<div class="stats-bar-container">
<div class="stats-bar" style="width: ${hashratePercent}%"></div>
</div>
`);
card.append(`
<div class="worker-stats">
<div class="worker-stats-row">
<div class="worker-stats-label">Last Share:</div>
<div class="blue-glow">${typeof worker.last_share === 'string' ? worker.last_share.split(' ')[1] || worker.last_share : 'N/A'}</div>
</div>
<div class="worker-stats-row">
<div class="worker-stats-label">Earnings:</div>
<div class="green-glow">${worker.earnings.toFixed(8)}</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>
`);
return card;
}
// 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();
const matchesFilter = filterState.currentFilter === 'all' ||
(filterState.currentFilter === 'online' && isOnline) ||
(filterState.currentFilter === 'offline' && !isOnline) ||
(filterState.currentFilter === 'asic' && workerType === 'asic') ||
(filterState.currentFilter === 'bitaxe' && workerType === 'bitaxe');
const matchesSearch = filterState.searchTerm === '' || workerName.includes(filterState.searchTerm);
return matchesFilter && matchesSearch;
});
}
// Apply filter to rendered worker cards
function filterWorkers() {
if (!workerData || !workerData.workers) return;
updateWorkerGrid();
}
// Update summary stats with normalized hashrate display
function updateSummaryStats() {
if (!workerData) return;
$('#workers-count').text(workerData.workers_total || 0);
$('#workers-online').text(workerData.workers_online || 0);
$('#workers-offline').text(workerData.workers_offline || 0);
const onlinePercent = workerData.workers_total > 0 ? workerData.workers_online / workerData.workers_total : 0;
$('.worker-ring').css('--online-percent', onlinePercent);
const formattedHashrate = workerData.total_hashrate !== undefined ?
formatHashrateForDisplay(workerData.total_hashrate, workerData.hashrate_unit || 'TH/s') :
'0.0 TH/s';
$('#total-hashrate').text(formattedHashrate);
$('#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() {
console.log("Initializing mini chart...");
const ctx = document.getElementById('total-hashrate-chart');
if (!ctx) {
console.error("Mini chart canvas not found");
return;
}
const labels = Array(24).fill('').map((_, i) => i);
const data = Array(24).fill(0).map(() => Math.random() * 100 + 700);
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 and normalization
function updateMiniChart() {
if (!miniChart || !workerData || !workerData.hashrate_history) {
console.log("Skipping mini chart update - missing data");
return;
}
const historyData = workerData.hashrate_history;
if (!historyData || historyData.length === 0) {
console.log("No hashrate history data available");
return;
}
const values = historyData.map(item => normalizeHashrate(parseFloat(item.value) || 0, item.unit || workerData.hashrate_unit || 'th/s'));
const labels = historyData.map(item => item.time);
miniChart.data.labels = labels;
miniChart.data.datasets[0].data = values;
const min = Math.min(...values.filter(v => v > 0)) || 0;
const max = Math.max(...values) || 1;
miniChart.options.scales.y.min = min * 0.9;
miniChart.options.scales.y.max = max * 1.1;
miniChart.update('none');
}
// Update the last updated timestamp
function updateLastUpdated() {
if (!workerData || !workerData.timestamp) return;
try {
const timestamp = new Date(workerData.timestamp);
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
};
$("#lastUpdated").html("<strong>Last Updated:</strong> " +
timestamp.toLocaleString('en-US', options) + "<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, ",");
}

73
templates/base.html Normal file
View File

@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BTC-OS MINING DASHBOARD v 0.3{% 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">
<!-- 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 %}BTC-OS MINING DASHBOARD{% endblock %}
</a>
</h1>
{% block last_updated %}
<p class="text-center" id="lastUpdated" style="color: #f7931a; text-transform: uppercase;"><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 %}">DASHBOARD</a>
<a href="/workers" class="nav-link {% block workers_active %}{% endblock %}">WORKERS</a>
<a href="/blocks" class="nav-link {% block blocks_active %}{% endblock %}">BLOCKS</a>
<a href="/notifications" class="nav-link {% block notifications_active %}{% endblock %}">
NOTIFICATIONS
<span id="nav-unread-badge" class="nav-badge"></span>
</a>
</div>
{% endblock %}
<!-- Main content area -->
{% block content %}{% 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 %}
<!-- Bitcoin Progress Bar -->
<script src="/static/js/BitcoinProgressBar.js"></script>
</body>
</html>

93
templates/blocks.html Normal file
View File

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}BLOCKS - BTC-OS MINING DASHBOARD v 0.3{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/blocks.css">
{% endblock %}
{% block header %}BLOCKCHAIN MONITOR v 0.1{% endblock %}
{% block blocks_active %}active{% endblock %}
{% block content %}
<!-- Latest block stats -->
<div class="row mb-2 equal-height">
<div class="col-12">
<div class="card">
<div class="card-header">LATEST BLOCK STATS</div>
<div class="card-body">
<div class="latest-block-stats">
<div class="stat-item">
<strong>BLOCK HEIGHT:</strong>
<span id="latest-height" class="metric-value white">Loading...</span>
</div>
<div class="stat-item">
<strong>TIME:</strong>
<span id="latest-time" class="metric-value blue">Loading...</span>
</div>
<div class="stat-item">
<strong>TRANSACTIONS:</strong>
<span id="latest-tx-count" class="metric-value white">Loading...</span>
</div>
<div class="stat-item">
<strong>SIZE:</strong>
<span id="latest-size" class="metric-value white">Loading...</span>
</div>
<div class="stat-item">
<strong>DIFFICULTY:</strong>
<span id="latest-difficulty" class="metric-value yellow">Loading...</span>
</div>
<div class="stat-item">
<strong>POOL:</strong>
<span id="latest-pool" class="metric-value green">Loading...</span>
</div>
<div class="stat-item">
<strong>AVG FEE RATE:</strong>
<span id="latest-fee-rate" class="metric-value yellow" style="animation: pulse 1s infinite;">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Blocks grid -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">RECENT BLOCKS</div>
<div class="card-body">
<div class="blocks-container">
<div id="blocks-grid" class="blocks-grid">
<!-- Blocks will be generated here via JavaScript -->
<div class="loader">
<span class="loader-text">Connecting to mempool.space API<span class="terminal-cursor"></span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Block details modal -->
<div id="block-modal" class="block-modal">
<div class="block-modal-content">
<div class="block-modal-header">
<span class="block-modal-title">BLOCK DETAILS</span>
<span class="block-modal-close">&times;</span>
</div>
<div class="block-modal-body">
<div id="block-details">
<!-- Block details will be displayed here -->
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<script src="/static/js/blocks.js"></script>
{% endblock %}

713
templates/boot.html Normal file
View File

@ -0,0 +1,713 @@
<!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">
<style>
/* Added styles for configuration form */
#config-form {
display: none;
margin-top: 20px;
background-color: rgba(0, 0, 0, 0.7);
border: 1px solid #f7931a;
padding: 15px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
.config-title {
color: #f7931a;
font-size: 22px;
text-align: center;
margin-bottom: 15px;
text-shadow: 0 0 8px rgba(247, 147, 26, 0.8);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #f7931a;
}
.form-group input {
width: 100%;
background-color: #111;
border: 1px solid #f7931a;
padding: 8px;
color: white;
font-family: 'VT323', monospace;
font-size: 18px;
}
.form-group input:focus {
outline: none;
box-shadow: 0 0 5px #f7931a;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.btn {
background-color: #f7931a;
border: none;
color: black;
padding: 8px 15px;
font-family: 'VT323', monospace;
font-size: 18px;
cursor: pointer;
min-width: 120px;
text-align: center;
}
.btn:hover {
background-color: #ffa642;
}
.btn-secondary {
background-color: #333;
color: #f7931a;
}
.btn-secondary:hover {
background-color: #444;
}
/* Make skip button more mobile-friendly */
#skip-button {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
padding: 12px 20px;
font-size: 18px;
border-radius: 8px;
}
@media (max-width: 768px) {
#skip-button {
bottom: 10px;
right: 10px;
padding: 15px 25px;
font-size: 20px; /* Larger font size for better tap targets */
border-radius: 10px;
width: auto;
}
.form-actions {
flex-direction: column;
gap: 10px;
}
.btn {
width: 100%;
padding: 12px;
font-size: 20px;
}
}
/* Tooltip styles */
.tooltip {
position: relative;
display: inline-block;
margin-left: 5px;
cursor: help;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 200px;
background-color: #000;
color: #fff;
text-align: center;
border: 1px solid #f7931a;
padding: 5px;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -100px;
opacity: 0;
transition: opacity 0.3s;
font-size: 14px;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* Form success/error message */
#form-message {
margin-top: 10px;
padding: 8px;
text-align: center;
display: none;
}
.message-success {
background-color: rgba(50, 205, 50, 0.2);
border: 1px solid #32CD32;
color: #32CD32;
}
.message-error {
background-color: rgba(255, 0, 0, 0.2);
border: 1px solid #ff0000;
color: #ff0000;
}
</style>
</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>
<!-- Configuration Form -->
<div id="config-form">
<div class="config-title">MINING CONFIGURATION</div>
<div class="form-group">
<label for="wallet-address">
Bitcoin Wallet Address
<span class="tooltip">
?
<span class="tooltip-text">Your Ocean.xyz pool mining address</span>
</span>
</label>
<input type="text" id="wallet-address" placeholder="bc1..." value="">
</div>
<div class="form-group">
<label for="power-cost">
Power Cost ($/kWh)
<span class="tooltip">
?
<span class="tooltip-text">Your electricity cost per kilowatt-hour</span>
</span>
</label>
<input type="number" id="power-cost" step="0.01" min="0" placeholder="0.12" value="">
</div>
<div class="form-group">
<label for="power-usage">
Power Usage (Watts)
<span class="tooltip">
?
<span class="tooltip-text">Total power consumption of your mining equipment</span>
</span>
</label>
<input type="number" id="power-usage" step="50" min="0" placeholder="13450" value="">
</div>
<div id="form-message"></div>
<div class="form-actions">
<button class="btn btn-secondary" id="use-defaults">Use Defaults</button>
<button class="btn" id="save-config">Save & Continue</button>
</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');
const configForm = document.getElementById('config-form');
let messageIndex = 0;
let timeoutId = null;
let waitingForUserInput = false;
let bootComplete = false;
let configLoaded = false;
let currentConfig = {
wallet: "yourwallethere",
power_cost: 0.0,
power_usage: 0.0
};
// Replace the current loadConfig function with this improved version
function loadConfig() {
// Always make a fresh request to get the latest config
fetch('/api/config?nocache=' + new Date().getTime()) // Add cache-busting parameter
.then(response => {
if (!response.ok) {
throw new Error('Failed to load configuration: ' + response.statusText);
}
return response.json();
})
.then(data => {
console.log("Loaded configuration:", data);
currentConfig = data;
// After loading, always update the form fields with the latest values
document.getElementById('wallet-address').value = currentConfig.wallet || "";
document.getElementById('power-cost').value = currentConfig.power_cost || "";
document.getElementById('power-usage').value = currentConfig.power_usage || "";
configLoaded = true;
})
.catch(err => {
console.error("Error loading config:", err);
// Use default values if loading fails
currentConfig = {
wallet: "yourwallethere",
power_cost: 0.0,
power_usage: 0.0
};
// Still update the form with default values
document.getElementById('wallet-address').value = currentConfig.wallet || "";
document.getElementById('power-cost').value = currentConfig.power_cost || "";
document.getElementById('power-usage').value = currentConfig.power_usage || "";
});
}
// Also update the save button event handler to reload the config after saving
document.getElementById('save-config').addEventListener('click', function () {
const messageElement = document.getElementById('form-message');
messageElement.style.display = 'block';
saveConfig()
.then(data => {
console.log("Configuration saved:", data);
messageElement.textContent = "Configuration saved successfully!";
messageElement.className = "message-success";
// Update currentConfig with the saved values
currentConfig = data.config || data;
setTimeout(redirectToDashboard, 1000);
})
.catch(error => {
console.error("Error saving configuration:", error);
messageElement.textContent = "Error saving configuration. Please try again.";
messageElement.className = "message-error";
});
});
// Save configuration
function saveConfig() {
const wallet = document.getElementById('wallet-address').value.trim();
const powerCost = parseFloat(document.getElementById('power-cost').value) || 0;
const powerUsage = parseFloat(document.getElementById('power-usage').value) || 0;
const updatedConfig = {
wallet: wallet || currentConfig.wallet,
power_cost: powerCost,
power_usage: powerUsage
};
return fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedConfig)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to save configuration');
}
return response.json();
});
}
// Safety timeout: redirect after 120 seconds if boot not complete
window.addEventListener('load', function () {
setTimeout(function () {
if (!bootComplete && !waitingForUserInput) {
console.warn("Safety timeout reached - redirecting to dashboard");
redirectToDashboard();
}
}, 120000);
});
// Configuration form event listeners
document.getElementById('save-config').addEventListener('click', function () {
const messageElement = document.getElementById('form-message');
messageElement.style.display = 'block';
saveConfig()
.then(data => {
console.log("Configuration saved:", data);
messageElement.textContent = "Configuration saved successfully!";
messageElement.className = "message-success";
setTimeout(redirectToDashboard, 1000);
})
.catch(error => {
console.error("Error saving configuration:", error);
messageElement.textContent = "Error saving configuration. Please try again.";
messageElement.className = "message-error";
});
});
// Replace the current Use Defaults button event listener with this fixed version
document.getElementById('use-defaults').addEventListener('click', function () {
console.log("Use Defaults button clicked");
// Always use the hardcoded default values, not the currentConfig
const defaultWallet = "bc1py5zmrtssheq3shd8cptpl5l5m3txxr5afynyg2gyvam6w78s4dlqqnt4v9";
const defaultPowerCost = 0.0;
const defaultPowerUsage = 0.0;
console.log("Setting to default values");
// Apply the hardcoded default values to the form fields
document.getElementById('wallet-address').value = defaultWallet;
document.getElementById('power-cost').value = defaultPowerCost;
document.getElementById('power-usage').value = defaultPowerUsage;
// Show visual feedback that the button was clicked
const btn = document.getElementById('use-defaults');
const originalText = btn.textContent;
btn.textContent = "Defaults Applied";
btn.style.backgroundColor = "#32CD32";
// Reset the button after a short delay
setTimeout(function () {
btn.textContent = originalText;
btn.style.backgroundColor = "";
}, 1500);
});
// 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);
// Load configuration
loadConfig();
});
// Post-confirmation messages with retro typing effect
function showPostConfirmationMessages(response) {
try {
outputElement = document.getElementById('output');
if (!outputElement) {
setTimeout(redirectToDashboard, 1000);
return;
}
// Configuration form will be shown after boot sequence
if (response.toUpperCase() === 'Y') {
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 mining configuration...", speed: 15, delay: 200 },
{ text: "<span class='green'>LOADED</span>\n", html: true, delay: 300 },
{ text: "Preparing configuration interface...", speed: 15, delay: 800 },
{ text: "<span class='green'>READY</span>\n", html: true, delay: 500 },
{ text: "\nPlease configure your mining setup or use the default values:\n", html: true, delay: 800, showConfigForm: true }
];
let msgIndex = 0;
function processNextMessage() {
if (msgIndex >= yesMessages.length) {
return;
}
const currentMessage = yesMessages[msgIndex];
if (currentMessage.showConfigForm) {
msgIndex++;
// Show configuration form
document.getElementById('config-form').style.display = 'block';
return;
}
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);
} else {
// If user selects 'N', just redirect to dashboard
outputElement.innerHTML += "N\n\nDASHBOARD INITIALIZATION ABORTED.\n";
outputElement.innerHTML += "\nUsing default configuration values.\n";
setTimeout(redirectToDashboard, 2000);
}
} 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();
if (response === 'Y' || response === 'N') {
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();
}
// 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;
}
}
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: reveal configuration form only
skipButton.addEventListener('click', function () {
clearTimeout(timeoutId);
// Optionally, clear boot messages or hide elements related to boot sequence
outputElement.innerHTML = "";
// Hide any loading or prompt messages
loadingMessage.style.display = 'none';
promptContainer.style.display = 'none';
// Show the configuration form
configForm.style.display = 'block';
});
// 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>

360
templates/dashboard.html Normal file
View File

@ -0,0 +1,360 @@
{% extends "base.html" %}
{% block title %}BTC-OS Mining Dashboard v 0.3{% 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 and Payout Info -->
<div class="row mb-2 equal-height">
<div class="col-md-6">
<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 class="col-md-6">
<div class="card" id="payoutMiscCard">
<div class="card-header">Payout Info</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|commafy 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>
<!-- 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">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 EARNINGS</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 EARNINGS</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>
{% 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_0x69420</div>
<p>{{ message }}<span class="terminal-cursor"></span></p>
<a href="/" class="btn btn-primary">RETURN TO DASHBOARD</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}NOTIFICATIONS - BTC-OS MINING DASHBOARD v 0.3{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/notifications.css">
{% endblock %}
{% block header %}NOTIFICATION CENTER v 0.1{% endblock %}
{% block notifications_active %}active{% endblock %}
{% block content %}
<!-- Notification Controls -->
<div class="row mb-2">
<div class="col-12">
<div class="card">
<div class="card-header">NOTIFICATION CONTROLS</div>
<div class="card-body">
<div class="notification-controls">
<div class="filter-buttons">
<button class="filter-button active" data-filter="all">All</button>
<button class="filter-button" data-filter="hashrate">Hashrate</button>
<button class="filter-button" data-filter="block">Blocks</button>
<button class="filter-button" data-filter="worker">Workers</button>
<button class="filter-button" data-filter="earnings">Earnings</button>
<button class="filter-button" data-filter="system">System</button>
</div>
<div class="notification-actions">
<button id="mark-all-read" class="action-button">Mark All as Read</button>
<button id="clear-read" class="action-button">Clear Read Notifications</button>
<button id="clear-all" class="action-button danger">Clear All</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Notifications List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<span>NOTIFICATIONS</span>
<span id="unread-badge" class="unread-badge">0</span>
</div>
<div class="card-body">
<div id="notifications-container">
<!-- Notifications will be populated here by JavaScript -->
<div class="loading-message">Loading notifications<span class="terminal-cursor"></span></div>
</div>
<!-- Pagination -->
<div class="pagination-controls">
<button id="load-more" class="load-more-button">LOAD MORE</button>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State Template (hidden) -->
<div id="empty-template" style="display:none;">
<div class="empty-state">
<i class="fas fa-bell-slash"></i>
<p>No notifications to display</p>
</div>
</div>
<!-- Notification Template (hidden) -->
<div id="notification-template" style="display:none;">
<div class="notification-item" data-id="">
<div class="notification-icon">
<i class="fas"></i>
</div>
<div class="notification-content">
<div class="notification-message"></div>
<div class="notification-meta">
<span class="notification-time"></span>
<span class="notification-category"></span>
</div>
</div>
<div class="notification-actions">
<button class="mark-read-button"><i class="fas fa-check"></i></button>
<button class="delete-button"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<script src="/static/js/notifications.js"></script>
{% endblock %}

109
templates/workers.html Normal file
View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}WORKERS - BTC-OS MINING DASHBOARD v 0.3{% 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">MINER 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">UNPAID 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</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="bitaxe">BITAXE</button>
</div>
</div>
<!-- Workers grid -->
<div class="worker-grid" id="worker-grid">
<!-- Worker cards will be generated here via JavaScript -->
<div id="loader" class="text-center p-5" style="display:none;">
<i class="fas fa-spinner fa-spin"></i> Loading worker data...
</div>
</div>
{% endblock %}
{% block javascript %}
<script src="/static/js/workers.js"></script>
{% endblock %}

800
worker_service.py Normal file
View File

@ -0,0 +1,800 @@
"""
Worker service module for managing workers data.
"""
import logging
import random
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
class WorkerService:
"""Service for retrieving 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
self.dashboard_service = None # Will be set by App.py during initialization
self.sats_per_btc = 100_000_000 # Constant for conversion
def set_dashboard_service(self, dashboard_service):
"""
Set the dashboard service instance - to be called from App.py
Args:
dashboard_service (MiningDashboardService): The initialized dashboard service
"""
self.dashboard_service = dashboard_service
# Immediately access the wallet from dashboard_service when it's set
if hasattr(dashboard_service, 'wallet'):
self.wallet = dashboard_service.wallet
logging.info(f"Worker service updated with new wallet: {self.wallet}")
logging.info("Dashboard service connected to worker service")
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:
# Even when using cached data, sync worker count with main dashboard
if cached_metrics and cached_metrics.get("workers_hashing") is not None:
self.sync_worker_counts_with_dashboard(self.worker_data_cache, cached_metrics)
logging.info("Using cached worker data")
return self.worker_data_cache
try:
# First try to get actual worker data from the dashboard service
if self.dashboard_service:
logging.info("Attempting to fetch real worker data from Ocean.xyz")
real_worker_data = self.dashboard_service.get_worker_data()
if real_worker_data and real_worker_data.get('workers') and len(real_worker_data['workers']) > 0:
# Validate that worker names are not just "Online" or "Offline"
valid_names = False
for worker in real_worker_data['workers']:
name = worker.get('name', '').lower()
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
valid_names = True
break
if valid_names:
logging.info(f"Successfully retrieved {len(real_worker_data['workers'])} real workers from Ocean.xyz")
# Add hashrate history if available in cached metrics
if cached_metrics and cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
real_worker_data["hashrate_history"] = cached_metrics["arrow_history"]["hashrate_3hr"]
# Sync with dashboard metrics to ensure consistency
if cached_metrics:
self.sync_worker_counts_with_dashboard(real_worker_data, cached_metrics)
# Update cache
self.worker_data_cache = real_worker_data
self.last_worker_data_update = current_time
return real_worker_data
else:
logging.warning("Real worker data had invalid names (like 'online'/'offline'), falling back to simulated data")
else:
logging.warning("Real worker data fetch returned no workers, falling back to simulated data")
else:
logging.warning("Dashboard service not available, cannot fetch real worker data")
# Fallback to simulated data if real data fetch fails or returns no workers
logging.info("Generating fallback simulated worker data")
worker_data = self.generate_fallback_data(cached_metrics)
# Add hashrate history if available in cached metrics
if cached_metrics and cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
worker_data["hashrate_history"] = cached_metrics["arrow_history"]["hashrate_3hr"]
# Ensure worker counts match dashboard metrics
if cached_metrics:
self.sync_worker_counts_with_dashboard(worker_data, cached_metrics)
# Update cache
self.worker_data_cache = worker_data
self.last_worker_data_update = current_time
logging.info(f"Successfully generated fallback worker data: {worker_data['workers_total']} workers")
return worker_data
except Exception as e:
logging.error(f"Error getting worker data: {e}")
fallback_data = self.generate_fallback_data(cached_metrics)
# Even on error, try to sync with dashboard metrics
if cached_metrics:
self.sync_worker_counts_with_dashboard(fallback_data, cached_metrics)
return fallback_data
def sync_worker_counts_with_dashboard(self, worker_data, dashboard_metrics):
"""
Synchronize worker counts and other metrics between worker data and dashboard metrics.
Args:
worker_data (dict): Worker data to be updated
dashboard_metrics (dict): Dashboard metrics with worker count and other data
"""
if not worker_data or not dashboard_metrics:
return
# Sync worker count
dashboard_worker_count = dashboard_metrics.get("workers_hashing")
# Only proceed if dashboard has valid worker count
if dashboard_worker_count is not None:
current_worker_count = worker_data.get("workers_total", 0)
# If counts already match, no need to sync workers count
if current_worker_count != dashboard_worker_count:
logging.info(f"Syncing worker count: worker page({current_worker_count}) → dashboard({dashboard_worker_count})")
# Update the total count
worker_data["workers_total"] = dashboard_worker_count
# Adjust online/offline counts proportionally
current_online = worker_data.get("workers_online", 0)
current_total = max(1, current_worker_count) # Avoid division by zero
# Calculate ratio of online workers
online_ratio = current_online / current_total
# Recalculate online and offline counts
new_online_count = round(dashboard_worker_count * online_ratio)
new_offline_count = dashboard_worker_count - new_online_count
# Update the counts
worker_data["workers_online"] = new_online_count
worker_data["workers_offline"] = new_offline_count
logging.info(f"Updated worker counts - Total: {dashboard_worker_count}, Online: {new_online_count}, Offline: {new_offline_count}")
# If we have worker instances, try to adjust them as well
if "workers" in worker_data and isinstance(worker_data["workers"], list):
self.adjust_worker_instances(worker_data, dashboard_worker_count)
# Sync daily sats - critical for fixing the daily sats discrepancy
if dashboard_metrics.get("daily_mined_sats") is not None:
daily_sats_value = dashboard_metrics.get("daily_mined_sats")
if daily_sats_value != worker_data.get("daily_sats"):
worker_data["daily_sats"] = daily_sats_value
logging.info(f"Synced daily sats: {worker_data['daily_sats']}")
# Sync other important metrics
if dashboard_metrics.get("total_hashrate") is not None:
worker_data["total_hashrate"] = dashboard_metrics.get("total_hashrate")
if dashboard_metrics.get("unpaid_earnings") is not None:
# Attempt to convert string to float if needed
unpaid_value = dashboard_metrics.get("unpaid_earnings")
if isinstance(unpaid_value, str):
try:
unpaid_value = float(unpaid_value.split()[0].replace(',', ''))
except (ValueError, IndexError):
pass
worker_data["total_earnings"] = unpaid_value
def adjust_worker_instances(self, worker_data, target_count):
"""
Adjust the number of worker instances to match the target count.
Args:
worker_data (dict): Worker data containing worker instances
target_count (int): Target number of worker instances
"""
current_workers = worker_data.get("workers", [])
current_count = len(current_workers)
if current_count == target_count:
return
if current_count < target_count:
# Need to add more workers
workers_to_add = target_count - current_count
# Get existing online/offline worker counts
online_workers = [w for w in current_workers if w["status"] == "online"]
offline_workers = [w for w in current_workers if w["status"] == "offline"]
# Use the same online/offline ratio for new workers
online_ratio = len(online_workers) / max(1, current_count)
new_online = round(workers_to_add * online_ratio)
new_offline = workers_to_add - new_online
# Copy and adjust existing workers to create new ones
if online_workers and new_online > 0:
for i in range(new_online):
# Pick a random online worker as template
template = random.choice(online_workers).copy()
# Give it a new name to avoid duplicates
template["name"] = f"{template['name']}_{current_count + i + 1}"
current_workers.append(template)
if offline_workers and new_offline > 0:
for i in range(new_offline):
# Pick a random offline worker as template
template = random.choice(offline_workers).copy()
# Give it a new name to avoid duplicates
template["name"] = f"{template['name']}_{current_count + new_online + i + 1}"
current_workers.append(template)
# If no existing workers of either type, create new ones from scratch
if not online_workers and new_online > 0:
for i in range(new_online):
worker = self.create_default_worker(f"Miner_{current_count + i + 1}", "online")
current_workers.append(worker)
if not offline_workers and new_offline > 0:
for i in range(new_offline):
worker = self.create_default_worker(f"Miner_{current_count + new_online + i + 1}", "offline")
current_workers.append(worker)
elif current_count > target_count:
# Need to remove some workers
workers_to_remove = current_count - target_count
# Remove workers from the end of the list to preserve earlier ones
worker_data["workers"] = current_workers[:target_count]
# Update the worker data
worker_data["workers"] = current_workers
def create_default_worker(self, name, status):
"""
Create a default worker with given name and status.
Args:
name (str): Worker name
status (str): Worker status ('online' or 'offline')
Returns:
dict: Default worker data
"""
is_online = status == "online"
current_time = datetime.now(ZoneInfo("America/Los_Angeles"))
# Generate some reasonable hashrate and other values
hashrate = round(random.uniform(50, 100), 2) if is_online else 0
last_share = current_time.strftime("%Y-%m-%d %H:%M") if is_online else (
(current_time - timedelta(hours=random.uniform(1, 24))).strftime("%Y-%m-%d %H:%M")
)
return {
"name": name,
"status": status,
"type": "ASIC",
"model": "Default Miner",
"hashrate_60sec": hashrate if is_online else 0,
"hashrate_60sec_unit": "TH/s",
"hashrate_3hr": hashrate if is_online else round(random.uniform(30, 80), 2),
"hashrate_3hr_unit": "TH/s",
"efficiency": round(random.uniform(80, 95), 1) if is_online else 0,
"last_share": last_share,
"earnings": round(random.uniform(0.0001, 0.001), 8),
"acceptance_rate": round(random.uniform(95, 99), 1),
"power_consumption": round(random.uniform(2000, 3500)) if is_online else 0,
"temperature": round(random.uniform(55, 75)) if is_online else 0
}
def generate_fallback_data(self, cached_metrics):
"""
Generate fallback worker data from cached metrics when real data can't be fetched.
Try to preserve real worker names if available.
Args:
cached_metrics (dict): Cached metrics from the dashboard
Returns:
dict: Generated worker data
"""
# If metrics aren't available yet, return default data
if not cached_metrics:
logging.warning("No cached metrics available for worker fallback data")
return self.generate_default_workers_data()
# Check if we have workers_hashing information
workers_count = cached_metrics.get("workers_hashing", 0)
# Force at least 1 worker if the count is 0
if workers_count <= 0:
logging.warning("No workers reported in metrics, forcing 1 worker")
workers_count = 1
# Get hashrate from cached metrics
original_hashrate_3hr = float(cached_metrics.get("hashrate_3hr", 0) or 0)
hashrate_unit = cached_metrics.get("hashrate_3hr_unit", "TH/s")
# If hashrate is 0, set a minimum value to avoid empty display
if original_hashrate_3hr <= 0:
original_hashrate_3hr = 50.0
logging.warning(f"Hashrate was 0, setting minimum value of {original_hashrate_3hr} {hashrate_unit}")
# Check if we have any previously cached real worker names
real_worker_names = []
if self.worker_data_cache and self.worker_data_cache.get('workers'):
for worker in self.worker_data_cache['workers']:
name = worker.get('name', '')
# Only use names that don't look like status indicators
if name and name.lower() not in ['online', 'offline', 'total']:
real_worker_names.append(name)
# Generate worker data
workers_data = []
# If we have real worker names, use them
if real_worker_names:
logging.info(f"Using {len(real_worker_names)} real worker names from cache")
workers_data = self.generate_simulated_workers(
workers_count,
original_hashrate_3hr,
hashrate_unit,
real_worker_names=real_worker_names
)
else:
# Otherwise use sequential names
logging.info("No real worker names available, using sequential names")
workers_data = self.generate_sequential_workers(
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
# Use unpaid_earnings from main dashboard
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.001
# Ensure we have a minimum value for unpaid earnings
if unpaid_earnings <= 0:
unpaid_earnings = 0.001
# Use unpaid_earnings as total_earnings
total_earnings = unpaid_earnings
# ---- IMPORTANT FIX: Daily sats calculation ----
# Get daily_mined_sats directly from cached metrics
daily_sats = cached_metrics.get("daily_mined_sats", 0)
# If daily_sats is missing or zero, try to calculate it from other available metrics
if daily_sats is None or daily_sats == 0:
logging.warning("daily_mined_sats is missing or zero, attempting alternative calculations")
# Try to calculate from daily_btc_net
if cached_metrics.get("daily_btc_net") is not None:
daily_btc_net = cached_metrics.get("daily_btc_net")
daily_sats = int(round(daily_btc_net * self.sats_per_btc))
logging.info(f"Calculated daily_sats from daily_btc_net: {daily_sats}")
# Alternative calculation from estimated_earnings_per_day
elif cached_metrics.get("estimated_earnings_per_day") is not None:
daily_btc = cached_metrics.get("estimated_earnings_per_day")
daily_sats = int(round(daily_btc * self.sats_per_btc))
logging.info(f"Calculated daily_sats from estimated_earnings_per_day: {daily_sats}")
# If still zero, try to use estimated_earnings_per_day_sats directly
elif cached_metrics.get("estimated_earnings_per_day_sats") is not None:
daily_sats = cached_metrics.get("estimated_earnings_per_day_sats")
logging.info(f"Using estimated_earnings_per_day_sats as fallback: {daily_sats}")
logging.info(f"Final daily_sats value: {daily_sats}")
# 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": original_hashrate_3hr,
"hashrate_unit": hashrate_unit,
"total_earnings": total_earnings,
"daily_sats": daily_sats, # Fixed daily_sats value
"avg_acceptance_rate": 98.8, # Default value
"hashrate_history": hashrate_history,
"timestamp": datetime.now(ZoneInfo("America/Los_Angeles")).isoformat()
}
# Update cache
self.worker_data_cache = result
self.last_worker_data_update = datetime.now().timestamp()
logging.info(f"Generated fallback data with {len(workers_data)} workers")
return result
def generate_sequential_workers(self, num_workers, total_hashrate, hashrate_unit, total_unpaid_earnings=None):
"""
Generate workers with sequential names when other methods fail.
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
"""
logging.info(f"Generating {num_workers} workers with sequential names")
# Ensure we have at least 1 worker
num_workers = max(1, num_workers)
# Worker model types for simulation
models = [
{"type": "ASIC", "model": "Bitmain Antminer S19 Pro", "max_hashrate": 110, "power": 3250},
{"type": "ASIC", "model": "Bitmain Antminer T21", "max_hashrate": 130, "power": 3276},
{"type": "ASIC", "model": "Bitmain Antminer S19j Pro", "max_hashrate": 104, "power": 3150},
{"type": "Bitaxe", "model": "Bitaxe Gamma 601", "max_hashrate": 3.2, "power": 35}
]
# 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 (ensure it's at least 0.5 TH/s)
avg_hashrate = max(0.5, 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 with sequential names
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 Bitaxe for most workers
else:
model_idx = len(models) - 1 # Bitaxe 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 sequential name
name = f"Miner_{i+1}"
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 Bitaxe for offline
if random.random() > 0.6:
model_info = models[-1] # Bitaxe
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"] == "Bitaxe":
hashrate_3hr = round(random.uniform(1, 3), 2)
else:
hashrate_3hr = round(random.uniform(20, 90), 2)
# Create a sequential name
idx = i + online_count # Index for offline workers starts after online workers
name = f"Miner_{idx+1}"
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
})
# Distribute earnings based on hashrate proportion
# 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
total_effective_hashrate = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
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)
logging.info(f"Generated {len(workers)} workers with sequential names")
return workers
def generate_simulated_workers(self, num_workers, total_hashrate, hashrate_unit, total_unpaid_earnings=None, real_worker_names=None):
"""
Generate simulated worker data based on total hashrate.
This is a fallback method used when real data can't be fetched.
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
real_worker_names (list, optional): List of real worker names to use instead of random names
Returns:
list: List of worker data dictionaries
"""
# Ensure we have at least 1 worker
num_workers = max(1, num_workers)
# Worker model types for simulation
models = [
{"type": "ASIC", "model": "Bitmain Antminer S19k Pro", "max_hashrate": 110, "power": 3250},
{"type": "ASIC", "model": "Bitmain Antminer T21", "max_hashrate": 130, "power": 3276},
{"type": "ASIC", "model": "Bitmain Antminer S19j Pro", "max_hashrate": 104, "power": 3150},
{"type": "Bitaxe", "model": "Bitaxe Gamma 601", "max_hashrate": 3.2, "power": 35}
]
# Worker names for simulation - only used if no real worker names are provided
prefixes = ["Antminer", "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 (ensure it's at least 0.5 TH/s)
avg_hashrate = max(0.5, 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
# Prepare name list - use real names if available, otherwise will generate random names
# If we have real names but not enough, we'll reuse them or generate additional random ones
name_list = []
if real_worker_names and len(real_worker_names) > 0:
logging.info(f"Using {len(real_worker_names)} real worker names")
# Ensure we have enough names by cycling through the list if needed
name_list = real_worker_names * (num_workers // len(real_worker_names) + 1)
name_list = name_list[:num_workers] # Truncate to exact number needed
# 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 Bitaxe for most workers
else:
model_idx = len(models) - 1 # Bitaxe 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 3 minutes)
minutes_ago = random.randint(0, 3)
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)
# Use a real name if available, otherwise generate a random name
if name_list and i < len(name_list):
name = name_list[i]
else:
# Create a unique name
if model_info["type"] == "Bitaxe":
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 Bitaxe for offline
if random.random() > 0.6:
model_info = models[-1] # Bitaxe
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"] == "Bitaxe":
hashrate_3hr = round(random.uniform(1, 3), 2)
else:
hashrate_3hr = round(random.uniform(20, 90), 2)
# Use a real name if available, otherwise generate a random name
idx = i + online_count # Index for offline workers starts after online workers
if name_list and idx < len(name_list):
name = name_list[idx]
else:
# Create a unique name
if model_info["type"] == "Bitaxe":
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
})
# 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 and current_total > 0:
scaling_factor = total_hashrate / current_total
# 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)
# 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
total_effective_hashrate = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
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