diff --git a/App.py b/App.py index 0d5e958..29d567f 100644 --- a/App.py +++ b/App.py @@ -1,4 +1,4 @@ -""" +""" Main application module for the Bitcoin Mining Dashboard. """ import os @@ -533,7 +533,19 @@ def create_scheduler(): def commafy(value): """Add commas to numbers for better readability.""" try: - return "{:,}".format(int(value)) + # Check if the value is already a string with decimal places + if isinstance(value, str) and '.' in value: + # Split by decimal point + integer_part, decimal_part = value.split('.') + # Format integer part with commas and rejoin with decimal part + return "{:,}.{}".format(int(integer_part), decimal_part) + elif isinstance(value, (int, float)): + # If it's a float, preserve decimal places + if isinstance(value, float): + return "{:,.2f}".format(value) + # If it's an integer, format without decimal places + return "{:,}".format(value) + return value except Exception: return value @@ -1227,6 +1239,184 @@ def reset_chart_data(): logging.error(f"Error resetting chart data: {e}") return jsonify({"status": "error", "message": str(e)}), 500 +# First, register the template filter outside of any route function +# Add this near the top of your file with other template filters +@app.template_filter('format_datetime') +def format_datetime(value, timezone=None): + """Format a datetime string according to the specified timezone using AM/PM format.""" + if not value: + return "None" + + import datetime + import pytz + + if timezone is None: + # Use default timezone if none provided + timezone = get_timezone() + + try: + if isinstance(value, str): + # Parse the string to a datetime object + dt = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M") + else: + dt = value + + # Make datetime timezone aware + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + + # Convert to user's timezone + user_tz = pytz.timezone(timezone) + dt = dt.astimezone(user_tz) + + # Format according to user preference with AM/PM format + return dt.strftime("%b %d, %Y %I:%M %p") + except ValueError: + return value + +# Then update your earnings route +@app.route('/earnings') +def earnings(): + """Serve the earnings page with user's currency and timezone preferences.""" + try: + # Get user's currency and timezone preferences + from config import get_currency, get_timezone + user_currency = get_currency() + user_timezone = get_timezone() + + # Define currency symbols for common currencies + currency_symbols = { + 'USD': '$', + 'EUR': '€', + 'GBP': '£', + 'JPY': '¥', + 'CAD': 'C$', + 'AUD': 'A$', + 'CNY': '¥', + 'KRW': '₩', + 'BRL': 'R$', + 'CHF': 'Fr' + } + + # Add graceful error handling for earnings data + try: + # Get earnings data with a longer timeout + earnings_data = dashboard_service.get_earnings_data() + except requests.exceptions.ReadTimeout: + logging.warning("Timeout fetching earnings data from ocean.xyz - using cached or fallback data") + # Try to use cached metrics as fallback + if cached_metrics and 'unpaid_earnings' in cached_metrics: + # Create minimal earnings data from cached metrics + earnings_data = { + 'payments': [], + 'total_payments': 0, + 'total_paid_btc': 0, + 'total_paid_sats': 0, + 'total_paid_usd': 0, + 'unpaid_earnings': cached_metrics.get('unpaid_earnings', 0), + 'unpaid_earnings_sats': int(float(cached_metrics.get('unpaid_earnings', 0)) * 100000000), + 'est_time_to_payout': cached_metrics.get('est_time_to_payout', 'Unknown'), + 'monthly_summaries': [], + 'timestamp': datetime.now(ZoneInfo(user_timezone)).isoformat() + } + else: + # Create empty fallback data + earnings_data = { + 'payments': [], + 'total_payments': 0, + 'total_paid_btc': 0, + 'total_paid_sats': 0, + 'total_paid_usd': 0, + 'unpaid_earnings': 0, + 'unpaid_earnings_sats': 0, + 'est_time_to_payout': 'Unknown', + 'monthly_summaries': [], + 'timestamp': datetime.now(ZoneInfo(user_timezone)).isoformat() + } + + # Add notification about timeout + notification_service.add_notification( + "Data fetch timeout", + "Unable to fetch payment history data from Ocean.xyz. Showing limited earnings data.", + NotificationLevel.WARNING, + NotificationCategory.DATA + ) + except Exception as e: + logging.error(f"Error fetching earnings data: {e}") + # Create empty fallback data + earnings_data = { + 'payments': [], + 'total_payments': 0, + 'total_paid_btc': 0, + 'total_paid_sats': 0, + 'total_paid_usd': 0, + 'unpaid_earnings': 0, + 'unpaid_earnings_sats': 0, + 'est_time_to_payout': 'Unknown', + 'monthly_summaries': [], + 'error': str(e), + 'timestamp': datetime.now(ZoneInfo(user_timezone)).isoformat() + } + + # Add notification about error + notification_service.add_notification( + "Error fetching earnings data", + f"Error: {str(e)}", + NotificationLevel.ERROR, + NotificationCategory.DATA + ) + + # Convert USD values to user's preferred currency if needed + if user_currency != 'USD' and earnings_data: + # Get exchange rate + try: + exchange_rates = dashboard_service.fetch_exchange_rates() + exchange_rate = exchange_rates.get(user_currency, 1.0) + + # Total paid conversion + if 'total_paid_usd' in earnings_data: + earnings_data['total_paid_fiat'] = earnings_data['total_paid_usd'] * exchange_rate + + # Monthly summaries conversion + if 'monthly_summaries' in earnings_data: + for month in earnings_data['monthly_summaries']: + if 'total_usd' in month: + month['total_fiat'] = month['total_usd'] * exchange_rate + except Exception as e: + logging.error(f"Error converting currency: {e}") + # Set fiat values equal to USD as fallback + if 'total_paid_usd' in earnings_data: + earnings_data['total_paid_fiat'] = earnings_data['total_paid_usd'] + + if 'monthly_summaries' in earnings_data: + for month in earnings_data['monthly_summaries']: + if 'total_usd' in month: + month['total_fiat'] = month['total_usd'] + else: + # If currency is USD, just copy USD values + if earnings_data: + if 'total_paid_usd' in earnings_data: + earnings_data['total_paid_fiat'] = earnings_data['total_paid_usd'] + + if 'monthly_summaries' in earnings_data: + for month in earnings_data['monthly_summaries']: + if 'total_usd' in month: + month['total_fiat'] = month['total_usd'] + + return render_template( + 'earnings.html', + earnings=earnings_data, + user_currency=user_currency, + user_timezone=user_timezone, + currency_symbols=currency_symbols, + current_time=datetime.now(ZoneInfo(user_timezone)).strftime("%b %d, %Y %I:%M:%S %p") + ) + except Exception as e: + logging.error(f"Error rendering earnings page: {e}") + import traceback + logging.error(traceback.format_exc()) + return render_template("error.html", message="Failed to load earnings data. Please try again later."), 500 + # Add the middleware app.wsgi_app = RobustMiddleware(app.wsgi_app) diff --git a/README.md b/README.md index 6e0aac3..5049be2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ This open-source dashboard provides real-time monitoring for Ocean.xyz pool mine - **Block Details**: Examine transaction counts, fees, and mining pool information - **Visual Indicators**: Track network difficulty and block discovery times +### Earnings Page +- **Detailed Earnings Breakdown**: View earnings by time period (daily, weekly, monthly) +- **Currency Conversion**: Automatically convert earnings to your preferred fiat currency +- **Historical Data**: Access past earnings data for analysis + ### System Resilience - **Connection Recovery**: Automatic reconnection after network interruptions - **Backup Polling**: Fallback to traditional polling if real-time connection fails @@ -145,6 +150,11 @@ For more details, refer to the [docker-compose documentation](https://docs.docke - Search and filtering functionality - Performance trend mini-charts +### Earnings Page +- Detailed earnings breakdown by time period +- Currency conversion for earnings in selected fiat +- Historical data for earnings analysis + ### Blocks Explorer - Recent block visualization with mining details @@ -152,6 +162,10 @@ For more details, refer to the [docker-compose documentation](https://docs.docke - Mining pool attribution - Block details modal with comprehensive data +### Notifications +- Real-time alerts for important events +- Notification history with read/unread status + ### System Monitor - Floating interface providing system statistics @@ -184,7 +198,19 @@ Built with a modern stack for reliability and performance: - `/api/health`: Returns the health status of the application. - `/api/notifications`: Manages notifications for the user. - `/api/workers`: Manages worker data and status. -- `/api/exchange_rates`: Fetches real-time exchange rates for supported currencies. +- `api/time`: Returns the current server time. +- `api/timezone`: Returns the current timezone. +- `api/scheduler-health`: Returns the health status of the scheduler. +- `api/fix-scheduler`: Fixes the scheduler if it is not running. +- `api/force-refresh`: Forces a refresh of the data. +- `api/reset-chart-data`: Resets the chart data. +- `api/memory-profile`: Returns the memory profile of the application. +- `api/memory-history`: Returns the memory history of the application. +- `api/force-gc`: Forces garbage collection to free up memory. +- `api/notifications/clear`: Clears all notifications. +- `api/notifications/delete`: Deletes a specific notification. +- `api/notifications/mark_read`: Marks a notification as read. +- `api/notifications/unread_count`: Returns the count of unread notifications. ## Project Structure @@ -206,6 +232,7 @@ DeepSea-Dashboard/ ├── requirements.txt # Python dependencies ├── Dockerfile # Docker configuration ├── docker-compose.yml # Docker Compose configuration +DeepSea-Dashboard/ │ ├── templates/ # HTML templates │ ├── base.html # Base template with common elements @@ -214,6 +241,7 @@ DeepSea-Dashboard/ │ ├── workers.html # Workers dashboard template │ ├── blocks.html # Bitcoin blocks template │ ├── notifications.html # Notifications template +│ ├── earnings.html # Earnings page template │ └── error.html # Error page template │ ├── static/ # Static assets @@ -224,6 +252,7 @@ DeepSea-Dashboard/ │ │ ├── boot.css # Boot sequence styles │ │ ├── blocks.css # Blocks page styles │ │ ├── notifications.css # Notifications page styles +│ │ ├── earnings.css # Earnings page styles │ │ ├── error.css # Error page styles │ │ ├── retro-refresh.css # Floating refresh bar styles │ │ └── theme-toggle.css # Theme toggle styles @@ -233,6 +262,7 @@ DeepSea-Dashboard/ │ ├── workers.js # Workers page functionality │ ├── blocks.js # Blocks page functionality │ ├── notifications.js # Notifications functionality +│ ├── earnings.js # Earnings page functionality │ ├── block-animation.js # Block mining animation │ ├── BitcoinProgressBar.js # System monitor functionality │ └── theme.js # Theme toggle functionality diff --git a/config.json b/config.json index bf40bf4..00ab1db 100644 --- a/config.json +++ b/config.json @@ -3,5 +3,6 @@ "power_usage": 0.0, "wallet": "yourwallethere", "timezone": "America/Los_Angeles", - "network_fee": 0.0 + "network_fee": 0.0, + "currency": "USD" } diff --git a/config.py b/config.py index 337815e..934052f 100644 --- a/config.py +++ b/config.py @@ -18,7 +18,8 @@ def load_config(): "power_usage": 0.0, "wallet": "yourwallethere", "timezone": "America/Los_Angeles", - "network_fee": 0.0 # Add default network fee + "network_fee": 0.0, # Add default network fee + "currency": "USD" # Default currency } if os.path.exists(CONFIG_FILE): @@ -94,3 +95,25 @@ def get_value(key, default=None): """ config = load_config() return config.get(key, default) + +def get_currency(): + """ + Get the configured currency with fallback to default. + + Returns: + str: Currency code (e.g., 'USD', 'EUR', etc.) + """ + # First check environment variable (for Docker) + import os + env_currency = os.environ.get("CURRENCY") + if env_currency: + return env_currency + + # Then check config file + config = load_config() + currency = config.get("currency") + if currency: + return currency + + # Default to USD + return "USD" diff --git a/data_service.py b/data_service.py index e5df4fc..5325300 100644 --- a/data_service.py +++ b/data_service.py @@ -1,4 +1,4 @@ -""" +""" Data service module for fetching and processing mining data. """ import logging @@ -489,6 +489,291 @@ class MiningDashboardService: except Exception as e: logging.error(f"Error fetching exchange rates: {e}") return {} + + def get_payment_history(self, max_pages=5, timeout=30, max_retries=3): + """ + Get payment history data from Ocean.xyz with retry logic. + + Args: + max_pages (int): Maximum number of pages to fetch + timeout (int): Timeout in seconds for each request + max_retries (int): Maximum number of retry attempts + + Returns: + list: List of payment history records + """ + logging.info(f"Fetching payment history data for wallet: {self.wallet}") + + 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' + } + + all_payments = [] + + try: + # Start with the main page + page_num = 0 + + while page_num < max_pages: + url = f"{stats_url}?ppage={page_num}#payouts-fulltable" + logging.info(f"Fetching payment history from: {url} (page {page_num+1} of max {max_pages})") + + # Add retry logic + retry_count = 0 + while retry_count < max_retries: + try: + response = self.session.get(url, headers=headers, timeout=timeout) + if response.ok: + break # Success, exit retry loop + else: + logging.warning(f"Got status code {response.status_code} fetching page {page_num}, retry {retry_count+1}/{max_retries}") + retry_count += 1 + time.sleep(1) # Wait before retrying + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + retry_count += 1 + logging.warning(f"Request timeout or connection error on page {page_num}, retry {retry_count}/{max_retries}: {e}") + if retry_count >= max_retries: + logging.error(f"Max retries reached for page {page_num}") + if page_num == 0: + # If we can't even get the first page, return empty list + return [] + else: + # If we got some data but hit timeout on later pages, just return what we have + logging.info(f"Returning {len(all_payments)} payments collected so far") + return all_payments + time.sleep(2) # Wait longer between retries + + # If we've exhausted all retries and still failed, break out of the loop + if retry_count >= max_retries and (not 'response' in locals() or not response.ok): + logging.error(f"Failed to fetch payment history page {page_num} after {max_retries} retries") + break + + soup = BeautifulSoup(response.text, 'html.parser') + + # Find the payments table + payments_table = soup.find('tbody', id='payouts-tablerows') + if not payments_table: + logging.warning(f"No payment table found on page {page_num}") + if page_num == 0: + # If we can't find the table on the first page, something is wrong + logging.error("Payment history table not found on main page") + return [] + else: + # If we found payments before but not on this page, we've reached the end + break + + # Find all payment rows + payment_rows = payments_table.find_all('tr', class_='table-row') + if not payment_rows: + logging.warning(f"No payment rows found on page {page_num}") + break + + logging.info(f"Found {len(payment_rows)} payment records on page {page_num}") + + # Process each payment row + for row in payment_rows: + cells = row.find_all('td', class_='table-cell') + + # Skip rows with insufficient data + if len(cells) < 3: + logging.warning(f"Payment row has too few cells: {len(cells)}") + continue + + try: + # Initialize payment record + payment = { + "date": "", + "txid": "", + "amount_btc": 0.0, + "amount_sats": 0, + "status": "confirmed" # Default status + } + + # Extract date from the first cell + date_cell = cells[0] + date_div = date_cell.find('div', class_='date-text') + if date_div: + payment["date"] = date_div.get_text(strip=True) + else: + payment["date"] = date_cell.get_text(strip=True) + + # Extract transaction ID from the second cell + tx_cell = cells[1] + tx_link = tx_cell.find('a') + if tx_link and tx_link.has_attr('href'): + tx_href = tx_link['href'] + # Extract transaction ID from the href + txid_match = re.search(r'/tx/([a-zA-Z0-9]+)', tx_href) + if txid_match: + payment["txid"] = txid_match.group(1) + else: + # If we can't extract from href, use the text but check for truncation + tx_text = tx_link.get_text(strip=True).replace('⛏️', '').strip() + # Check if this looks like a truncated TXID with ellipsis + if '...' in tx_text: + # Don't use truncated TXIDs as they're incomplete + payment["txid"] = "" + payment["truncated_txid"] = tx_text + else: + # Only use text content if it's a complete TXID (should be ~64 chars for BTC) + payment["txid"] = tx_text + + # Extract BTC amount from the third cell + amount_cell = cells[2] + amount_text = amount_cell.get_text(strip=True) + # Extract numeric amount from text like "0.01093877 BTC" + amount_match = re.search(r'([\d\.]+)', amount_text) + if amount_match: + try: + payment["amount_btc"] = float(amount_match.group(1)) + payment["amount_sats"] = int(round(payment["amount_btc"] * self.sats_per_btc)) + except ValueError as e: + logging.warning(f"Could not parse payment amount '{amount_text}': {e}") + + # Convert date string to ISO format if possible + try: + parsed_date = datetime.strptime(payment["date"], "%Y-%m-%d %H:%M") + utc_date = parsed_date.replace(tzinfo=ZoneInfo("UTC")) + local_date = utc_date.astimezone(ZoneInfo(get_timezone())) + payment["date_iso"] = local_date.isoformat() + except Exception as e: + logging.warning(f"Could not parse payment date '{payment['date']}': {e}") + payment["date_iso"] = None + + all_payments.append(payment) + + except Exception as e: + logging.error(f"Error processing payment row: {e}") + continue + + # If we have no payments after processing the first page, stop + if page_num == 0 and not all_payments: + logging.warning("No payment data could be extracted from the first page") + break + + # Move to the next page + page_num += 1 + + logging.info(f"Retrieved {len(all_payments)} payment records across {page_num} pages") + return all_payments + + except Exception as e: + logging.error(f"Error fetching payment history: {e}") + import traceback + logging.error(traceback.format_exc()) + return [] + + def get_earnings_data(self): + """ + Get comprehensive earnings data from Ocean.xyz with improved error handling. + + Returns: + dict: Earnings data including payment history and statistics + """ + try: + # Get the payment history with longer timeout and retries + payments = self.get_payment_history(max_pages=30, timeout=20, max_retries=3) + + # Get basic Ocean data for summary metrics (with timeout handling) + try: + ocean_data = self.get_ocean_data() + except Exception as e: + logging.error(f"Error fetching ocean data for earnings: {e}") + ocean_data = None + + # Calculate summary statistics + total_paid = sum(payment["amount_btc"] for payment in payments) + total_paid_sats = sum(payment["amount_sats"] for payment in payments) + + # Get the latest BTC price with fallback + try: + _, _, btc_price, _ = self.get_bitcoin_stats() + if not btc_price: + btc_price = 75000 # Default value if fetch fails + except Exception as e: + logging.error(f"Error getting BTC price: {e}") + btc_price = 75000 # Default value + + # Calculate USD value + total_paid_usd = round(total_paid * btc_price, 2) + + # Organize payments by month + payments_by_month = {} + for payment in payments: + if payment.get("date_iso"): + try: + month_key = payment["date_iso"][:7] # YYYY-MM format + month_name = datetime.strptime(month_key, "%Y-%m").strftime("%B %Y") + + if month_key not in payments_by_month: + payments_by_month[month_key] = { + "month": month_key, + "month_name": month_name, + "payments": [], + "total_btc": 0.0, + "total_sats": 0, + "total_usd": 0.0 + } + payments_by_month[month_key]["payments"].append(payment) + payments_by_month[month_key]["total_btc"] += payment["amount_btc"] + payments_by_month[month_key]["total_sats"] += payment["amount_sats"] + payments_by_month[month_key]["total_usd"] = round( + payments_by_month[month_key]["total_btc"] * btc_price, 2 + ) + except Exception as e: + logging.error(f"Error processing payment for monthly grouping: {e}") + + # Convert to list and sort by month (newest first) + monthly_summaries = list(payments_by_month.values()) + monthly_summaries.sort(key=lambda x: x["month"], reverse=True) + + # Calculate additional statistics + avg_payment = total_paid / len(payments) if payments else 0 + avg_payment_sats = int(round(avg_payment * self.sats_per_btc)) if avg_payment else 0 + + # Get unpaid earnings from Ocean data + unpaid_earnings = ocean_data.unpaid_earnings if ocean_data else None + unpaid_earnings_sats = int(round(unpaid_earnings * self.sats_per_btc)) if unpaid_earnings is not None else None + + # Create result dictionary + result = { + "payments": payments, + "total_payments": len(payments), + "total_paid_btc": total_paid, + "total_paid_sats": total_paid_sats, + "total_paid_usd": total_paid_usd, + "avg_payment_btc": avg_payment, + "avg_payment_sats": avg_payment_sats, + "btc_price": btc_price, + "monthly_summaries": monthly_summaries, + "unpaid_earnings": unpaid_earnings, + "unpaid_earnings_sats": unpaid_earnings_sats, + "est_time_to_payout": ocean_data.est_time_to_payout if ocean_data else None, + "timestamp": datetime.now(ZoneInfo(get_timezone())).isoformat() + } + + # Add last payment date if available + if payments: + result["last_payment_date"] = payments[0]["date"] + result["last_payment_amount_btc"] = payments[0]["amount_btc"] + result["last_payment_amount_sats"] = payments[0]["amount_sats"] + + return result + + except Exception as e: + logging.error(f"Error fetching earnings data: {e}") + import traceback + logging.error(traceback.format_exc()) + return { + "payments": [], + "total_payments": 0, + "error": str(e), + "timestamp": datetime.now(ZoneInfo(get_timezone())).isoformat() + } def get_bitcoin_stats(self): """ diff --git a/setup.py b/setup.py index 72a63bf..9e514ef 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ FILE_MAPPINGS = { 'blocks.css': 'static/css/blocks.css', 'notifications.css': 'static/css/notifications.css', 'theme-toggle.css': 'static/css/theme-toggle.css', # Added theme-toggle.css + 'earnings.css': 'static/css/earnings.css', # Added earnings.css # JS files 'main.js': 'static/js/main.js', @@ -69,6 +70,7 @@ FILE_MAPPINGS = { 'BitcoinProgressBar.js': 'static/js/BitcoinProgressBar.js', 'notifications.js': 'static/js/notifications.js', 'theme.js': 'static/js/theme.js', # Added theme.js + 'earnings.js': 'static/js/earnings.js', # Added earnings.js # Template files 'base.html': 'templates/base.html', @@ -78,6 +80,7 @@ FILE_MAPPINGS = { 'error.html': 'templates/error.html', 'blocks.html': 'templates/blocks.html', 'notifications.html': 'templates/notifications.html', + 'earnings.html': 'templates/earnings.html', # Added earnings.html } # Default configuration @@ -86,7 +89,8 @@ DEFAULT_CONFIG = { "power_usage": 0.0, "wallet": "yourwallethere", "timezone": "America/Los_Angeles", # Added default timezone - "network_fee": 0.0 # Added default network fee + "network_fee": 0.0, # Added default network fee + "currency": "USD" } def parse_arguments(): diff --git a/static/css/common.css b/static/css/common.css index 99c714a..385e0ce 100644 --- a/static/css/common.css +++ b/static/css/common.css @@ -280,11 +280,12 @@ h1 { /* Container */ .container-fluid { - max-width: 1200px; - margin: 0 auto; - padding-left: 1rem; - padding-right: 1rem; - position: relative; + max-width: 1200px; + margin: 0 auto; + padding-left: 1rem; + padding-right: 1rem; + position: relative; + padding-bottom: 6rem; } /* Status indicators */ @@ -482,6 +483,14 @@ h1 { } } +@media (max-width: 576px) { + .nav-link { + font-size: 0.8rem; /* Reduce font size for smaller screens */ + padding: 3px 10px; /* Adjust padding to save space */ + } +} + + /* Navigation badges for notifications */ .nav-badge { background-color: var(--primary-color); @@ -495,3 +504,4 @@ h1 { margin-left: 5px; vertical-align: middle; } + diff --git a/static/css/dashboard.css b/static/css/dashboard.css index bf98c4f..f3b1832 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -182,10 +182,6 @@ 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: 100px !important; /* Enough space for minimized monitor */ -} /* Add these styles to dashboard.css */ @keyframes pulse-block-marker { diff --git a/static/css/earnings.css b/static/css/earnings.css new file mode 100644 index 0000000..5078591 --- /dev/null +++ b/static/css/earnings.css @@ -0,0 +1,560 @@ +/* earnings.css - Enhanced with dashboard styling */ + +/* Main section styling */ +.earnings-section { + margin: 2rem 0; +} + +/* Dashboard title styling */ +.dashboard-title { + color: var(--primary-color); + text-transform: uppercase; + margin-bottom: 1.5rem; + font-family: var(--header-font); + letter-spacing: 2px; + text-shadow: 0 0 10px var(--primary-color, rgba(0, 136, 204, 0.5)); +} + +/* Stats grid styling for summary cards */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background-color: #000; + padding: 1rem; + border: 1px solid var(--primary-color); + box-shadow: 0 0 10px var(--primary-color, rgba(247, 147, 26, 0.2)); + position: relative; +} + + .stat-card h2 { + color: var(--primary-color); + font-size: 1.2rem; + margin-bottom: 0.5rem; + text-transform: uppercase; + font-family: var(--header-font); + } + +.stat-value { + font-size: 1.8rem; + font-weight: bold; + margin-bottom: 0.5rem; +} + +.stat-unit { + font-size: 1rem; + color: var(--text-color); + margin-left: 0.25rem; +} + +.stat-secondary { + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.stat-time { + margin-top: 0.5rem; + font-size: 0.9rem; + color: #00dfff; /* Blue color for time values */ +} + +/* Card and container styling similar to dashboard */ +.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%; + } + +/* Section headers like dashboard blue headers */ +.earnings-section h2 { + background-color: var(--primary-color, #0088cc); + color: #000; + padding: 0.5rem; + font-weight: bold; + font-family: var(--header-font); + text-transform: uppercase; + margin-bottom: 0.5rem; + border: 1px solid var(--primary-color); + box-shadow: 0 0 5px var(--primary-color, rgba(0, 136, 204, 0.5)); +} + +/* Table styling */ +.table-container { + overflow-x: auto; + margin: 1rem 0; + background-color: #000; + padding: 0.5rem; + border: 1px solid var(--primary-color); + box-shadow: 0 0 10px var(--primary-color, rgba(247, 147, 26, 0.2)); + position: relative; +} + + /* Add scanline effect to tables */ + .table-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; + } + +.earnings-table { + width: 100%; + border-collapse: collapse; +} + + .earnings-table th, .earnings-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + .earnings-table th { + background-color: var(--card-header-bg); + color: var(--primary-color); + font-family: var(--header-font); + text-transform: uppercase; + font-size: 0.9rem; + } + +/* Transaction link styling */ +.tx-link { + color: #00dfff; /* Blue color from dashboard */ + text-decoration: none; + letter-spacing: 1px; +} + + .tx-link:hover { + text-decoration: underline; + text-shadow: 0 0 5px var(--primary-color, rgba(0, 223, 255, 0.7)); + } + +/* Status indicators with color families similar to dashboard */ +.status-indicator { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; +} + +.status-confirmed { + background-color: rgba(75, 181, 67, 0.15); + color: #32CD32; /* Matched with green color family from dashboard */ +} + +.status-pending { + background-color: rgba(247, 147, 26, 0.15); + color: #ffd700; /* Matched with yellow color family from dashboard */ +} + +.status-processing { + background-color: rgba(52, 152, 219, 0.15); + color: #00dfff; /* Matched with blue metrics from dashboard */ +} + +/* Metric styling by category - matching dashboard */ +.metric-value { + color: var(--text-color); + font-weight: bold; +} + +/* Yellow color family - for BTC/sats values */ +#unpaid-sats, +#total-paid-sats, +.earnings-table td:nth-child(4) { + color: #ffd700; +} + +#unpaid-btc, +#total-paid-btc, +.earnings-table td:nth-child(3) { + color: #ffd700; +} + +/* Green color family - for earnings/profits */ +#total-paid-usd { + color: #32CD32; +} + +/* Green color for fiat currency amount */ +#total-paid-fiat { + color: #32CD32; /* Using the same green already in your theme */ +} + +.earnings-table td:nth-child(5) { + color: #32CD32; /* USD values */ +} + +/* Red color family - for fees/costs */ +.earnings-fee, +.earnings-cost { + color: #ff5555 !important; +} + +/* Styling for date fields */ +.earnings-table td:nth-child(1) { + color: #00dfff; /* Blue date indicators */ +} + +/* Add bounce animations from dashboard */ +@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; +} + +.arrow { + display: inline-block; + font-weight: bold; + margin-left: 0.5rem; +} + +/* Add pulse animation for new payments */ +@keyframes pulse-highlight { + 0% { + background-color: color-mix(in srgb, var(--primary-color, rgba(247, 147, 26, 0.1)) 10%, transparent); + } + + 50% { + background-color: color-mix(in srgb, var(--primary-color, rgba(247, 147, 26, 0.3)) 30%, transparent); + } + + 100% { + background-color: color-mix(in srgb, var(--primary-color, rgba(247, 147, 26, 0.1)) 10%, transparent); + } +} + +.new-payment { + animation: pulse-highlight 2s infinite; +} + +/* Card body styling consistency */ +.card-body strong { + color: var(--primary-color); + margin-right: 0.25rem; +} + +.card-body p { + margin: 0.25rem 0; + line-height: 1.2; +} + +/* Monospace styling for numerical values */ +#payment-count { + font-size: 1.5rem; +} + +/* Font styling for Latest payment */ +#latest-payment { + color: #00dfff; + font-size: 0.9rem; +} + +/* Status label specific styling with uppercase text */ +.status-confirmed, +.status-pending, +.status-processing { + text-transform: uppercase; + letter-spacing: 1px; + text-align: center; +} + +/* Empty state styling */ +.earnings-table tr td[colspan] { + text-align: center; + padding: 2rem; + color: var(--text-color); + font-style: italic; +} + +/* Pool luck indicators - reused from dashboard */ +.very-lucky { + color: #32CD32 !important; + font-weight: bold !important; +} + +.lucky { + color: #90EE90 !important; +} + +.normal-luck { + color: #ffd700 !important; +} + +.unlucky { + color: #ff5555 !important; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .earnings-table th, .earnings-table td { + padding: 0.5rem; + font-size: 0.85rem; + } + + .status-indicator { + font-size: 0.7rem; + padding: 0.15rem 0.3rem; + } + + .stat-value { + font-size: 1.5rem; + } +} + +/* Chart container styling for future charts */ +.chart-container { + background-color: #000; + padding: 0.5rem; + margin-bottom: 1rem; + height: 230px; + border: 1px solid var(--primary-color); + box-shadow: 0 0 10px var(--primary-color, rgba(247, 147, 26, 0.2)); + position: relative; +} + + .chart-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; + } + +.settings-info { + display: flex; + justify-content: flex-end; + margin-bottom: 15px; + font-size: 0.9em; + color: #888; +} + +.setting-item { + margin-left: 20px; +} + + .setting-item strong { + color: var(--accent-color); + } + +.currency-symbol { + display: inline-block; + margin-right: 2px; +} + +/* Table styling - Enhanced for mobile */ +.table-container { + overflow-x: auto; + margin: 1rem 0; + background-color: #000; + padding: 0.5rem; + border: 1px solid var(--primary-color); + box-shadow: 0 0 10px var(--primary-color, rgba(247, 147, 26, 0.2)); + position: relative; + /* Add momentum-based scrolling for touch devices */ + -webkit-overflow-scrolling: touch; +} + +/* Responsive adjustments - Expanded */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .earnings-table th, .earnings-table td { + padding: 0.5rem; + font-size: 0.85rem; + } + + .status-indicator { + font-size: 0.7rem; + padding: 0.15rem 0.3rem; + } + + .stat-value { + font-size: 1.5rem; + } + + /* Additional mobile optimizations */ + .tx-link { + font-size: 0.8rem; + } + + .settings-info { + flex-direction: column; + align-items: flex-start; + } + + .setting-item { + margin-left: 0; + margin-bottom: 5px; + } +} + +/* Small phone screens */ +@media (max-width: 480px) { + /* Convert payment history table to card-like layout for very small screens */ + .earnings-table thead { + display: none; /* Hide table headers */ + } + + .earnings-table, + .earnings-table tbody, + .earnings-table tr { + display: block; + width: 100%; + } + + .earnings-table tr { + margin-bottom: 15px; + border: 1px solid var(--border-color); + padding: 8px; + } + + .earnings-table td { + display: flex; + padding: 6px 3px; + border: none; + border-bottom: 1px solid var(--border-color, #333); + } + + .earnings-table td:last-child { + border-bottom: none; + } + + /* Add labels for each cell */ + .earnings-table td::before { + content: attr(data-label); + font-weight: bold; + width: 40%; + color: var(--primary-color); + margin-right: 5%; + } + + /* Specific adjustments for payment history */ + #payment-history-table td:nth-child(1)::before { + content: "Date: "; + } + + #payment-history-table td:nth-child(2)::before { + content: "BTC: "; + } + + #payment-history-table td:nth-child(3)::before { + content: "Sats: "; + } + + #payment-history-table td:nth-child(4)::before { + content: "TX: "; + } + + #payment-history-table td:nth-child(5)::before { + content: "Status: "; + } + + /* Monthly summary table */ + #monthly-summary-table td:nth-child(1)::before { + content: "Month: "; + } + + #monthly-summary-table td:nth-child(2)::before { + content: "Payments: "; + } + + #monthly-summary-table td:nth-child(3)::before { + content: "BTC: "; + } + + #monthly-summary-table td:nth-child(4)::before { + content: "Sats: "; + } + + #monthly-summary-table td:nth-child(5)::before { + content: "Fiat: "; + } + + /* Adjust empty state */ + .earnings-table tr td[colspan] { + padding: 1rem; + } + + /* Fix truncated transaction links */ + .tx-link { + word-break: break-all; + font-size: 0.75rem; + } +} diff --git a/static/css/workers.css b/static/css/workers.css index 0e42d15..185793a 100644 --- a/static/css/workers.css +++ b/static/css/workers.css @@ -330,7 +330,7 @@ } /* Add extra padding at bottom of worker grid to avoid overlap */ .worker-grid { - margin-bottom: 120px; + margin-bottom: 20px; } /* Ensure summary stats have proper spacing on mobile */ diff --git a/static/js/earnings.js b/static/js/earnings.js new file mode 100644 index 0000000..8d612d2 --- /dev/null +++ b/static/js/earnings.js @@ -0,0 +1,169 @@ +// earnings.js +document.addEventListener('DOMContentLoaded', function () { + console.log('Earnings page loaded'); + + // Add refresh functionality if needed + setupAutoRefresh(); + + // Format all currency values with commas + formatCurrencyValues(); + + // Apply user timezone formatting to all dates + applyUserTimezoneFormatting(); + + // Initialize the system monitor + initializeSystemMonitor(); +}); + +// Initialize the BitcoinMinuteRefresh system monitor +function initializeSystemMonitor() { + // Define refresh function for the system monitor + window.manualRefresh = function () { + console.log("Manual refresh triggered by system monitor"); + location.reload(); + }; + + // Initialize system monitor if it's available + if (typeof BitcoinMinuteRefresh !== 'undefined') { + // Get server time and initialize + fetchServerTimeAndInitializeMonitor(); + } else { + console.warn("BitcoinMinuteRefresh not available"); + } +} + +// Fetch server time and initialize the monitor +function fetchServerTimeAndInitializeMonitor() { + fetch('/api/time') + .then(response => response.json()) + .then(data => { + if (data && data.server_time) { + const serverTime = new Date(data.server_time).getTime(); + const clientTime = Date.now(); + const offset = serverTime - clientTime; + + // Get server start time + fetch('/api/server-start') + .then(response => response.json()) + .then(startData => { + if (startData && startData.start_time) { + const startTime = new Date(startData.start_time).getTime(); + + // Initialize the system monitor with server time info + if (typeof BitcoinMinuteRefresh !== 'undefined') { + BitcoinMinuteRefresh.initialize(window.manualRefresh); + BitcoinMinuteRefresh.updateServerTime(offset, startTime); + } + } + }) + .catch(error => { + console.error("Error fetching server start time:", error); + // Initialize with just time offset if server start time fails + if (typeof BitcoinMinuteRefresh !== 'undefined') { + BitcoinMinuteRefresh.initialize(window.manualRefresh); + BitcoinMinuteRefresh.updateServerTime(offset, Date.now() - 3600000); // fallback to 1 hour ago + } + }); + } + }) + .catch(error => { + console.error("Error fetching server time:", error); + // Initialize without server time if API fails + if (typeof BitcoinMinuteRefresh !== 'undefined') { + BitcoinMinuteRefresh.initialize(window.manualRefresh); + } + }); +} + +// Function to format currency values with commas +function formatCurrency(amount) { + return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +// Format all currency values on the page +function formatCurrencyValues() { + // Format USD/fiat values in monthly summaries table + const monthlyFiatCells = document.querySelectorAll('.earnings-table td:nth-child(5)'); + monthlyFiatCells.forEach(cell => { + const currencySymbol = cell.querySelector('.currency-symbol'); + const symbol = currencySymbol ? currencySymbol.textContent : ''; + + // Remove symbol temporarily to parse the value + let valueText = cell.textContent; + if (currencySymbol) { + valueText = valueText.replace(symbol, ''); + } + + const value = parseFloat(valueText.replace(/[^\d.-]/g, '')); + if (!isNaN(value)) { + // Keep the currency symbol and add commas to the number + cell.innerHTML = `${symbol}${formatCurrency(value.toFixed(2))}`; + } + }); + + // Format all sats values + const satsElements = document.querySelectorAll('#unpaid-sats, #total-paid-sats, .earnings-table td:nth-child(4)'); + satsElements.forEach(element => { + if (element) { + const rawValue = element.textContent.replace(/,/g, '').trim(); + if (!isNaN(parseInt(rawValue))) { + element.textContent = formatCurrency(parseInt(rawValue)); + } + } + }); + + // Format payment count + const paymentCount = document.getElementById('payment-count'); + if (paymentCount && !isNaN(parseInt(paymentCount.textContent))) { + paymentCount.textContent = formatCurrency(parseInt(paymentCount.textContent)); + } +} + +function setupAutoRefresh() { + // Check if refresh is enabled in the UI + const refreshToggle = document.getElementById('refresh-toggle'); + if (refreshToggle && refreshToggle.checked) { + // Set a refresh interval (e.g., every 5 minutes) + setInterval(function () { + location.reload(); + }, 5 * 60 * 1000); + } +} + +// Function to format BTC values +function formatBTC(btcValue) { + return parseFloat(btcValue).toFixed(8); +} + +// Function to format sats with commas +function formatSats(satsValue) { + return formatCurrency(parseInt(satsValue)); +} + +// Function to format USD values with commas +function formatUSD(usdValue) { + return formatCurrency(parseFloat(usdValue).toFixed(2)); +} + +// Function to apply user timezone formatting to dates +function applyUserTimezoneFormatting() { + // Store timezone for use by system monitor + window.dashboardTimezone = userTimezone || 'America/Los_Angeles'; + + // This function would format dates according to user timezone preference + // when dates are dynamically loaded or updated via JavaScript +} + +// Function to format a timestamp based on user timezone +function formatDateToUserTimezone(timestamp) { + const timezone = window.userTimezone || 'America/Los_Angeles'; + + return new Date(timestamp).toLocaleString('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} diff --git a/static/js/main.js b/static/js/main.js index 96f021d..55fbbfe 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -345,24 +345,41 @@ if (window['chartjs-plugin-annotation']) { // Hashrate Normalization Utilities // Enhanced normalizeHashrate function with better error handling for units -function normalizeHashrate(value, unit) { - if (!value || isNaN(value)) return 0; +/** + * Normalizes hashrate values to TH/s (terahashes per second) for consistent comparison + * @param {number|string} value - The hashrate value to normalize + * @param {string} unit - The unit of the provided hashrate (e.g., 'ph/s', 'th/s', 'gh/s') + * @param {boolean} [debug=false] - Whether to output detailed debugging information + * @returns {number} - The normalized hashrate value in TH/s + */ +function normalizeHashrate(value, unit, debug = false) { + // Handle null, undefined, empty strings or non-numeric values + if (value === null || value === undefined || value === '' || isNaN(parseFloat(value))) { + if (debug) console.warn(`Invalid hashrate value: ${value}`); + return 0; + } - // Validate and normalize input value + // Convert to number and handle scientific notation (e.g., "1.23e+5") value = parseFloat(value); // Standardize unit handling with a lookup table const unit_normalized = (unit || 'th/s').toLowerCase().trim(); + // Store original values for logging + const originalValue = value; + const originalUnit = unit; + // Lookup table for conversion factors (all relative to TH/s) const unitConversions = { - 'ph/s': 1000, - 'p/s': 1000, - 'p': 1000, - 'petahash': 1000, - 'petahash/s': 1000, - 'peta': 1000, + // Zettahash (ZH/s) - 1 ZH/s = 1,000,000,000 TH/s + 'zh/s': 1000000000, + 'z/s': 1000000000, + 'z': 1000000000, + 'zettahash': 1000000000, + 'zettahash/s': 1000000000, + 'zetta': 1000000000, + // Exahash (EH/s) - 1 EH/s = 1,000,000 TH/s 'eh/s': 1000000, 'e/s': 1000000, 'e': 1000000, @@ -370,6 +387,15 @@ function normalizeHashrate(value, unit) { 'exahash/s': 1000000, 'exa': 1000000, + // Petahash (PH/s) - 1 PH/s = 1,000 TH/s + 'ph/s': 1000, + 'p/s': 1000, + 'p': 1000, + 'petahash': 1000, + 'petahash/s': 1000, + 'peta': 1000, + + // Terahash (TH/s) - Base unit 'th/s': 1, 't/s': 1, 't': 1, @@ -377,6 +403,7 @@ function normalizeHashrate(value, unit) { 'terahash/s': 1, 'tera': 1, + // Gigahash (GH/s) - 1 TH/s = 1,000 GH/s 'gh/s': 1 / 1000, 'g/s': 1 / 1000, 'g': 1 / 1000, @@ -384,6 +411,7 @@ function normalizeHashrate(value, unit) { 'gigahash/s': 1 / 1000, 'giga': 1 / 1000, + // Megahash (MH/s) - 1 TH/s = 1,000,000 MH/s 'mh/s': 1 / 1000000, 'm/s': 1 / 1000000, 'm': 1 / 1000000, @@ -391,6 +419,7 @@ function normalizeHashrate(value, unit) { 'megahash/s': 1 / 1000000, 'mega': 1 / 1000000, + // Kilohash (KH/s) - 1 TH/s = 1,000,000,000 KH/s 'kh/s': 1 / 1000000000, 'k/s': 1 / 1000000000, 'k': 1 / 1000000000, @@ -398,44 +427,72 @@ function normalizeHashrate(value, unit) { 'kilohash/s': 1 / 1000000000, 'kilo': 1 / 1000000000, + // Hash (H/s) - 1 TH/s = 1,000,000,000,000 H/s 'h/s': 1 / 1000000000000, 'h': 1 / 1000000000000, 'hash': 1 / 1000000000000, 'hash/s': 1 / 1000000000000 }; - // Try to find the conversion factor let conversionFactor = null; + let matchedUnit = null; - // First try direct lookup + // Direct lookup for exact matches if (unitConversions.hasOwnProperty(unit_normalized)) { conversionFactor = unitConversions[unit_normalized]; + matchedUnit = unit_normalized; } else { - // If direct lookup fails, try a fuzzy match + // Fuzzy matching for non-exact matches for (const knownUnit in unitConversions) { if (unit_normalized.includes(knownUnit) || knownUnit.includes(unit_normalized)) { - // Log the unit correction for debugging - console.log(`Fuzzy matching unit: "${unit}" → interpreted as "${knownUnit}" (conversion: ${unitConversions[knownUnit]})`); conversionFactor = unitConversions[knownUnit]; + matchedUnit = knownUnit; + + if (debug) { + console.log(`Fuzzy matching unit: "${unit}" → interpreted as "${knownUnit}" (conversion: ${unitConversions[knownUnit]})`); + } break; } } } - // If no conversion factor found, assume TH/s but log a warning + // Handle unknown units if (conversionFactor === null) { console.warn(`Unrecognized hashrate unit: "${unit}", assuming TH/s. Value: ${value}`); - // Add additional info to help diagnose incorrect units + + // Automatically detect and suggest the appropriate unit based on magnitude if (value > 1000) { console.warn(`NOTE: Value ${value} is quite large for TH/s. Could it be PH/s?`); + } else if (value > 1000000) { + console.warn(`NOTE: Value ${value} is extremely large for TH/s. Could it be EH/s?`); } else if (value < 0.001) { console.warn(`NOTE: Value ${value} is quite small for TH/s. Could it be GH/s or MH/s?`); } - return value; // Assume TH/s + + // Assume TH/s as fallback + conversionFactor = 1; + matchedUnit = 'th/s'; } - // Apply conversion and return normalized value - return value * conversionFactor; + // Calculate normalized value + const normalizedValue = value * conversionFactor; + + // Log abnormally large conversions for debugging + if ((normalizedValue > 1000000 || normalizedValue < 0.000001) && normalizedValue !== 0) { + console.log(`Large scale conversion detected: ${originalValue} ${originalUnit} → ${normalizedValue.toExponential(2)} TH/s`); + } + + // Extra debugging for very large values to help track the Redis storage issue + if (debug && originalValue > 900000 && matchedUnit === 'th/s') { + console.group('High Hashrate Debug Info'); + console.log(`Original: ${originalValue} ${originalUnit}`); + console.log(`Normalized: ${normalizedValue} TH/s`); + console.log(`Should be displayed as: ${(normalizedValue / 1000).toFixed(2)} PH/s`); + console.log('Call stack:', new Error().stack); + console.groupEnd(); + } + + return normalizedValue; } // Helper function to format hashrate values for display @@ -1306,183 +1363,294 @@ function updateChartWithNormalizedData(chart, data) { } } - // Process history data with enhanced validation and error handling + /** + * Process history data with comprehensive validation, unit normalization, and performance optimizations + * @param {Object} data - The metrics data containing hashrate history + * @param {Object} chart - The Chart.js chart instance to update + * @param {boolean} useHashrate3hr - Whether to use 3hr average data instead of 60sec data + * @param {number} normalizedAvg - The normalized 24hr average hashrate for reference + */ if (data.arrow_history && data.arrow_history.hashrate_60sec) { // Validate history data try { - // Log 60sec data - console.log("60sec history data received:", data.arrow_history.hashrate_60sec); + const perfStart = performance.now(); // Performance measurement - // Also log 3hr data if available - if (data.arrow_history.hashrate_3hr) { - console.log("3hr history data received:", data.arrow_history.hashrate_3hr); + // Determine which history data to use (3hr or 60sec) with proper fallback + let historyData; + let dataSource; + + if (useHashrate3hr && data.arrow_history.hashrate_3hr && data.arrow_history.hashrate_3hr.length > 0) { + historyData = data.arrow_history.hashrate_3hr; + dataSource = "3hr"; + chart.data.datasets[0].label = 'Hashrate Trend (3HR AVG)'; } else { - console.log("3hr history data not available in API response"); + historyData = data.arrow_history.hashrate_60sec; + dataSource = "60sec"; + chart.data.datasets[0].label = 'Hashrate Trend (60SEC AVG)'; + + // If we wanted 3hr data but it wasn't available, log a warning + if (useHashrate3hr) { + console.warn("3hr data requested but not available, falling back to 60sec data"); + } } - // If we're using 3hr average, try to use that history if available - const historyData = useHashrate3hr && data.arrow_history.hashrate_3hr ? - data.arrow_history.hashrate_3hr : data.arrow_history.hashrate_60sec; - - console.log("Using history data:", useHashrate3hr ? "3hr data" : "60sec data"); + console.log(`Using ${dataSource} history data with ${historyData?.length || 0} points`); if (historyData && historyData.length > 0) { - // Format time labels - chart.data.labels = historyData.map(item => { + // Pre-process history data to filter out invalid entries + const validHistoryData = historyData.filter(item => { + return item && + (typeof item.value !== 'undefined') && + !isNaN(parseFloat(item.value)) && + (parseFloat(item.value) >= 0) && + typeof item.time === 'string'; + }); + + if (validHistoryData.length < historyData.length) { + console.warn(`Filtered out ${historyData.length - validHistoryData.length} invalid data points`); + } + + if (validHistoryData.length === 0) { + console.warn("No valid history data points after filtering"); + useSingleDataPoint(); + return; + } + + // Format time labels more efficiently (do this once, not in a map callback) + const timeZone = dashboardTimezone || 'America/Los_Angeles'; + const now = new Date(); + const yearMonthDay = { + year: now.getFullYear(), + month: now.getMonth(), + day: now.getDate() + }; + + // Create time formatter function with consistent options + const timeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: timeZone, + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + + // Format all time labels at once + const formattedLabels = validHistoryData.map(item => { const timeStr = item.time; try { - // Parse and format the time (existing code)... - let timeParts; + // Parse time efficiently + let hours = 0, minutes = 0, seconds = 0; + if (timeStr.length === 8 && timeStr.indexOf(':') !== -1) { // Format: HH:MM:SS - timeParts = timeStr.split(':'); + const parts = timeStr.split(':'); + hours = parseInt(parts[0], 10); + minutes = parseInt(parts[1], 10); + seconds = parseInt(parts[2], 10); } else if (timeStr.length === 5 && timeStr.indexOf(':') !== -1) { // Format: HH:MM - timeParts = timeStr.split(':'); - timeParts.push('00'); // Add seconds + const parts = timeStr.split(':'); + hours = parseInt(parts[0], 10); + minutes = parseInt(parts[1], 10); } else { - return timeStr; // Use as-is if format is unexpected + return timeStr; // Use original if format is unexpected } - // Format in 12-hour time with timezone support - const now = new Date(); - const timeDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), - parseInt(timeParts[0]), parseInt(timeParts[1]), parseInt(timeParts[2] || 0)); + // Create time date with validation + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds) || + hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { + return timeStr; // Use original for invalid times + } - let formattedTime = timeDate.toLocaleTimeString('en-US', { - timeZone: dashboardTimezone || 'America/Los_Angeles', - hour: '2-digit', - minute: '2-digit', - hour12: true - }); + const timeDate = new Date(yearMonthDay.year, yearMonthDay.month, yearMonthDay.day, + hours, minutes, seconds); - // Remove the AM/PM part - formattedTime = formattedTime.replace(/\s[AP]M$/i, ''); - return formattedTime; + // Format using the formatter + const formatted = timeFormatter.format(timeDate); + return formatted.replace(/\s[AP]M$/i, ''); // Remove AM/PM } catch (e) { - console.error("Error formatting time:", e, timeStr); + console.error("Time formatting error:", e); return timeStr; // Use original on error } }); - // Process and normalize hashrate values with validation + chart.data.labels = formattedLabels; + + // Process and normalize hashrate values with validation (optimize by avoiding multiple iterations) const hashrateValues = []; - const validatedData = historyData.map((item, index) => { + const validatedData = new Array(validHistoryData.length); + + // Enhanced unit validation + const validUnits = new Set(['th/s', 'ph/s', 'eh/s', 'gh/s', 'mh/s', 'zh/s']); + + // Process all data points with error boundaries around each item + for (let i = 0; i < validHistoryData.length; i++) { try { + const item = validHistoryData[i]; + // Safety conversion in case value is a string - const val = parseFloat(item.value || 0); - if (isNaN(val)) { - console.warn(`Invalid value at index ${index}: ${item.value}`); - return 0; + const val = parseFloat(item.value); + + // Get unit with better validation + let unit = (item.unit || 'th/s').toLowerCase().trim(); + + // Use storeHashrateWithUnit to properly handle unit conversions for large values + // This increases chart precision by storing values in appropriate units + if (typeof window.storeHashrateWithUnit === 'function') { + // Use our specialized function if available + const storedFormat = window.storeHashrateWithUnit(val, unit); + const normalizedValue = normalizeHashrate(val, unit); + + // Store the properly adjusted values for tooltip display + item.storageValue = storedFormat.value; + item.storageUnit = storedFormat.unit; + item.originalValue = val; + item.originalUnit = unit; + + validatedData[i] = normalizedValue; + + // Collect valid values for statistics + if (normalizedValue > 0) { + hashrateValues.push(normalizedValue); + } + } else { + // Original approach if storeHashrateWithUnit isn't available + const normalizedValue = normalizeHashrate(val, unit); + + // Store original values for tooltip reference + item.originalValue = val; + item.originalUnit = unit; + + validatedData[i] = normalizedValue; + + // Collect valid values for statistics + if (normalizedValue > 0) { + hashrateValues.push(normalizedValue); + } } - - // Validate the unit - const unit = item.unit || 'th/s'; - const normalizedValue = normalizeHashrate(val, unit); - - // Collect valid values for statistics - if (normalizedValue > 0) { - hashrateValues.push(normalizedValue); - } - - return normalizedValue; } catch (err) { - console.error(`Error processing hashrate at index ${index}:`, err); - return 0; - } - }); - - chart.data.datasets[0].data = validatedData; - - // Calculate statistics for anomaly detection - if (hashrateValues.length > 1) { - const mean = hashrateValues.reduce((sum, val) => sum + val, 0) / hashrateValues.length; - const max = Math.max(...hashrateValues); - const min = Math.min(...hashrateValues); - - // Check for outliers that might indicate incorrect units - if (max > mean * 10 || min < mean / 10) { - console.warn("WARNING: Wide hashrate variance detected in chart data. Possible unit inconsistency."); - console.warn(`Min: ${min.toFixed(2)} TH/s, Max: ${max.toFixed(2)} TH/s, Mean: ${mean.toFixed(2)} TH/s`); + console.error(`Error processing hashrate at index ${i}:`, err); + validatedData[i] = 0; // Use 0 as a safe fallback } } - // Update chart dataset label to indicate which average we're displaying - chart.data.datasets[0].label = useHashrate3hr ? - 'Hashrate Trend (3HR AVG)' : 'Hashrate Trend (60SEC AVG)'; + // Assign the processed data to chart + chart.data.datasets[0].data = validatedData; + chart.originalData = validHistoryData; // Store for tooltip reference - // Calculate appropriate y-axis range with safeguards for outliers and ensure 24hr avg line is visible - const values = chart.data.datasets[0].data.filter(v => !isNaN(v) && v !== null && v > 0); - if (values.length > 0) { - const max = Math.max(...values); - const min = Math.min(...values) || 0; + // Update tooltip callback to display proper units + chart.options.plugins.tooltip.callbacks.label = function (context) { + const index = context.dataIndex; + const originalData = chart.originalData?.[index]; - // MODIFICATION: When in low hashrate mode, ensure the y-axis includes the 24hr average - if (useHashrate3hr && normalizedAvg > 0) { - // Ensure the 24-hour average is visible on the chart - const yMin = Math.min(min * 0.8, normalizedAvg * 0.5); - const yMax = Math.max(max * 1.2, normalizedAvg * 1.5); - - chart.options.scales.y.min = yMin; - chart.options.scales.y.max = yMax; - console.log(`Low hashrate mode: Adjusting y-axis to include 24hr avg: [${yMin.toFixed(2)}, ${yMax.toFixed(2)}]`); - } else { - // Normal mode scaling - chart.options.scales.y.min = min * 0.8; - chart.options.scales.y.max = max * 1.2; - } - - // Set appropriate step size based on range - const range = chart.options.scales.y.max - chart.options.scales.y.min; - - // Calculate an appropriate stepSize that won't exceed Chart.js tick limits - // Aim for approximately 5-10 ticks (Chart.js recommends ~5-6 ticks for readability) - let stepSize; - const targetTicks = 6; // Target number of ticks we want to display - - if (range <= 0.1) { - // For very small ranges (< 0.1 TH/s) - stepSize = 0.01; - } else if (range <= 1) { - // For small ranges (0.1 - 1 TH/s) - stepSize = 0.1; - } else if (range <= 10) { - // For medium ranges (1 - 10 TH/s) - stepSize = 1; - } else if (range <= 50) { - stepSize = 5; - } else if (range <= 100) { - stepSize = 10; - } else if (range <= 500) { - stepSize = 50; - } else if (range <= 1000) { - stepSize = 100; - } else if (range <= 5000) { - stepSize = 500; - } else { - // For very large ranges, calculate stepSize that will produce ~targetTicks ticks - stepSize = Math.ceil(range / targetTicks); - - // Round to a nice number (nearest power of 10 multiple) - const magnitude = Math.pow(10, Math.floor(Math.log10(stepSize))); - stepSize = Math.ceil(stepSize / magnitude) * magnitude; - - // Safety check for extremely large ranges - if (range / stepSize > 1000) { - console.warn(`Y-axis range (${range.toFixed(2)}) requires extremely large stepSize. - Adjusting to limit ticks to 1000.`); - stepSize = Math.ceil(range / 1000); + if (originalData) { + if (originalData.storageValue !== undefined && originalData.storageUnit) { + // Use the optimized storage value/unit if available + return `HASHRATE: ${originalData.storageValue} ${originalData.storageUnit.toUpperCase()}`; + } + else if (originalData.originalValue !== undefined && originalData.originalUnit) { + // Fall back to original values + return `HASHRATE: ${originalData.originalValue} ${originalData.originalUnit.toUpperCase()}`; } } + // Last resort fallback + return 'HASHRATE: ' + formatHashrateForDisplay(context.raw).toUpperCase(); + }; + + // Calculate statistics for anomaly detection with optimization + if (hashrateValues.length > 1) { + // Calculate mean, min, max in a single pass for efficiency + let sum = 0, min = Infinity, max = -Infinity; + + for (let i = 0; i < hashrateValues.length; i++) { + const val = hashrateValues[i]; + sum += val; + if (val < min) min = val; + if (val > max) max = val; + } + + const mean = sum / hashrateValues.length; + + // Enhanced outlier detection + const standardDeviation = calculateStandardDeviation(hashrateValues, mean); + const outlierThreshold = 3; // Standard deviations + + // Check for outliers using both range and statistical methods + const hasOutliersByRange = (max > mean * 10 || min < mean / 10); + const hasOutliersByStats = hashrateValues.some(v => Math.abs(v - mean) > outlierThreshold * standardDeviation); + + // Log more helpful diagnostics for outliers + if (hasOutliersByRange || hasOutliersByStats) { + console.warn("WARNING: Hashrate variance detected in chart data. Possible unit inconsistency."); + console.warn(`Stats: Min: ${min.toFixed(2)}, Max: ${max.toFixed(2)}, Mean: ${mean.toFixed(2)}, StdDev: ${standardDeviation.toFixed(2)} TH/s`); + + // Give more specific guidance + if (max > 1000 && min < 10) { + console.warn("ADVICE: Data contains mixed units (likely TH/s and PH/s). Check API response consistency."); + } + } + + // Log performance timing for large datasets + if (hashrateValues.length > 100) { + const perfEnd = performance.now(); + console.log(`Processed ${hashrateValues.length} hashrate points in ${(perfEnd - perfStart).toFixed(1)}ms`); + } + } + + // Find filtered valid values for y-axis limits (more efficient than creating a new array) + let activeValues = 0, yMin = Infinity, yMax = -Infinity; + for (let i = 0; i < validatedData.length; i++) { + const v = validatedData[i]; + if (!isNaN(v) && v !== null && v > 0) { + activeValues++; + if (v < yMin) yMin = v; + if (v > yMax) yMax = v; + } + } + + if (activeValues > 0) { + // Optimized y-axis range calculation with padding + const padding = useHashrate3hr ? 0.5 : 0.2; // More padding in low hashrate mode + + // When in low hashrate mode, ensure the y-axis includes the 24hr average + if (useHashrate3hr && normalizedAvg > 0) { + // Ensure the 24-hour average is visible with adequate padding + const minPadding = normalizedAvg * padding; + const maxPadding = normalizedAvg * padding; + + chart.options.scales.y.min = Math.min(yMin * (1 - padding), normalizedAvg - minPadding); + chart.options.scales.y.max = Math.max(yMax * (1 + padding), normalizedAvg + maxPadding); + + console.log(`Low hashrate mode: Y-axis range [${chart.options.scales.y.min.toFixed(2)}, ${chart.options.scales.y.max.toFixed(2)}] TH/s`); + } else { + // Normal mode scaling with smarter padding (less padding for large ranges) + const dynamicPadding = Math.min(0.2, 10 / yMax); // Reduce padding as max increases + chart.options.scales.y.min = Math.max(0, yMin * (1 - dynamicPadding)); // Never go below zero + chart.options.scales.y.max = yMax * (1 + dynamicPadding); + } + + // Set appropriate step size based on range - improved algorithm + const range = chart.options.scales.y.max - chart.options.scales.y.min; + + // Dynamic target ticks based on chart height for better readability + const chartHeight = chart.height || 300; + const targetTicks = Math.max(4, Math.min(8, Math.floor(chartHeight / 50))); + + // Calculate ideal step size + const rawStepSize = range / targetTicks; + + // Find a "nice" step size that's close to the raw step size + const stepSize = calculateNiceStepSize(rawStepSize); + // Set the calculated stepSize chart.options.scales.y.ticks.stepSize = stepSize; - // Log the chosen stepSize for debugging - console.log(`Y-axis range: ${range.toFixed(2)}, using stepSize: ${stepSize}`); + // Log the chosen stepSize + console.log(`Y-axis range: ${range.toFixed(2)} TH/s, using stepSize: ${stepSize} (target ticks: ${targetTicks})`); } } else { - console.warn("No valid history data items available"); + console.warn("No history data items available"); + useSingleDataPoint(); } } catch (historyError) { console.error("Error processing hashrate history data:", historyError); @@ -1494,6 +1662,48 @@ function updateChartWithNormalizedData(chart, data) { useSingleDataPoint(); } + /** + * Calculate standard deviation of an array of values + * @param {Array} values - Array of numeric values + * @param {number} mean - Pre-calculated mean (optional) + * @returns {number} - Standard deviation + */ + function calculateStandardDeviation(values, precalculatedMean = null) { + if (!values || values.length <= 1) return 0; + + // Calculate mean if not provided + const mean = precalculatedMean !== null ? precalculatedMean : + values.reduce((sum, val) => sum + val, 0) / values.length; + + // Calculate sum of squared differences + const squaredDiffSum = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0); + + // Calculate standard deviation + return Math.sqrt(squaredDiffSum / values.length); + } + + /** + * Calculate a "nice" step size close to the raw step size + * @param {number} rawStepSize - The mathematically ideal step size + * @returns {number} - A rounded, human-friendly step size + */ + function calculateNiceStepSize(rawStepSize) { + if (rawStepSize <= 0) return 1; // Safety check + + // Get order of magnitude + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStepSize))); + const normalized = rawStepSize / magnitude; + + // Choose a nice step size + let niceStepSize; + if (normalized < 1.5) niceStepSize = 1; + else if (normalized < 3) niceStepSize = 2; + else if (normalized < 7) niceStepSize = 5; + else niceStepSize = 10; + + return niceStepSize * magnitude; + } + // Handle single datapoint display when no history is available function useSingleDataPoint() { try { diff --git a/static/js/workers.js b/static/js/workers.js index 246e96c..8a7bf12 100644 --- a/static/js/workers.js +++ b/static/js/workers.js @@ -227,84 +227,131 @@ function hideLoader() { $("#loader").hide(); } -// Fetch worker data from API with pagination, limiting to 10 pages +// Fetch worker data with better pagination and progressive loading function fetchWorkerData(forceRefresh = false) { console.log("Fetching worker data..."); lastManualRefreshTime = Date.now(); $('#worker-grid').addClass('loading-fade'); showLoader(); - const maxPages = 10; + // For large datasets, better to use streaming or chunked approach + // First fetch just the summary data and first page + const initialRequest = $.ajax({ + url: `/api/workers?summary=true&page=1${forceRefresh ? '&force=true' : ''}`, + method: 'GET', + dataType: 'json', + timeout: 15000 + }); + + initialRequest.then(summaryData => { + // Store summary stats immediately to update UI + workerData = summaryData || {}; + workerData.workers = workerData.workers || []; + + // Update summary stats while we load more workers + updateSummaryStats(); + updateMiniChart(); + updateLastUpdated(); + + const totalPages = Math.ceil((workerData.workers_total || 0) / 100); // Assuming 100 workers per page + const pagesToFetch = Math.min(totalPages, 20); // Limit to 20 pages max + + if (pagesToFetch <= 1) { + // We already have all the data from the first request + finishWorkerLoad(); + return; + } + + // Progress indicator + const progressBar = $('
Loading workers: 1/' + pagesToFetch + '
'); + $('#worker-grid').html(progressBar); + + // Load remaining pages in batches to avoid overwhelming the browser + loadWorkerPages(2, pagesToFetch, progressBar); + }).catch(error => { + console.error("Error fetching initial worker data:", error); + $('#worker-grid').html('
Error loading workers.
'); + $('.retry-btn').on('click', () => fetchWorkerData(true)); + hideLoader(); + $('#worker-grid').removeClass('loading-fade'); + }); +} + +// Load worker pages in batches +function loadWorkerPages(startPage, totalPages, progressBar) { + const BATCH_SIZE = 3; // Number of pages to load in parallel + const endPage = Math.min(startPage + BATCH_SIZE - 1, totalPages); 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 - })); + for (let page = startPage; page <= endPage; page++) { + requests.push( + $.ajax({ + url: `/api/workers?page=${page}`, + 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}`); + // Process each page + pages.forEach(pageData => { + if (pageData && pageData.workers && pageData.workers.length > 0) { + // Append new workers to our list efficiently + workerData.workers = workerData.workers.concat(pageData.workers); } }); - // 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) - ); + // Update progress + const progress = Math.min(endPage / totalPages * 100, 100); + progressBar.find('.progress-bar').css('width', progress + '%'); + progressBar.find('.progress-text').text(`Loading workers: ${endPage}/${totalPages}`); - workerData = aggregatedData || {}; - workerData.workers = uniqueWorkers; - - if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.notifyRefresh) { - BitcoinMinuteRefresh.notifyRefresh(); + if (endPage < totalPages) { + // Continue with next batch + setTimeout(() => loadWorkerPages(endPage + 1, totalPages, progressBar), 100); + } else { + // All pages loaded + finishWorkerLoad(); } - - 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(); + console.error(`Error fetching worker pages ${startPage}-${endPage}:`, error); + // Continue with what we have so far + finishWorkerLoad(); }); } -// Refresh worker data every 60 seconds -setInterval(function () { - console.log("Refreshing worker data at " + new Date().toLocaleTimeString()); - fetchWorkerData(); -}, 60000); +// Finish loading process with optimized rendering +function finishWorkerLoad() { + // Deduplicate workers more efficiently with a Map + const uniqueWorkersMap = new Map(); + workerData.workers.forEach(worker => { + if (worker.name) { + uniqueWorkersMap.set(worker.name, worker); + } + }); + workerData.workers = Array.from(uniqueWorkersMap.values()); -// Update the worker grid with data -function updateWorkerGrid() { - console.log("Updating worker grid..."); + // Notify BitcoinMinuteRefresh + if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.notifyRefresh) { + BitcoinMinuteRefresh.notifyRefresh(); + } + // Efficiently render workers with virtualized list approach + renderWorkersList(); + + $('#retry-button').hide(); + connectionRetryCount = 0; + console.log(`Worker data updated successfully: ${workerData.workers.length} workers`); + $('#worker-grid').removeClass('loading-fade'); + hideLoader(); +} + +// Virtualized list rendering for large datasets +function renderWorkersList() { if (!workerData || !workerData.workers) { console.error("No worker data available"); return; @@ -325,14 +372,114 @@ function updateWorkerGrid() { return; } - filteredWorkers.forEach(worker => { - const card = createWorkerCard(worker); - workerGrid.append(card); - }); + // Performance optimization for large lists + if (filteredWorkers.length > 200) { + // For very large lists, render in batches + const workerBatch = 100; + const totalBatches = Math.ceil(filteredWorkers.length / workerBatch); + + console.log(`Rendering ${filteredWorkers.length} workers in ${totalBatches} batches`); + + // Render first batch immediately + renderWorkerBatch(filteredWorkers.slice(0, workerBatch), workerGrid); + + // Render remaining batches with setTimeout to avoid UI freezing + for (let i = 1; i < totalBatches; i++) { + const start = i * workerBatch; + const end = Math.min(start + workerBatch, filteredWorkers.length); + + setTimeout(() => { + renderWorkerBatch(filteredWorkers.slice(start, end), workerGrid); + + // Update "loading more" message with progress + const loadingMsg = workerGrid.find('.loading-more-workers'); + if (loadingMsg.length) { + if (i === totalBatches - 1) { + loadingMsg.remove(); + } else { + loadingMsg.text(`Loading more workers... ${Math.min((i + 1) * workerBatch, filteredWorkers.length)}/${filteredWorkers.length}`); + } + } + }, i * 50); // 50ms delay between batches + } + + // Add "loading more" indicator at the bottom + if (totalBatches > 1) { + workerGrid.append(`
Loading more workers... ${workerBatch}/${filteredWorkers.length}
`); + } + } else { + // For smaller lists, render all at once + renderWorkerBatch(filteredWorkers, workerGrid); + } } -// Create worker card element -function createWorkerCard(worker) { +// Render a batch of workers efficiently +function renderWorkerBatch(workers, container) { + // Create a document fragment for better performance + const fragment = document.createDocumentFragment(); + + // Calculate max hashrate once for this batch + const maxHashrate = calculateMaxHashrate(); + + workers.forEach(worker => { + const card = createWorkerCard(worker, maxHashrate); + fragment.appendChild(card[0]); + }); + + container.append(fragment); +} + +// Calculate max hashrate once to avoid recalculating for each worker +function calculateMaxHashrate() { + let maxHashrate = 125; // Default fallback + + // First check if global hashrate data is available + if (workerData && workerData.hashrate_24hr) { + const globalHashrate = normalizeHashrate(workerData.hashrate_24hr, workerData.hashrate_24hr_unit || 'th/s'); + if (globalHashrate > 0) { + return Math.max(5, globalHashrate * 1.2); + } + } + + // If no global data, calculate from workers efficiently + if (workerData && workerData.workers && workerData.workers.length > 0) { + const onlineWorkers = workerData.workers.filter(w => w.status === 'online'); + + if (onlineWorkers.length > 0) { + let maxWorkerHashrate = 0; + + // Find maximum hashrate without logging every worker + onlineWorkers.forEach(w => { + const hashrateValue = w.hashrate_24hr || w.hashrate_3hr || 0; + const hashrateUnit = w.hashrate_24hr ? + (w.hashrate_24hr_unit || 'th/s') : + (w.hashrate_3hr_unit || 'th/s'); + const normalizedRate = normalizeHashrate(hashrateValue, hashrateUnit); + + if (normalizedRate > maxWorkerHashrate) { + maxWorkerHashrate = normalizedRate; + } + }); + + if (maxWorkerHashrate > 0) { + return Math.max(5, maxWorkerHashrate * 1.2); + } + } + } + + // Fallback to total hashrate + if (workerData && workerData.total_hashrate) { + const totalHashrate = normalizeHashrate(workerData.total_hashrate, workerData.hashrate_unit || 'th/s'); + if (totalHashrate > 0) { + return Math.max(5, totalHashrate * 1.2); + } + } + + return maxHashrate; +} + +// Optimized worker card creation (removed debug logging) +function createWorkerCard(worker, maxHashrate) { const card = $('
'); card.addClass(worker.status === 'online' ? 'worker-card-online' : 'worker-card-offline'); @@ -340,7 +487,7 @@ function createWorkerCard(worker) { card.append(`
${worker.name}
`); card.append(`
${worker.status.toUpperCase()}
`); - const maxHashrate = 125; // TH/s - adjust based on your fleet + // Use 3hr hashrate for display as in original code 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'); @@ -358,15 +505,8 @@ function createWorkerCard(worker) { // Format the last share using the proper method for timezone conversion let formattedLastShare = 'N/A'; if (worker.last_share && typeof worker.last_share === 'string') { - // This is a more reliable method for timezone conversion try { - // The worker.last_share is likely in format "YYYY-MM-DD HH:MM" - // We need to consider it as UTC and convert to the configured timezone - - // Create a proper date object, ensuring UTC interpretation - const dateWithoutTZ = new Date(worker.last_share + 'Z'); // Adding Z to treat as UTC - - // Format it according to the configured timezone + const dateWithoutTZ = new Date(worker.last_share + 'Z'); formattedLastShare = dateWithoutTZ.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', @@ -374,8 +514,7 @@ function createWorkerCard(worker) { timeZone: window.dashboardTimezone || 'America/Los_Angeles' }); } catch (e) { - console.error("Error formatting last share time:", e, worker.last_share); - formattedLastShare = worker.last_share; // Fallback to original value + formattedLastShare = worker.last_share; } } diff --git a/templates/base.html b/templates/base.html index cbb2053..f2f4bff 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}BTC-OS DASHBOARD {% endblock %} + {% block title %}BTC-OS Dashboard{% endblock %} @@ -116,6 +116,7 @@ diff --git a/templates/blocks.html b/templates/blocks.html index dd25832..c9343d9 100644 --- a/templates/blocks.html +++ b/templates/blocks.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}BLOCKS - BTC-OS MINING DASHBOARD {% endblock %} +{% block title %}BLOCKS - BTC-OS Dashboard {% endblock %} {% block css %} diff --git a/templates/dashboard.html b/templates/dashboard.html index ad20ff0..65a369b 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}BTC-OS Mining Dashboard {% endblock %} +{% block title %}BTC-OS Dashboard {% endblock %} {% block css %} diff --git a/templates/earnings.html b/templates/earnings.html new file mode 100644 index 0000000..20ab6e8 --- /dev/null +++ b/templates/earnings.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} + +{% block title %}EARNINGS - BTC-OS Dashboard {% endblock %} + +{% block css %} + +{% endblock %} + +{% block header %}EARNINGS MONITOR{% endblock %} + +{% block earnings_active %}active{% endblock %} + +{% block content %} +
+ + +
+ Currency: {{ user_currency }} + Timezone: {{ user_timezone }} +
+ + +
+
+

Unpaid Earnings

+
+ {{ earnings.unpaid_earnings_sats|default(0)|int|commafy }} + sats +
+
+ {{ "%.8f"|format(earnings.unpaid_earnings|default(0)|float) }} BTC +
+
{{ earnings.est_time_to_payout|default('Unknown') }}
+
+ +
+

Total Paid

+
+ {{ earnings.total_paid_sats|default(0)|int|commafy }} + sats +
+
+ {{ "%.8f"|format(earnings.total_paid_btc|default(0)|float) }} BTC +
+
+ {{ currency_symbols[user_currency] }}{{ "%.2f"|format(earnings.total_paid_fiat|default(0)|float)|commafy }} +
+
+ +
+

Total Payments

+
+ {{ earnings.total_payments|default(0) }} +
+
+ Latest: {{ earnings.payments[0].date|format_datetime if earnings.payments else 'None' }} +
+
+
+ + +
+

Monthly Summary

+
+ + + + + + + + + + + + {% for month in earnings.monthly_summaries %} + + + + + + + + {% else %} + + + + {% endfor %} + +
MonthPaymentsBTCSats{{ user_currency }}
{{ month.month_name }}{{ month.payments|length }}{{ "%.8f"|format(month.total_btc|float) }}{{ month.total_sats|int|commafy }}{{ currency_symbols[user_currency] }}{{ "%.2f"|format(month.total_fiat|float) }}
No payment data available
+
+
+ + +
+

Payment History

+
+ + + + + + + + + + + + {% for payment in earnings.payments %} + + + + + + + + {% else %} + + + + {% endfor %} + +
DateAmount (BTC)Amount (sats)Transaction IDStatus
{{ payment.date|format_datetime }}{{ "%.8f"|format(payment.amount_btc|float) }}{{ payment.amount_sats|int|commafy }} + {% if payment.txid %} + + {{ payment.txid[:8] }}...{{ payment.txid[-8:] }} + + {% elif payment.truncated_txid %} + {{ payment.truncated_txid }} + {% else %} + N/A + {% endif %} + + {{ payment.status }} +
No payment history available
+
+
+
+ + +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/templates/error.html b/templates/error.html index 812e0cd..da47ee3 100644 --- a/templates/error.html +++ b/templates/error.html @@ -3,7 +3,7 @@ - Error - Mining Dashboard + Error - BTC-OS Dashboard diff --git a/templates/notifications.html b/templates/notifications.html index 3f43535..1e64c32 100644 --- a/templates/notifications.html +++ b/templates/notifications.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}NOTIFICATIONS - BTC-OS MINING DASHBOARD {% endblock %} +{% block title %}NOTIFICATIONS - BTC-OS Dashboard{% endblock %} {% block css %} diff --git a/templates/workers.html b/templates/workers.html index 101581a..974bee5 100644 --- a/templates/workers.html +++ b/templates/workers.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}WORKERS - BTC-OS MINING DASHBOARD {% endblock %} +{% block title %}WORKERS - BTC-OS Dashboard{% endblock %} {% block css %} diff --git a/worker_service.py b/worker_service.py index 2e1e339..2e37719 100644 --- a/worker_service.py +++ b/worker_service.py @@ -54,33 +54,33 @@ class WorkerService: def get_workers_data(self, cached_metrics, force_refresh=False): """ - Get worker data with caching for better performance. - + Get worker data with caching for better performance and use consistent hashrate values. + Args: cached_metrics (dict): Cached metrics from the dashboard force_refresh (bool): Whether to force a refresh of cached data Returns: - dict: Worker data + dict: Worker data with standardized hashrates """ 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 @@ -89,22 +89,22 @@ class WorkerService: 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") @@ -112,93 +112,97 @@ class WorkerService: 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) - + + # IMPORTANT: Use the dashboard's hashrate values for consistency + # This ensures the workers page shows the same hashrate as the main dashboard + if dashboard_metrics.get("hashrate_3hr") is not None: + worker_data["total_hashrate"] = dashboard_metrics.get("hashrate_3hr") + worker_data["hashrate_unit"] = dashboard_metrics.get("hashrate_3hr_unit", "TH/s") + logging.info(f"Synced total hashrate from dashboard: {worker_data['total_hashrate']} {worker_data['hashrate_unit']}") + # 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")