Compare commits

...

7 Commits

Author SHA1 Message Date
7ef24db7e2
new path 2025-04-29 13:15:58 +02:00
DJObleezy
2f0da50d93 Update footer version to v0.9.5
Updated the version number in the footer from "v0.9.4 - Public Beta" to "v0.9.5 - Public Beta".
2025-04-28 20:02:44 -07:00
DJObleezy
034aec6d12 Enhance animations and refactor filtering logic
- Updated `dashboard.css` with new animations for DATUM text, including a rainbow glitch effect and pulse glow.
- Added margin-top to `.stats-grid` in `earnings.css` for better layout.
- Modified `.search-box` focus styles in `workers.css` to use primary color variable.
- Refactored filtering functionality in `workers.js` to simplify logic and improve search capabilities.
- Reintroduced user settings info section in `earnings.html`.
- Removed 'asic' and 'bitaxe' filter buttons in `workers.html` for a cleaner interface.
2025-04-28 20:00:00 -07:00
DJObleezy
bdb757c1db Update footer version to v0.9.4
Updated the version number in the footer from "v0.9.3 - Public Beta" to "v0.9.4 - Public Beta".
2025-04-28 13:51:50 -07:00
DJObleezy
bf53b9159d Enhance earnings.css for theme support and styling
Updated earnings.css to improve theme-switching capabilities with CSS variables for color management. Introduced two themes: bitcoin and deepsea, each with distinct styles. Refined layout components, added scanline effects to stat cards, and improved table responsiveness with a card-like layout for small screens. Introduced new utility classes for better data differentiation and added animations for a dynamic user experience. Overall, these changes enhance the visual appeal and maintainability of the dashboard.
2025-04-28 13:51:26 -07:00
DJObleezy
05301cc1ea Rename status-indicator to status-label in CSS and HTML
Updated the CSS class `.status-indicator` to `.status-label` in `earnings.css`, including style adjustments for padding and font size. Modified the corresponding HTML in `earnings.html` to reflect this change, ensuring consistency across the codebase.
2025-04-28 09:50:11 -07:00
DJObleezy
5a6331d032 Enhance earnings features and improve data handling
- Updated `App.py` to add a datetime formatting filter and enhance the earnings route with better error handling and currency conversion.
- Revised `README.md` to document new features, including earnings breakdown and API endpoints.
- Added default currency setting in `config.json` and a function to retrieve it in `config.py`.
- Improved `data_service.py` with a new method for fetching payment history and enhanced error handling.
- Updated `setup.py` to include new CSS and JS files for the earnings page.
- Enhanced styling in `common.css`, `dashboard.css`, and `earnings.css` for better responsiveness.
- Optimized `workers.js` for improved performance with a progressive loading approach.
- Updated multiple HTML files to reflect new dashboard structure and features.
- Enhanced `worker_service.py` for consistent hashrate values and improved logging.
2025-04-28 09:20:56 -07:00
21 changed files with 2410 additions and 303 deletions

194
App.py
View File

@ -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)

View File

@ -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

View File

@ -3,5 +3,6 @@
"power_usage": 0.0,
"wallet": "yourwallethere",
"timezone": "America/Los_Angeles",
"network_fee": 0.0
"network_fee": 0.0,
"currency": "USD"
}

View File

@ -7,7 +7,7 @@ import json
import logging
# Default configuration file path
CONFIG_FILE = "config.json"
CONFIG_FILE = "data/config.json"
def load_config():
"""
@ -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"

View File

@ -1,4 +1,4 @@
"""
"""
Data service module for fetching and processing mining data.
"""
import logging
@ -490,6 +490,291 @@ class MiningDashboardService:
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):
"""
Fetch Bitcoin network statistics with improved error handling and caching.

View File

@ -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():

View File

@ -285,6 +285,7 @@ h1 {
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;
}

View File

@ -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 {
@ -217,15 +213,283 @@
vertical-align: middle;
}
/* Rainbow Glitch animation for DATUM text */
@keyframes rainbow-shift {
0% {
color: #ff2a6d;
text-shadow: 2px 0 #05d9e8, -2px 0 #ff2a6d, 0 0 10px #ff2a6d;
}
10% {
color: #ff2a6d;
text-shadow: 2px 0 #05d9e8, -2px 0 #ff2a6d, 0 0 10px #ff2a6d;
}
20% {
color: #ff7f00;
text-shadow: -2px 0 #05d9e8, 2px 0 #ff7f00, 0 0 10px #ff7f00;
}
30% {
color: #d8ff00;
text-shadow: 2px 0 #05d9e8, -2px 0 #d8ff00, 0 0 10px #d8ff00;
}
40% {
color: #00ff80;
text-shadow: -2px 0 #ff2a6d, 2px 0 #00ff80, 0 0 10px #00ff80;
}
50% {
color: #05d9e8;
text-shadow: 2px 0 #ff2a6d, -2px 0 #05d9e8, 0 0 10px #05d9e8;
}
60% {
color: #0000ff;
text-shadow: -2px 0 #ff2a6d, 2px 0 #0000ff, 0 0 10px #0000ff;
}
70% {
color: #8000ff;
text-shadow: 2px 0 #05d9e8, -2px 0 #8000ff, 0 0 10px #8000ff;
}
80% {
color: #ff00ff;
text-shadow: -2px 0 #05d9e8, 2px 0 #ff00ff, 0 0 10px #ff00ff;
}
90% {
color: #ff007f;
text-shadow: 2px 0 #05d9e8, -2px 0 #ff007f, 0 0 10px #ff007f;
}
100% {
color: #ff2a6d;
text-shadow: -2px 0 #05d9e8, 2px 0 #ff2a6d, 0 0 10px #ff2a6d;
}
}
@keyframes glitch-anim {
0% {
transform: none;
opacity: 1;
}
7% {
transform: skew(-0.5deg, -0.9deg);
opacity: 0.75;
}
10% {
transform: none;
opacity: 1;
}
27% {
transform: none;
opacity: 1;
}
30% {
transform: skew(0.8deg, -0.1deg);
opacity: 0.75;
}
35% {
transform: none;
opacity: 1;
}
52% {
transform: none;
opacity: 1;
}
55% {
transform: skew(-1deg, 0.2deg);
opacity: 0.75;
}
50% {
transform: none;
opacity: 1;
}
72% {
transform: none;
opacity: 1;
}
75% {
transform: skew(0.4deg, 1deg);
opacity: 0.75;
}
80% {
transform: none;
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
@keyframes pulse-glow {
0% {
filter: brightness(1);
}
50% {
filter: brightness(1.3);
}
100% {
filter: brightness(1);
}
}
.datum-label {
color: cyan; /* cyan color */
position: relative;
color: cyan;
font-size: 0.95em;
font-weight: bold;
text-transform: uppercase;
margin-left: 4px;
background-color: rgba(0, 0, 0, 0.2);
padding: 2px 5px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 3px;
letter-spacing: 2px;
display: inline-block;
animation: rainbow-shift 8s infinite linear, glitch-anim 3s infinite, pulse-glow 2s infinite;
transform-origin: center;
}
/* Create a glitch effect with duplicated text */
.datum-label::before,
.datum-label::after {
content: "DATUM";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 3px;
padding: 2px 5px;
clip: rect(0, 0, 0, 0);
}
.datum-label::before {
left: -1px;
animation: glitch-frames 2s infinite linear alternate-reverse;
text-shadow: -2px 0 #ff2a6d;
clip: rect(44px, 450px, 56px, 0);
}
.datum-label::after {
left: 1px;
animation: glitch-frames 3s infinite linear alternate-reverse;
text-shadow: 2px 0 #05d9e8;
clip: rect(24px, 450px, 36px, 0);
}
@keyframes glitch-frames {
0% {
clip: rect(19px, 450px, 23px, 0);
}
5% {
clip: rect(36px, 450px, 16px, 0);
}
10% {
clip: rect(11px, 450px, 41px, 0);
}
15% {
clip: rect(22px, 450px, 33px, 0);
}
20% {
clip: rect(9px, 450px, 47px, 0);
}
25% {
clip: rect(31px, 450px, 21px, 0);
}
30% {
clip: rect(44px, 450px, 9px, 0);
}
35% {
clip: rect(17px, 450px, 38px, 0);
}
40% {
clip: rect(26px, 450px, 25px, 0);
}
45% {
clip: rect(12px, 450px, 43px, 0);
}
50% {
clip: rect(35px, 450px, 18px, 0);
}
55% {
clip: rect(8px, 450px, 49px, 0);
}
60% {
clip: rect(29px, 450px, 23px, 0);
}
65% {
clip: rect(42px, 450px, 11px, 0);
}
70% {
clip: rect(15px, 450px, 40px, 0);
}
75% {
clip: rect(24px, 450px, 27px, 0);
}
80% {
clip: rect(10px, 450px, 45px, 0);
}
85% {
clip: rect(33px, 450px, 20px, 0);
}
90% {
clip: rect(46px, 450px, 7px, 0);
}
95% {
clip: rect(13px, 450px, 42px, 0);
}
100% {
clip: rect(28px, 450px, 26px, 0);
}
}
/* Add some hover effects for interactivity */
.datum-label:hover {
animation-play-state: paused;
filter: brightness(1.5);
cursor: pointer;
transform: scale(1.1);
transition: transform 0.2s ease;
}
/* Pool luck indicators */

622
static/css/earnings.css Normal file
View File

@ -0,0 +1,622 @@
/* earnings.css - Clean theme-switching friendly version */
/* Color variables - Defined at root level for global access */
:root {
/* Shared colors for both themes */
--yellow-color: #ffd700;
--green-color: #32CD32;
--light-green-color: #90EE90;
--red-color: #ff5555;
--accent-color: #00dfff;
--accent-color-rgb: 0, 223, 255;
--bg-color: #000;
--card-header-bg: #000;
}
/* Theme-specific variables */
html.bitcoin-theme {
--primary-color: #f2a900;
--primary-color-rgb: 242, 169, 0;
--border-color: #333;
--primary-gradient-end: #bf7d00; /* Darker orange */
}
html.deepsea-theme {
--primary-color: #0088cc;
--primary-color-rgb: 0, 136, 204;
--border-color: #224;
--primary-gradient-end: #006699; /* Darker blue */
}
/* ===== LAYOUT COMPONENTS ===== */
/* Main section styling */
.earnings-section {
margin: 2rem 0;
}
/* Section headers */
.earnings-section h2 {
color: white;
padding: 0.5rem;
font-weight: bold;
font-family: var(--header-font);
text-transform: uppercase;
margin-bottom: 0.5rem;
}
/* Stats grid for summary cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
margin-top: 2rem;
}
/* ===== CARD STYLING ===== */
/* Stat card - Blue background with white text */
.stat-card {
color: white;
padding: 1rem;
border: 1px solid var(--primary-color);
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.3);
position: relative;
}
.stat-card h2 {
padding: 0.5rem;
color: white;
font-size: 1.2rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
font-family: var(--header-font);
}
/* Card content elements */
.stat-value {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-card .stat-value {
color: white;
}
.stat-unit {
font-size: 1rem;
color: var(--text-color);
margin-left: 0.25rem;
}
.stat-card .stat-unit,
.stat-card .stat-secondary {
color: rgba(255, 255, 255, 0.8);
}
.stat-secondary {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.stat-time {
margin-top: 0.5rem;
font-size: 0.9rem;
color: var(--accent-color, #00dfff);
}
.stat-card .stat-time {
color: var(--accent-color, #a0ffff);
}
/* ===== TABLE STYLING ===== */
.table-container {
overflow-x: auto;
margin: 1rem 0;
background-color: var(--bg-color);
padding: 0.5rem;
border: 1px solid var(--primary-color);
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.2);
position: relative;
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
/* Scanline effect for 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;
}
/* Empty state message */
.earnings-table tr td[colspan] {
text-align: center;
padding: 2rem;
color: var(--text-color);
font-style: italic;
}
/* ===== SPECIFIC DATA COLORS ===== */
/* Yellow color - BTC/sats values */
#unpaid-sats,
#total-paid-sats,
.earnings-table td:nth-child(4),
#unpaid-btc,
#total-paid-btc,
.earnings-table td:nth-child(3) {
color: var(--yellow-color);
}
/* Green color - Earnings/profits */
#total-paid-usd,
#total-paid-fiat,
.earnings-table td:nth-child(5) {
color: var(--green-color);
}
/* Red color - Fees/costs */
.earnings-fee,
.earnings-cost {
color: var(--red-color) !important;
}
/* Blue color - Dates */
.earnings-table td:nth-child(1) {
color: var(--accent-color);
}
/* ===== MISC ELEMENTS ===== */
/* Transaction links */
.tx-link {
color: var(--accent-color);
text-decoration: none;
letter-spacing: 1px;
}
.tx-link:hover {
text-decoration: underline;
text-shadow: 0 0 5px rgba(var(--accent-color-rgb), 0.7);
}
/* Status indicators */
.status-label {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
text-align: center;
}
.status-confirmed {
background-color: rgba(75, 181, 67, 0.15);
color: var(--green-color);
}
.status-pending {
background-color: rgba(247, 147, 26, 0.15);
color: var(--yellow-color);
}
.status-processing {
background-color: rgba(52, 152, 219, 0.15);
color: var(--accent-color);
}
/* User settings info */
.settings-info {
display: flex;
justify-content: flex-end;
font-size: 0.9em;
color: #888;
}
.setting-item {
margin-left: 20px;
}
.setting-item strong {
color: var(--accent-color);
}
/* Currency symbol */
.currency-symbol {
display: inline-block;
margin-right: 2px;
}
/* ===== SPECIFIC ELEMENT STYLING ===== */
/* Payment count */
#payment-count {
font-size: 1.5rem;
}
/* Latest payment timestamp */
#latest-payment {
color: var(--accent-color);
font-size: 0.9rem;
}
/* Pool luck indicators */
.very-lucky {
color: var(--green-color) !important;
font-weight: bold !important;
}
.lucky {
color: var(--light-green-color) !important;
}
.normal-luck {
color: var(--yellow-color) !important;
}
.unlucky {
color: var(--red-color) !important;
}
/* ===== ANIMATIONS ===== */
/* Bounce animations */
@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;
}
/* Pulse animation for new payments */
@keyframes pulse-highlight {
0% {
background-color: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
50% {
background-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
100% {
background-color: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
}
.new-payment {
animation: pulse-highlight 2s infinite;
}
/* ===== FUTURE CHART STYLING ===== */
.chart-container {
background-color: var(--bg-color);
padding: 0.5rem;
margin-bottom: 1rem;
height: 230px;
border: 1px solid var(--primary-color);
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 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;
}
/* ===== UTILITY CLASSES ===== */
.arrow {
display: inline-block;
font-weight: bold;
margin-left: 0.5rem;
}
/* Metric value styling */
.metric-value {
color: var(--text-color);
font-weight: bold;
}
/* Card body general styling */
.card-body strong {
color: var(--primary-color);
margin-right: 0.25rem;
}
.card-body p {
margin: 0.25rem 0;
line-height: 1.2;
}
/* ===== RESPONSIVE STYLING ===== */
/* Tablets and smaller desktops */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.earnings-table th,
.earnings-table td {
padding: 0.5rem;
font-size: 0.85rem;
}
.status-label {
font-size: 0.7rem;
padding: 0.15rem 0.3rem;
}
.stat-value {
font-size: 1.5rem;
}
.tx-link {
font-size: 0.8rem;
}
.settings-info {
flex-direction: column;
align-items: flex-start;
}
.setting-item {
margin-left: 0;
margin-bottom: 5px;
}
}
/* Mobile phones */
@media (max-width: 480px) {
/* Card-like table layout for small screens */
.earnings-table thead {
display: none;
}
.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);
}
.earnings-table td:last-child {
border-bottom: none;
}
/* Responsive labels for table cells */
.earnings-table td::before {
content: attr(data-label);
font-weight: bold;
width: 40%;
color: var(--primary-color);
margin-right: 5%;
}
/* Table-specific cell labels */
#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 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: ";
}
/* Fix truncated transaction links */
.tx-link {
word-break: break-all;
font-size: 0.75rem;
}
}
/* Add scanline effect to the main dashboard container */
.dashboard-container {
position: relative;
z-index: 0;
}
/* Scanline effect for the entire dashboard container */
.dashboard-container::before {
content: '';
position: fixed;
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;
}
/* Update stat card with scanline effect */
.stat-card {
color: white;
padding: 1rem;
border: 1px solid var(--primary-color);
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.3);
position: relative;
background-color: var(--bg-color);
overflow: hidden;
}
/* Add scanline effect to stat cards */
.stat-card::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;
}
/* Ensure h2 headers in stat cards show above scanlines */
.stat-card h2 {
padding: 0.5rem;
color: white;
font-size: 1.2rem;
margin: -1rem -1rem 0.5rem -1rem;
text-transform: uppercase;
font-weight: bold;
font-family: var(--header-font);
position: relative;
z-index: 1;
}
/* Ensure content in stat cards shows above scanlines */
.stat-card .stat-value,
.stat-card .stat-secondary,
.stat-card .stat-time,
.stat-card .stat-unit {
position: relative;
z-index: 2;
}
/* Add scanline overlay to section headers */
.earnings-section h2 {
position: relative;
}
.earnings-section h2::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;
}

View File

@ -21,7 +21,7 @@
.search-box:focus {
outline: none;
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
box-shadow: 0 0 8px var(--primary-color);
}
.filter-button {
@ -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 */

169
static/js/earnings.js Normal file
View File

@ -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 = `<span class="currency-symbol">${symbol}</span>${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'
});
}

View File

@ -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 => {
const timeStr = item.time;
try {
// Parse and format the time (existing code)...
let timeParts;
if (timeStr.length === 8 && timeStr.indexOf(':') !== -1) {
// Format: HH:MM:SS
timeParts = timeStr.split(':');
} else if (timeStr.length === 5 && timeStr.indexOf(':') !== -1) {
// Format: HH:MM
timeParts = timeStr.split(':');
timeParts.push('00'); // Add seconds
} else {
return timeStr; // Use as-is if format is unexpected
// 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`);
}
// 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));
if (validHistoryData.length === 0) {
console.warn("No valid history data points after filtering");
useSingleDataPoint();
return;
}
let formattedTime = timeDate.toLocaleTimeString('en-US', {
timeZone: dashboardTimezone || 'America/Los_Angeles',
// 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
});
// Remove the AM/PM part
formattedTime = formattedTime.replace(/\s[AP]M$/i, '');
return formattedTime;
// Format all time labels at once
const formattedLabels = validHistoryData.map(item => {
const timeStr = item.time;
try {
// Parse time efficiently
let hours = 0, minutes = 0, seconds = 0;
if (timeStr.length === 8 && timeStr.indexOf(':') !== -1) {
// Format: HH:MM:SS
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
const parts = timeStr.split(':');
hours = parseInt(parts[0], 10);
minutes = parseInt(parts[1], 10);
} else {
return timeStr; // Use original if format is unexpected
}
// 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
}
const timeDate = new Date(yearMonthDay.year, yearMonthDay.month, yearMonthDay.day,
hours, minutes, seconds);
// 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
const hashrateValues = [];
const validatedData = historyData.map((item, index) => {
try {
// 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;
}
chart.data.labels = formattedLabels;
// Validate the unit
const unit = item.unit || 'th/s';
// Process and normalize hashrate values with validation (optimize by avoiding multiple iterations)
const hashrateValues = [];
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);
// 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);
}
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`);
}
}
// 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)';
// 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;
// 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;
// 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);
}
}
} catch (err) {
console.error(`Error processing hashrate at index ${i}:`, err);
validatedData[i] = 0; // Use 0 as a safe fallback
}
}
// Set appropriate step size based on range
// Assign the processed data to chart
chart.data.datasets[0].data = validatedData;
chart.originalData = validHistoryData; // Store for tooltip reference
// Update tooltip callback to display proper units
chart.options.plugins.tooltip.callbacks.label = function (context) {
const index = context.dataIndex;
const originalData = chart.originalData?.[index];
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;
// 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
// 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)));
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);
// Calculate ideal step size
const rawStepSize = 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);
}
}
// 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<number>} 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 {

View File

@ -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;
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,
// 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
}));
}
// 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)
);
initialRequest.then(summaryData => {
// Store summary stats immediately to update UI
workerData = summaryData || {};
workerData.workers = workerData.workers || [];
workerData = aggregatedData || {};
workerData.workers = uniqueWorkers;
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.notifyRefresh) {
BitcoinMinuteRefresh.notifyRefresh();
}
updateWorkerGrid();
// Update summary stats while we load more workers
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(() => {
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 = $('<div class="worker-load-progress"><div class="progress-bar"></div><div class="progress-text">Loading workers: 1/' + pagesToFetch + '</div></div>');
$('#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('<div class="text-center p-5"><i class="fas fa-exclamation-circle"></i> Error loading workers. <button class="retry-btn">Retry</button></div>');
$('.retry-btn').on('click', () => fetchWorkerData(true));
hideLoader();
$('#worker-grid').removeClass('loading-fade');
});
}
// Refresh worker data every 60 seconds
setInterval(function () {
console.log("Refreshing worker data at " + new Date().toLocaleTimeString());
fetchWorkerData();
}, 60000);
// 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 = [];
// Update the worker grid with data
function updateWorkerGrid() {
console.log("Updating worker grid...");
for (let page = startPage; page <= endPage; page++) {
requests.push(
$.ajax({
url: `/api/workers?page=${page}`,
method: 'GET',
dataType: 'json',
timeout: 15000
})
);
}
Promise.all(requests)
.then(pages => {
// 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);
}
});
// 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}`);
if (endPage < totalPages) {
// Continue with next batch
setTimeout(() => loadWorkerPages(endPage + 1, totalPages, progressBar), 100);
} else {
// All pages loaded
finishWorkerLoad();
}
})
.catch(error => {
console.error(`Error fetching worker pages ${startPage}-${endPage}:`, error);
// Continue with what we have so far
finishWorkerLoad();
});
}
// 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());
// 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
}
// Create worker card element
function createWorkerCard(worker) {
// Add "loading more" indicator at the bottom
if (totalBatches > 1) {
workerGrid.append(`<div class="loading-more-workers">Loading more workers... ${workerBatch}/${filteredWorkers.length}</div>`);
}
} else {
// For smaller lists, render all at once
renderWorkerBatch(filteredWorkers, workerGrid);
}
}
// 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 = $('<div class="worker-card"></div>');
card.addClass(worker.status === 'online' ? 'worker-card-online' : 'worker-card-offline');
@ -340,7 +487,7 @@ function createWorkerCard(worker) {
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
// 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;
}
}
@ -395,33 +534,41 @@ function createWorkerCard(worker) {
return card;
}
// Filter worker data based on current filter state
// Modified filterWorkers function for better search functionality
// This will replace the existing filterWorkers function in workers.js
function filterWorkers() {
if (!workerData || !workerData.workers) return;
renderWorkersList();
}
// Update the workers grid when filters change
function updateWorkerGrid() {
renderWorkersList();
}
// Modified filterWorkersData function to only include 'all', 'online', and 'offline' filters
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();
// Modified to only handle 'all', 'online', and 'offline' filters
const matchesFilter = filterState.currentFilter === 'all' ||
(filterState.currentFilter === 'online' && isOnline) ||
(filterState.currentFilter === 'offline' && !isOnline) ||
(filterState.currentFilter === 'asic' && workerType === 'asic') ||
(filterState.currentFilter === 'bitaxe' && workerType === 'bitaxe');
(filterState.currentFilter === 'offline' && !isOnline);
const matchesSearch = filterState.searchTerm === '' || workerName.includes(filterState.searchTerm);
// Improved search matching to check name, model and type
const matchesSearch = filterState.searchTerm === '' ||
workerName.includes(filterState.searchTerm) ||
(worker.model && worker.model.toLowerCase().includes(filterState.searchTerm)) ||
(worker.type && worker.type.toLowerCase().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;

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BTC-OS DASHBOARD {% endblock %}</title>
<title>{% block title %}BTC-OS Dashboard{% endblock %}</title>
<!-- Common fonts -->
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
@ -116,6 +116,7 @@
<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="/earnings" class="nav-link {% block earnings_active %}{% endblock %}">EARNINGS</a>
<a href="/blocks" class="nav-link {% block blocks_active %}{% endblock %}">BLOCKS</a>
<a href="/notifications" class="nav-link {% block notifications_active %}{% endblock %}">
NOTIFICATIONS
@ -135,7 +136,7 @@
<!-- Footer -->
<footer class="footer text-center">
<p>Not affiliated with <a href="https://www.Ocean.xyz">Ocean.xyz</a></p>
<p>v0.9.2 - Public Beta</p>
<p>v0.9.5 - Public Beta</p>
</footer>
</div>

View File

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}BLOCKS - BTC-OS MINING DASHBOARD {% endblock %}
{% block title %}BLOCKS - BTC-OS Dashboard {% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/blocks.css">

View File

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}BTC-OS Mining Dashboard {% endblock %}
{% block title %}BTC-OS Dashboard {% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/dashboard.css">

149
templates/earnings.html Normal file
View File

@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}EARNINGS - BTC-OS Dashboard {% endblock %}
{% block css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/earnings.css') }}">
{% endblock %}
{% block header %}EARNINGS MONITOR{% endblock %}
{% block earnings_active %}active{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Summary Cards Section -->
<div class="stats-grid">
<div class="stat-card">
<h2>Unpaid Earnings</h2>
<div class="stat-value">
<span id="unpaid-sats">{{ earnings.unpaid_earnings_sats|default(0)|int|commafy }}</span>
<span class="stat-unit">sats</span>
</div>
<div class="stat-secondary">
<span id="unpaid-btc">{{ "%.8f"|format(earnings.unpaid_earnings|default(0)|float) }}</span> BTC
</div>
<div class="stat-time" id="est-time-payout">{{ earnings.est_time_to_payout|default('Unknown') }}</div>
</div>
<div class="stat-card">
<h2>Total Paid</h2>
<div class="stat-value">
<span id="total-paid-sats">{{ earnings.total_paid_sats|default(0)|int|commafy }}</span>
<span class="stat-unit">sats</span>
</div>
<div class="stat-secondary">
<span id="total-paid-btc">{{ "%.8f"|format(earnings.total_paid_btc|default(0)|float) }}</span> BTC
</div>
<div class="stat-secondary" id="total-paid-fiat">
<span id="total-paid-currency-symbol">{{ currency_symbols[user_currency] }}</span>{{ "%.2f"|format(earnings.total_paid_fiat|default(0)|float)|commafy }}
</div>
</div>
<div class="stat-card">
<h2>Total Payments</h2>
<div class="stat-value">
<span id="payment-count">{{ earnings.total_payments|default(0) }}</span>
</div>
<div class="stat-secondary" id="latest-payment">
Latest: {{ earnings.payments[0].date|format_datetime if earnings.payments else 'None' }}
</div>
</div>
</div>
<!-- Monthly Summaries Section -->
<div class="earnings-section">
<h2>Monthly Summary</h2>
<div class="table-container">
<table class="earnings-table">
<thead>
<tr>
<th>Month</th>
<th>Payments</th>
<th>BTC</th>
<th>Sats</th>
<th>{{ user_currency }}</th>
</tr>
</thead>
<tbody id="monthly-summary-table">
{% for month in earnings.monthly_summaries %}
<tr>
<td>{{ month.month_name }}</td>
<td>{{ month.payments|length }}</td>
<td>{{ "%.8f"|format(month.total_btc|float) }}</td>
<td>{{ month.total_sats|int|commafy }}</td>
<td><span class="currency-symbol">{{ currency_symbols[user_currency] }}</span>{{ "%.2f"|format(month.total_fiat|float) }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No payment data available</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Payments History Section -->
<div class="earnings-section">
<h2>Payment History</h2>
<div class="table-container">
<table class="earnings-table">
<thead>
<tr>
<th>Date</th>
<th>Amount (BTC)</th>
<th>Amount (sats)</th>
<th>Transaction ID</th>
<th>Status</th>
</tr>
</thead>
<tbody id="payment-history-table">
{% for payment in earnings.payments %}
<tr>
<td>{{ payment.date|format_datetime }}</td>
<td>{{ "%.8f"|format(payment.amount_btc|float) }}</td>
<td>{{ payment.amount_sats|int|commafy }}</td>
<td>
{% if payment.txid %}
<a href="https://mempool.guide/tx/{{ payment.txid }}" target="_blank" class="tx-link">
{{ payment.txid[:8] }}...{{ payment.txid[-8:] }}
</a>
{% elif payment.truncated_txid %}
<span class="tx-link truncated" title="Incomplete transaction ID">{{ payment.truncated_txid }}</span>
{% else %}
N/A
{% endif %}
</td>
<td>
<span class="status-label status-{{ payment.status }}">{{ payment.status }}</span>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No payment history available</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- User Settings Info -->
<div class="settings-info">
<span class="setting-item">Currency: <strong id="user-currency">{{ user_currency }}</strong></span>
<span class="setting-item">Timezone: <strong id="user-timezone">{{ user_timezone }}</strong></span>
</div>
</div>
<script>
// Pass configuration values to JavaScript
const userCurrency = "{{ user_currency }}";
const userTimezone = "{{ user_timezone }}";
const currencySymbol = "{{ currency_symbols[user_currency] }}";
</script>
{% endblock %}
{% block javascript %}
<script src="{{ url_for('static', filename='js/earnings.js') }}"></script>
{% endblock %}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Mining Dashboard</title>
<title>Error - BTC-OS 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">

View File

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}NOTIFICATIONS - BTC-OS MINING DASHBOARD {% endblock %}
{% block title %}NOTIFICATIONS - BTC-OS Dashboard{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/notifications.css">

View File

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}WORKERS - BTC-OS MINING DASHBOARD {% endblock %}
{% block title %}WORKERS - BTC-OS Dashboard{% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/workers.css">
@ -80,8 +80,6 @@
<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>

View File

@ -54,14 +54,14 @@ 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()
@ -188,6 +188,13 @@ class WorkerService:
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")
@ -196,9 +203,6 @@ class WorkerService:
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")