mirror of
https://github.com/Retropex/custom-ocean.xyz-dashboard.git
synced 2025-05-13 03:30:46 +02:00
Compare commits
No commits in common. "main" and "v0.2c" have entirely different histories.
42
LICENSE.md
42
LICENSE.md
@ -1,21 +1,21 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 DJObleezy
|
Copyright (c) 2025 DJObleezy
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
340
README.md
340
README.md
@ -1,253 +1,193 @@
|
|||||||
# DeepSea Dashboard
|
# Ocean.xyz Bitcoin Mining Dashboard
|
||||||
|
|
||||||
## A Retro Mining Monitoring Solution
|
A real-time dashboard application for monitoring Bitcoin mining operations using the Ocean.xyz Mining Pool.
|
||||||
|
|
||||||
This open-source dashboard provides real-time monitoring for Ocean.xyz pool miners, offering detailed insights on hashrate, profitability, worker status, and network metrics. Designed with a retro terminal aesthetic and focused on reliability, it helps miners maintain complete oversight of their operations.
|

|
||||||
|
|
||||||
---
|

|
||||||
## Gallery:
|
|
||||||
|
|
||||||

|

|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
## Overview
|
||||||
|
|
||||||
## Key Features
|
This application provides miners with a comprehensive view of their mining operations, including:
|
||||||
|
|
||||||
|
- Real-time hashrate monitoring
|
||||||
|
- BTC price tracking
|
||||||
|
- Profitability metrics
|
||||||
|
- Payout status
|
||||||
|
- Network statistics
|
||||||
|
- Worker fleet management
|
||||||
|
- Historical data visualization
|
||||||
|
|
||||||
### Real-Time Mining Metrics
|
Built with Flask and modern web technologies, the dashboard features a responsive design that works on both desktop and mobile devices, real-time data updates via Server-Sent Events (SSE), and persistent storage with Redis.
|
||||||
- **Live Hashrate Tracking**: Monitor 60-second, 10-minute, 3-hour, and 24-hour average hashrates
|
|
||||||
- **Profitability Analysis**: View daily and monthly earnings in both BTC and USD
|
|
||||||
- **Financial Calculations**: Automatically calculate revenue, power costs, and net profit
|
|
||||||
- **Network Statistics**: Track current Bitcoin price, difficulty, and network hashrate
|
|
||||||
- **Payout Monitoring**: View unpaid balance and estimated time to next payout
|
|
||||||
- **Pool Fee Analysis**: Monitor pool fee percentages with visual indicator when optimal rates (0.9-1.3%) are detected
|
|
||||||
|
|
||||||
### Worker Management
|
## Features
|
||||||
- **Fleet Overview**: Comprehensive view of all mining devices in one interface
|
|
||||||
- **Status Monitoring**: Real-time status indicators for online and offline devices
|
|
||||||
- **Performance Data**: Individual hashrate, temperature, and acceptance rate metrics
|
|
||||||
- **Filtering Options**: Sort and search by device type or operational status
|
|
||||||
|
|
||||||
### Bitcoin Block Explorer
|
- **Real-Time Monitoring**: Live updates of mining metrics with minimal delay
|
||||||
- **Recent Blocks**: View the latest blocks added to the blockchain
|
- **Hashrate Visualization**: Interactive charts showing hashrate trends
|
||||||
- **Block Details**: Examine transaction counts, fees, and mining pool information
|
- **Profitability Calculations**: Daily and monthly profit estimates in USD and BTC
|
||||||
- **Visual Indicators**: Track network difficulty and block discovery times
|
- **Network Stats**: Current Bitcoin difficulty, network hashrate, and block count
|
||||||
|
- **Payout Tracking**: Monitor unpaid earnings and estimated time to next payout
|
||||||
|
- **Workers Dashboard**: View and manage your mining fleet with status, hashrate, and earnings for each device
|
||||||
|
- **Worker Filtering**: Filter and search your miners by status (online/offline) or type (ASIC/FPGA)
|
||||||
|
- **High Performance**: Optimized for low-resource environments with data compression
|
||||||
|
- **Responsive Design**: Works on desktop, tablet, and mobile devices
|
||||||
|
- **Retro Boot Screen**: Bitcoin-themed boot sequence with system initialization display
|
||||||
|
|
||||||
### System Resilience
|
## File Structure
|
||||||
- **Connection Recovery**: Automatic reconnection after network interruptions
|
|
||||||
- **Backup Polling**: Fallback to traditional polling if real-time connection fails
|
|
||||||
- **Cross-Tab Synchronization**: Data consistency across multiple browser tabs
|
|
||||||
- **Server Health Monitoring**: Built-in watchdog processes ensure reliability
|
|
||||||
- **Error Handling**: Displays a user-friendly error page (`error.html`) for unexpected issues.
|
|
||||||
|
|
||||||
### Distinctive Design Elements
|
```
|
||||||
- **Retro Terminal Aesthetic**: Nostalgic interface with modern functionality
|
ocean-mining-dashboard/
|
||||||
- **Boot Sequence Animation**: Engaging initialization sequence on startup
|
├── App.py # Main Flask application with backend logic
|
||||||
- **System Monitor**: Floating status display with uptime and refresh information
|
├── Dockerfile # Docker container configuration
|
||||||
- **Responsive Interface**: Adapts to desktop and mobile devices
|
├── minify.py # HTML minification utility
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── static/
|
||||||
|
│ └── js/
|
||||||
|
│ ├── main.js # Frontend JavaScript for main dashboard functionality
|
||||||
|
│ └── workers.js # Frontend JavaScript for workers dashboard
|
||||||
|
├── templates/
|
||||||
|
│ ├── boot.html # Bitcoin-themed boot sequence page
|
||||||
|
│ ├── error.html # Error page template
|
||||||
|
│ ├── index.html # Main dashboard template
|
||||||
|
│ └── workers.html # Workers dashboard template
|
||||||
|
└── config.json # Configuration file (created on first run)
|
||||||
|
```
|
||||||
|
|
||||||
### DeepSea Theme
|
## Installation
|
||||||
- **Underwater Effects**: Light rays and digital noise create an immersive experience.
|
|
||||||
- **Retro Glitch Effects**: Subtle animations for a nostalgic feel.
|
|
||||||
- **Theme Toggle**: Switch between Bitcoin and DeepSea themes with a single click.
|
|
||||||
|
|
||||||
## Quick Start
|
### Prerequisites
|
||||||
|
|
||||||
### Installation
|
- Python 3.9 or higher
|
||||||
|
- Docker (optional, for containerized deployment)
|
||||||
|
- Redis (optional, for data persistence)
|
||||||
|
|
||||||
1. Clone the repository
|
### Option 1: Local Installation
|
||||||
```
|
|
||||||
git clone https://github.com/Djobleezy/DeepSea-Dashboard.git
|
1. Clone the repository:
|
||||||
cd DeepSea-Dashboard
|
```bash
|
||||||
|
git clone https://github.com/yourusername/ocean-mining-dashboard.git
|
||||||
|
cd ocean-mining-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Create a virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the setup script:
|
4. Run the application:
|
||||||
```
|
```bash
|
||||||
python setup.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Start the application:
|
|
||||||
```
|
|
||||||
python App.py
|
python App.py
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open your browser at `http://localhost:5000`
|
5. Access the dashboard at http://localhost:5000
|
||||||
|
|
||||||
For detailed deployment instructions with Redis persistence and Gunicorn configuration, see [deployment_steps.md](deployment_steps.md).
|
### Option 2: Docker Installation
|
||||||
|
|
||||||
## Using docker-compose (with Redis)
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
The `docker-compose.yml` file makes it easy to deploy the dashboard and its dependencies.
|
git clone https://github.com/yourusername/ocean-mining-dashboard.git
|
||||||
|
cd ocean-mining-dashboard
|
||||||
### Steps to Deploy
|
|
||||||
|
|
||||||
1. **Start the services**:
|
|
||||||
Run the following command in the project root:
|
|
||||||
```
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Access the dashboard**:
|
2. Build the Docker image:
|
||||||
Open your browser at `http://localhost:5000`.
|
```bash
|
||||||
|
docker build -t mining-dashboard .
|
||||||
3. **Stop the services**:
|
|
||||||
To stop the services, run:
|
|
||||||
```
|
```
|
||||||
docker-compose down
|
|
||||||
|
3. Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 5000:5000 --name mining-dashboard mining-dashboard
|
||||||
```
|
```
|
||||||
### Customization
|
|
||||||
|
|
||||||
You can modify the following environment variables in the `docker-compose.yml` file:
|
4. Optional: Run with Redis for data persistence:
|
||||||
- `WALLET`: Your Bitcoin wallet address.
|
```bash
|
||||||
- `POWER_COST`: Cost of power per kWh.
|
# First start a Redis container
|
||||||
- `POWER_USAGE`: Power usage in watts.
|
docker run -d --name redis redis
|
||||||
- `NETWORK_FEE`: Additional fees beyond pool fees (e.g., firmware fees).
|
|
||||||
- `TIMEZONE`: Local timezone for displaying time information.
|
# Then start the dashboard with Redis connection
|
||||||
|
docker run -d -p 5000:5000 --link redis --env REDIS_URL=redis://redis:6379 mining-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
Redis data is stored in a persistent volume (`redis_data`), and application logs are saved in the `./logs` directory.
|
5. Access the dashboard at http://localhost:5000
|
||||||
|
|
||||||
For more details, refer to the [docker-compose documentation](https://docs.docker.com/compose/).
|
## Workers Dashboard
|
||||||
|
|
||||||
## Dashboard Components
|
The Workers Dashboard provides detailed information about each mining device in your fleet:
|
||||||
|
|
||||||
### Main Dashboard
|
- **Fleet Summary**: Quick view of total hashrate, worker count, and online/offline status
|
||||||
|
- **Worker Cards**: Individual cards for each mining device showing:
|
||||||
|
- Current status (online/offline)
|
||||||
|
- Device type (ASIC/FPGA)
|
||||||
|
- Current hashrate with visual bar indicator
|
||||||
|
- Last share time
|
||||||
|
- Earnings
|
||||||
|
- Acceptance rate
|
||||||
|
- Temperature (for online devices)
|
||||||
|
- **Filtering Options**: Filter workers by status or device type
|
||||||
|
- **Search Functionality**: Quickly find specific workers by name
|
||||||
|
- **Real-time Updates**: Synchronized with the main dashboard for consistent data
|
||||||
|
|
||||||
- Interactive hashrate visualization with trend analysis
|
Access the Workers Dashboard through the navigation links at the top of the main dashboard or directly at `/workers`.
|
||||||
- Real-time profitability metrics with cost calculations
|
|
||||||
- Network statistics with difficulty and price tracking
|
|
||||||
- Payout information with estimation timing
|
|
||||||
- Visual indicators for metric changes
|
|
||||||
|
|
||||||
### Workers Dashboard
|
## Configuration
|
||||||
|
|
||||||
- Fleet summary with aggregate statistics
|
On first run, the application will create a `config.json` file with default settings. Edit this file to customize:
|
||||||
- Individual worker cards with detailed metrics
|
|
||||||
- Status indicators with color-coded alerts
|
|
||||||
- Search and filtering functionality
|
|
||||||
- Performance trend mini-charts
|
|
||||||
|
|
||||||
### Blocks Explorer
|
```json
|
||||||
|
{
|
||||||
- Recent block visualization with mining details
|
"power_cost": 0.12, // Cost per kWh in USD
|
||||||
- Transaction statistics and fee information
|
"power_usage": 3450, // Power consumption in watts
|
||||||
- Mining pool attribution
|
"wallet": "your-btc-wallet-address-here"
|
||||||
- Block details modal with comprehensive data
|
}
|
||||||
|
```
|
||||||
### System Monitor
|
|
||||||
|
|
||||||
- Floating interface providing system statistics
|
|
||||||
- Progress indicator for data refresh cycles
|
|
||||||
- System uptime display
|
|
||||||
- Real-time connection status
|
|
||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
The application is designed for efficient resource utilization:
|
The dashboard is designed to be lightweight:
|
||||||
- **Server**: Any system capable of running Python 3.9+
|
- Minimal CPU usage (single worker with threading)
|
||||||
- **Memory**: Minimal requirements (~100MB RAM)
|
- ~100-200MB RAM usage
|
||||||
- **Storage**: Less than 50MB for application files
|
- <50MB disk space
|
||||||
- **Database**: Optional Redis for persistent state
|
|
||||||
- **Compatible with**: Windows, macOS, and Linux
|
|
||||||
|
|
||||||
## Technical Architecture
|
|
||||||
|
|
||||||
Built with a modern stack for reliability and performance:
|
|
||||||
- **Backend**: Flask with Server-Sent Events for real-time updates
|
|
||||||
- **Frontend**: Vanilla JavaScript with Chart.js for visualization
|
|
||||||
- **Data Processing**: Concurrent API calls with smart caching
|
|
||||||
- **Resilience**: Automatic recovery mechanisms and state persistence
|
|
||||||
- **Configuration**: Environment variables and JSON-based settings
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
- `/api/metrics`: Provides real-time mining metrics.
|
|
||||||
- `/api/available_timezones`: Returns a list of supported timezones.
|
|
||||||
- `/api/config`: Fetches or updates the mining configuration.
|
|
||||||
- `/api/health`: Returns the health status of the application.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
The project follows a modular architecture with clear separation of concerns:
|
|
||||||
|
|
||||||
```
|
|
||||||
DeepSea-Dashboard/
|
|
||||||
│
|
|
||||||
├── App.py # Main application entry point
|
|
||||||
├── config.py # Configuration management
|
|
||||||
├── config.json # Configuration file
|
|
||||||
├── data_service.py # Service for fetching mining data
|
|
||||||
├── models.py # Data models
|
|
||||||
├── state_manager.py # Manager for persistent state
|
|
||||||
├── worker_service.py # Service for worker data management
|
|
||||||
├── notification_service.py # Service for notifications
|
|
||||||
├── minify.py # Script for minifying assets
|
|
||||||
├── setup.py # Setup script for organizing files
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── Dockerfile # Docker configuration
|
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
|
||||||
│
|
|
||||||
├── templates/ # HTML templates
|
|
||||||
│ ├── base.html # Base template with common elements
|
|
||||||
│ ├── boot.html # Boot sequence animation
|
|
||||||
│ ├── dashboard.html # Main dashboard template
|
|
||||||
│ ├── workers.html # Workers dashboard template
|
|
||||||
│ ├── blocks.html # Bitcoin blocks template
|
|
||||||
│ ├── notifications.html # Notifications template
|
|
||||||
│ └── error.html # Error page template
|
|
||||||
│
|
|
||||||
├── static/ # Static assets
|
|
||||||
│ ├── css/ # CSS files
|
|
||||||
│ │ ├── common.css # Shared styles across all pages
|
|
||||||
│ │ ├── dashboard.css # Main dashboard styles
|
|
||||||
│ │ ├── workers.css # Workers page styles
|
|
||||||
│ │ ├── boot.css # Boot sequence styles
|
|
||||||
│ │ ├── blocks.css # Blocks page styles
|
|
||||||
│ │ ├── notifications.css # Notifications page styles
|
|
||||||
│ │ ├── error.css # Error page styles
|
|
||||||
│ │ ├── retro-refresh.css # Floating refresh bar styles
|
|
||||||
│ │ └── theme-toggle.css # Theme toggle styles
|
|
||||||
│ │
|
|
||||||
│ └── js/ # JavaScript files
|
|
||||||
│ ├── main.js # Main dashboard functionality
|
|
||||||
│ ├── workers.js # Workers page functionality
|
|
||||||
│ ├── blocks.js # Blocks page functionality
|
|
||||||
│ ├── notifications.js # Notifications functionality
|
|
||||||
│ ├── block-animation.js # Block mining animation
|
|
||||||
│ ├── BitcoinProgressBar.js # System monitor functionality
|
|
||||||
│ └── theme.js # Theme toggle functionality
|
|
||||||
│
|
|
||||||
├── deployment_steps.md # Deployment guide
|
|
||||||
├── project_structure.md # Additional structure documentation
|
|
||||||
├── LICENSE.md # License information
|
|
||||||
└── logs/ # Application logs (generated at runtime)
|
|
||||||
```
|
|
||||||
|
|
||||||
For more detailed information on the architecture and component interactions, see [project_structure.md](project_structure.md).
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
For optimal performance:
|
If you encounter connection issues with the dashboard:
|
||||||
|
|
||||||
1. Ensure your wallet address is correctly configured
|
1. Check the "Health" endpoint at `/api/health` for system status
|
||||||
2. Check network connectivity for consistent updates
|
2. Use the "Force Refresh" button if data becomes stale
|
||||||
3. Use the system monitor to verify connection status
|
3. Inspect browser console logs for error messages
|
||||||
4. Access the health endpoint at `/api/health` for diagnostics
|
4. Check server logs with `docker logs mining-dashboard` if using Docker
|
||||||
5. For stale data issues, use the Force Refresh function
|
|
||||||
6. Use hotkey Shift+R to clear chart and Redis data (as needed, not required)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Available under the MIT License. This is an independent project not affiliated with Ocean.xyz.
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- Ocean.xyz mining pool for their service
|
- [Ocean.xyz](https://ocean.xyz) mining pool website
|
||||||
- mempool.guide
|
- [Flask](https://flask.palletsprojects.com/) web framework for the backend
|
||||||
- The open-source community for their contributions
|
- [Chart.js](https://www.chartjs.org/) for interactive data visualization
|
||||||
- Bitcoin protocol developers
|
- [Bootstrap](https://getbootstrap.com/) for responsive UI components
|
||||||
|
- [Redis](https://redis.io/) for data persistence and caching
|
||||||
|
- [BeautifulSoup4](https://www.crummy.com/software/BeautifulSoup/) for HTML parsing
|
||||||
|
- [Gunicorn](https://gunicorn.org/) for WSGI HTTP server
|
||||||
|
- [APScheduler](https://apscheduler.readthedocs.io/) for background task scheduling
|
||||||
|
- [jQuery](https://jquery.com/) for DOM manipulation and AJAX requests
|
||||||
|
- [Font Awesome](https://fontawesome.com/) for icons
|
||||||
|
- [chartjs-plugin-annotation](https://github.com/chartjs/chartjs-plugin-annotation) for chart annotations
|
||||||
|
- [Orbitron Font](https://fonts.google.com/specimen/Orbitron) for dashboard typography
|
||||||
|
- [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) for real-time updates
|
||||||
|
- [Docker](https://www.docker.com/) for containerization and deployment
|
||||||
|
- The Bitcoin open source community for inspiration and resources
|
||||||
|
- [Satoshi Nakamoto](https://en.wikipedia.org/wiki/Satoshi_Nakamoto) for creating Bitcoin
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
563
boot.html
Normal file
563
boot.html
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ocean.xyz Pool Miner - Initializing...</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* Base Styles with a subtle radial background for extra depth */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #121212, #000000);
|
||||||
|
color: #f7931a;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-shadow: 0 0 5px rgba(247, 147, 26, 0.4);
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
/* CRT Screen Effect */
|
||||||
|
body::before {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0; right: 0;
|
||||||
|
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
||||||
|
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
||||||
|
background-size: 100% 2px, 3px 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
/* Flicker Animation */
|
||||||
|
@keyframes flicker {
|
||||||
|
0% { opacity: 0.97; }
|
||||||
|
5% { opacity: 0.95; }
|
||||||
|
10% { opacity: 0.97; }
|
||||||
|
15% { opacity: 0.94; }
|
||||||
|
20% { opacity: 0.98; }
|
||||||
|
50% { opacity: 0.95; }
|
||||||
|
80% { opacity: 0.96; }
|
||||||
|
90% { opacity: 0.94; }
|
||||||
|
100% { opacity: 0.98; }
|
||||||
|
}
|
||||||
|
/* Terminal Window with scrolling enabled */
|
||||||
|
#terminal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
animation: flicker 4s infinite;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
#terminal-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #f7931a;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
/* Neon-inspired color classes */
|
||||||
|
.green {
|
||||||
|
color: #39ff14 !important;
|
||||||
|
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
color: #00dfff !important;
|
||||||
|
text-shadow: 0 0 10px #00dfff, 0 0 20px #00dfff;
|
||||||
|
}
|
||||||
|
.yellow {
|
||||||
|
color: #ffd700 !important;
|
||||||
|
text-shadow: 0 0 8px #ffd700, 0 0 16px #ffd700;
|
||||||
|
}
|
||||||
|
.white {
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-shadow: 0 0 8px #ffffff, 0 0 16px #ffffff;
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
color: #ff2d2d !important;
|
||||||
|
text-shadow: 0 0 10px #ff2d2d, 0 0 20px #ff2d2d;
|
||||||
|
}
|
||||||
|
.magenta {
|
||||||
|
color: #ff2d95 !important;
|
||||||
|
text-shadow: 0 0 10px #ff2d95, 0 0 20px #ff2d95;
|
||||||
|
}
|
||||||
|
/* Bitcoin Logo styling with extra neon border */
|
||||||
|
#bitcoin-logo {
|
||||||
|
display: block;
|
||||||
|
visibility: hidden;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px auto;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #f7931a;
|
||||||
|
text-shadow: 0 0 10px rgba(247, 147, 26, 0.8);
|
||||||
|
white-space: pre;
|
||||||
|
width: 260px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #f7931a;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
box-shadow: 0 0 15px rgba(247, 147, 26, 0.5);
|
||||||
|
font-family: monospace;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease;
|
||||||
|
}
|
||||||
|
/* Skip Button */
|
||||||
|
#skip-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #f7931a;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
#skip-button:hover {
|
||||||
|
background-color: #ffa32e;
|
||||||
|
box-shadow: 0 0 12px rgba(247, 147, 26, 0.7);
|
||||||
|
}
|
||||||
|
/* Prompt Styling */
|
||||||
|
#prompt-container {
|
||||||
|
display: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#prompt-text {
|
||||||
|
color: #f7931a;
|
||||||
|
margin-right: 5px;
|
||||||
|
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
#user-input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #f7931a;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
caret-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
width: 20px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.prompt-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #f7931a;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
margin-left: -2px;
|
||||||
|
}
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { font-size: 14px; padding: 10px; }
|
||||||
|
#terminal { margin: 0; }
|
||||||
|
}
|
||||||
|
/* Loading and Debug Info */
|
||||||
|
#loading-message {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
}
|
||||||
|
#debug-info {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="skip-button">SKIP</button>
|
||||||
|
<div id="debug-info"></div>
|
||||||
|
<div id="loading-message">Loading mining data...</div>
|
||||||
|
<div id="bitcoin-logo">
|
||||||
|
██████╗ ████████╗ ██████╗ ██████╗ ███████╗
|
||||||
|
██╔══██╗╚══██╔══╝██╔════╝ ██╔═══██╗██╔════╝
|
||||||
|
██████╔╝ ██║ ██║ ██║ ██║███████╗
|
||||||
|
██╔══██╗ ██║ ██║ ██║ ██║╚════██║
|
||||||
|
██████╔╝ ██║ ╚██████╗ ╚██████╔╝███████║
|
||||||
|
╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||||
|
v.21
|
||||||
|
</div>
|
||||||
|
<div id="terminal">
|
||||||
|
<div id="terminal-content">
|
||||||
|
<span id="output"></span><span class="cursor"></span>
|
||||||
|
<span id="prompt-container">
|
||||||
|
<span id="prompt-text">Initialize mining dashboard? [Y/N]:
|
||||||
|
<span class="prompt-cursor"></span>
|
||||||
|
<input type="text" id="user-input" maxlength="1" autocomplete="off" spellcheck="false" autofocus style="font-size: 16px; font-weight: bold;">
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Debug logging
|
||||||
|
function updateDebug(message) {
|
||||||
|
document.getElementById('debug-info').textContent = message;
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format numbers with commas
|
||||||
|
function numberWithCommas(x) {
|
||||||
|
if (x == null) return "N/A";
|
||||||
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global variables
|
||||||
|
let bootMessages = [];
|
||||||
|
let dashboardData = null;
|
||||||
|
let outputElement = document.getElementById('output');
|
||||||
|
const bitcoinLogo = document.getElementById('bitcoin-logo');
|
||||||
|
const skipButton = document.getElementById('skip-button');
|
||||||
|
const loadingMessage = document.getElementById('loading-message');
|
||||||
|
const promptContainer = document.getElementById('prompt-container');
|
||||||
|
const userInput = document.getElementById('user-input');
|
||||||
|
let messageIndex = 0;
|
||||||
|
let timeoutId = null;
|
||||||
|
let waitingForUserInput = false;
|
||||||
|
let bootComplete = false;
|
||||||
|
|
||||||
|
// Safety timeout: redirect after 60 seconds if boot not complete
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (!bootComplete && !waitingForUserInput) {
|
||||||
|
console.warn("Safety timeout reached - redirecting to dashboard");
|
||||||
|
redirectToDashboard();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
function redirectToDashboard() {
|
||||||
|
updateDebug("Boot sequence complete, redirecting...");
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
window.location.href = baseUrl + "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in Bitcoin logo
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
bitcoinLogo.style.visibility = 'visible';
|
||||||
|
setTimeout(function() {
|
||||||
|
bitcoinLogo.style.opacity = '1';
|
||||||
|
}, 100);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Post-confirmation messages with retro typing effect
|
||||||
|
function showPostConfirmationMessages(response) {
|
||||||
|
try {
|
||||||
|
outputElement = document.getElementById('output');
|
||||||
|
if (!outputElement) {
|
||||||
|
setTimeout(redirectToDashboard, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const yesMessages = [
|
||||||
|
{ text: "INITIALIZING DASHBOARD...\n", html: true, delay: 400 },
|
||||||
|
{ text: "Connecting to real-time data feeds...", speed: 20, delay: 300 },
|
||||||
|
{ text: "<span class='green'>CONNECTED</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Loading blockchain validators...", speed: 15, delay: 300 },
|
||||||
|
{ text: "<span class='green'>COMPLETE</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Starting TX fee calculation module...", speed: 15, delay: 400 },
|
||||||
|
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Verifying BTC-USD exchange rates...", speed: 15, delay: 200 },
|
||||||
|
{ text: "<span class='green'>CURRENT RATE CONFIRMED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "Calibrating hashrate telemetry...", speed: 15, delay: 200 },
|
||||||
|
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 200 },
|
||||||
|
{ text: "Loading historical mining data: [", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='green'>████████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='green'>███████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='green'>████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='green'>██</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='green'>█</span>", html: true, delay: 100 },
|
||||||
|
{ text: "] <span class='green'>COMPLETE</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Block reward calculation initialized...", speed: 15, delay: 300 },
|
||||||
|
{ text: "<span class='yellow'>HALVING SCHEDULE VERIFIED</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Satoshi per kWh optimizer: <span class='green'>ENGAGED</span>\n", html: true, delay: 500 },
|
||||||
|
{ text: "Launching mining dashboard in 3...", speed: 80, delay: 1000 },
|
||||||
|
{ text: " 2...", speed: 80, delay: 1000 },
|
||||||
|
{ text: " 1...\n", speed: 80, delay: 1000 },
|
||||||
|
{ text: "<span class='green'>STACK SATS MODE: ACTIVATED</span>\n", html: true, delay: 500 }
|
||||||
|
];
|
||||||
|
const noMessages = [
|
||||||
|
{ text: "DASHBOARD INITIALIZATION ABORTED.\n", html: true, delay: 400 },
|
||||||
|
{ text: "Mining processes will continue in background.\n", speed: 30, delay: 500 },
|
||||||
|
{ text: "Attempting emergency recovery...\n", speed: 30, delay: 1000 },
|
||||||
|
{ text: "Bypassing authentication checks...", speed: 20, delay: 300 },
|
||||||
|
{ text: "<span class='green'>SUCCESS</span>\n", html: true, delay: 500 },
|
||||||
|
{ text: "Initializing fallback ASIC control interface...", speed: 20, delay: 300 },
|
||||||
|
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "<span class='green'>RECOVERY SUCCESSFUL!</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "Loading minimal dashboard in safe mode: [", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>████████</span>", html: true, delay: 100 },
|
||||||
|
{ text: "] <span class='white'>READY</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "\nNOTE: REDUCED FUNCTIONALITY IN SAFE MODE\n", html: true, delay: 500 },
|
||||||
|
{ text: "Launching minimal dashboard in 3...", speed: 80, delay: 1000 },
|
||||||
|
{ text: " 2...", speed: 80, delay: 1000 },
|
||||||
|
{ text: " 1...\n", speed: 80, delay: 1000 },
|
||||||
|
{ text: "<span class='green'>BITCOIN MINING CONTINUES REGARDLESS! ;)</span>\n", html: true, delay: 500 }
|
||||||
|
];
|
||||||
|
const messages = response === 'Y' ? yesMessages : noMessages;
|
||||||
|
let msgIndex = 0;
|
||||||
|
function processNextMessage() {
|
||||||
|
if (msgIndex >= messages.length) {
|
||||||
|
bootComplete = true;
|
||||||
|
setTimeout(redirectToDashboard, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentMessage = messages[msgIndex];
|
||||||
|
if (currentMessage.html) {
|
||||||
|
outputElement.innerHTML += currentMessage.text;
|
||||||
|
msgIndex++;
|
||||||
|
setTimeout(processNextMessage, currentMessage.delay || 300);
|
||||||
|
} else {
|
||||||
|
let charIndex = 0;
|
||||||
|
function typeCharacter() {
|
||||||
|
if (charIndex < currentMessage.text.length) {
|
||||||
|
outputElement.innerHTML += currentMessage.text.charAt(charIndex);
|
||||||
|
charIndex++;
|
||||||
|
setTimeout(typeCharacter, currentMessage.speed || 20);
|
||||||
|
} else {
|
||||||
|
msgIndex++;
|
||||||
|
setTimeout(processNextMessage, currentMessage.delay || 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typeCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(processNextMessage, 500);
|
||||||
|
} catch(err) {
|
||||||
|
setTimeout(redirectToDashboard, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Y/N prompt input
|
||||||
|
userInput.addEventListener('keydown', function(e) {
|
||||||
|
if (waitingForUserInput && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const response = userInput.value.toUpperCase();
|
||||||
|
promptContainer.style.display = 'none';
|
||||||
|
waitingForUserInput = false;
|
||||||
|
outputElement.innerHTML += response + "\n";
|
||||||
|
userInput.value = '';
|
||||||
|
showPostConfirmationMessages(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the prompt
|
||||||
|
function showUserPrompt() {
|
||||||
|
promptContainer.style.display = 'inline';
|
||||||
|
waitingForUserInput = true;
|
||||||
|
document.querySelector('.cursor').style.display = 'none';
|
||||||
|
userInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We disable truncation so all text is visible.
|
||||||
|
function manageTerminalContent() { }
|
||||||
|
|
||||||
|
// Retro typing effect for boot messages
|
||||||
|
function typeBootMessages() {
|
||||||
|
try {
|
||||||
|
if (!outputElement) {
|
||||||
|
outputElement = document.getElementById('output');
|
||||||
|
if (!outputElement) {
|
||||||
|
skipButton.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("Processing boot message index:", messageIndex);
|
||||||
|
if (messageIndex >= bootMessages.length) { return; }
|
||||||
|
const currentMessage = bootMessages[messageIndex];
|
||||||
|
if (currentMessage.showPrompt) {
|
||||||
|
messageIndex++;
|
||||||
|
showUserPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentMessage.html) {
|
||||||
|
outputElement.innerHTML += currentMessage.text;
|
||||||
|
messageIndex++;
|
||||||
|
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentMessage.typingIndex) { currentMessage.typingIndex = 0; }
|
||||||
|
if (currentMessage.typingIndex < currentMessage.text.length) {
|
||||||
|
outputElement.innerHTML += currentMessage.text.charAt(currentMessage.typingIndex);
|
||||||
|
currentMessage.typingIndex++;
|
||||||
|
timeoutId = setTimeout(typeBootMessages, currentMessage.speed || 15);
|
||||||
|
} else {
|
||||||
|
messageIndex++;
|
||||||
|
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
messageIndex++;
|
||||||
|
timeoutId = setTimeout(typeBootMessages, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip button: immediately redirect
|
||||||
|
skipButton.addEventListener('click', function() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
redirectToDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the typing animation (hides loading message)
|
||||||
|
function startTyping() {
|
||||||
|
loadingMessage.style.display = 'none';
|
||||||
|
setTimeout(typeBootMessages, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback messages (used immediately)
|
||||||
|
function setupFallbackMessages() {
|
||||||
|
bootMessages = [
|
||||||
|
{ text: "BITCOIN OS - MINING SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
|
||||||
|
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto\n", speed: 20, delay: 250 },
|
||||||
|
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
|
||||||
|
{ text: "INITIALIZING SYSTEM...\n", speed: 25, delay: 300 },
|
||||||
|
{ text: "HARDWARE: ", speed: 25, delay: 100 },
|
||||||
|
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "NETWORK: ", speed: 25, delay: 100 },
|
||||||
|
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "BLOCKCHAIN: ", speed: 25, delay: 100 },
|
||||||
|
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "MINING RIG: ", speed: 25, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nSystem ready. ", speed: 25, delay: 400 },
|
||||||
|
{ showPrompt: true, delay: 0 }
|
||||||
|
];
|
||||||
|
startTyping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with fallback, then try live data
|
||||||
|
setupFallbackMessages();
|
||||||
|
updateDebug("Fetching dashboard data...");
|
||||||
|
fetch('/api/metrics')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); }
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
dashboardData = data;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
messageIndex = 0;
|
||||||
|
outputElement = document.getElementById('output');
|
||||||
|
outputElement.innerHTML = "";
|
||||||
|
bootMessages = [
|
||||||
|
{ text: "BITCOIN OS - MINING CONTROL SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
|
||||||
|
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto & The Bitcoin Core Developers\n", speed: 20, delay: 250 },
|
||||||
|
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
|
||||||
|
{ text: "INITIALIZING SHA-256 MINING SUBSYSTEMS...\n", speed: 25, delay: 400 },
|
||||||
|
{ text: "ASIC CLUSTER STATUS: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "CHIP TEMPERATURE: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>62°C - WITHIN OPTIMAL RANGE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "COOLING SYSTEMS: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>OPERATIONAL</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "POWER SUPPLY HEALTH: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>98.7% - NOMINAL</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nCONNECTING TO BITCOIN NETWORK...\n", speed: 20, delay: 400 },
|
||||||
|
{ text: "BLOCKCHAIN SYNC STATUS: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "DIFFICULTY ADJUSTMENT: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>CALCULATED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "MEMPOOL MONITORING: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nESTABLISHING POOL CONNECTION...\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "CONNECTING TO OCEAN.XYZ...\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "STRATUM PROTOCOL v2: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>INITIALIZED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "POOL HASHRATE: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>VERIFIED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "WORKER AUTHENTICATION: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>SUCCESSFUL</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nINITIALIZING METRICS COLLECTORS...\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "HASHRATE MONITOR: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "EARNINGS CALCULATOR: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "POWER USAGE TRACKING: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ENABLED</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "PAYOUT THRESHOLD MONITOR: ", speed: 15, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nCURRENT NETWORK METRICS DETECTED\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "BTC PRICE: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>$" + numberWithCommas((data.btc_price || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "NETWORK DIFFICULTY: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='white'>" + numberWithCommas(Math.round(data.difficulty || 0)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "NETWORK HASHRATE: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='white'>" + (data.network_hashrate ? numberWithCommas(Math.round(data.network_hashrate)) : "N/A") + " EH/s</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "BLOCK HEIGHT: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='white'>" + numberWithCommas(data.block_number || "N/A") + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nMINER PERFORMANCE DATA\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "CURRENT HASHRATE: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>" + (data.hashrate_60sec || "N/A") + " " + (data.hashrate_60sec_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "24HR AVG HASHRATE: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>" + (data.hashrate_24hr || "N/A") + " " + (data.hashrate_24hr_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "ACTIVE WORKERS: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>" + (data.workers_hashing || "0") + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\nFINANCIAL CALCULATIONS\n", speed: 20, delay: 300 },
|
||||||
|
{ text: "DAILY MINING REVENUE: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='green'>$" + numberWithCommas((data.daily_revenue || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "DAILY POWER COST: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='red'>$" + numberWithCommas((data.daily_power_cost || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "DAILY PROFIT: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='green'>$" + numberWithCommas((data.daily_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "PROJECTED MONTHLY PROFIT: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='green'>$" + numberWithCommas((data.monthly_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "DAILY SATOSHI YIELD: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>" + numberWithCommas(data.daily_mined_sats || 0) + " sats</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "UNPAID EARNINGS: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='green'>" + (data.unpaid_earnings || "0") + " BTC</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "ESTIMATED TIME TO PAYOUT: ", speed: 20, delay: 100 },
|
||||||
|
{ text: "<span class='yellow'>" + (data.est_time_to_payout || "Unknown") + "</span>\n", html: true, delay: 300 },
|
||||||
|
{ text: "\n", speed: 25, delay: 100 },
|
||||||
|
{ text: "<span class='green'>ALL MINING PROCESSES OPERATIONAL</span>\n", html: true, delay: 400 },
|
||||||
|
{ text: "\nInitialize mining dashboard? ", speed: 25, delay: 400 },
|
||||||
|
{ showPrompt: true, delay: 0 }
|
||||||
|
];
|
||||||
|
startTyping();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
updateDebug(`Error fetching dashboard data: ${error.message}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"power_cost": 0.0,
|
|
||||||
"power_usage": 0.0,
|
|
||||||
"wallet": "yourwallethere",
|
|
||||||
"timezone": "America/Los_Angeles",
|
|
||||||
"network_fee": 0.0
|
|
||||||
}
|
|
96
config.py
96
config.py
@ -1,96 +0,0 @@
|
|||||||
"""
|
|
||||||
Configuration management module for the Bitcoin Mining Dashboard.
|
|
||||||
Responsible for loading and managing application settings.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Default configuration file path
|
|
||||||
CONFIG_FILE = "config.json"
|
|
||||||
|
|
||||||
def load_config():
|
|
||||||
"""
|
|
||||||
Load configuration from file or return defaults if file doesn't exist.
|
|
||||||
"""
|
|
||||||
default_config = {
|
|
||||||
"power_cost": 0.0,
|
|
||||||
"power_usage": 0.0,
|
|
||||||
"wallet": "yourwallethere",
|
|
||||||
"timezone": "America/Los_Angeles",
|
|
||||||
"network_fee": 0.0 # Add default network fee
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.path.exists(CONFIG_FILE):
|
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE, "r") as f:
|
|
||||||
config = json.load(f)
|
|
||||||
logging.info(f"Configuration loaded from {CONFIG_FILE}")
|
|
||||||
|
|
||||||
# Ensure network_fee is present even in existing config files
|
|
||||||
if "network_fee" not in config:
|
|
||||||
config["network_fee"] = default_config["network_fee"]
|
|
||||||
logging.info("Added missing network_fee to config with default value")
|
|
||||||
|
|
||||||
return config
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error loading config: {e}")
|
|
||||||
else:
|
|
||||||
logging.warning(f"Config file {CONFIG_FILE} not found, using defaults")
|
|
||||||
|
|
||||||
return default_config
|
|
||||||
|
|
||||||
def get_timezone():
|
|
||||||
"""
|
|
||||||
Get the configured timezone with fallback to default.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Timezone identifier
|
|
||||||
"""
|
|
||||||
# First check environment variable (for Docker)
|
|
||||||
import os
|
|
||||||
env_timezone = os.environ.get("TIMEZONE")
|
|
||||||
if env_timezone:
|
|
||||||
return env_timezone
|
|
||||||
|
|
||||||
# Then check config file
|
|
||||||
config = load_config()
|
|
||||||
timezone = config.get("timezone")
|
|
||||||
if timezone:
|
|
||||||
return timezone
|
|
||||||
|
|
||||||
# Default to Los Angeles
|
|
||||||
return "America/Los_Angeles"
|
|
||||||
|
|
||||||
def save_config(config):
|
|
||||||
"""
|
|
||||||
Save configuration to file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration dictionary to save
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if save was successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE, "w") as f:
|
|
||||||
json.dump(config, f, indent=2)
|
|
||||||
logging.info(f"Configuration saved to {CONFIG_FILE}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error saving config: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_value(key, default=None):
|
|
||||||
"""
|
|
||||||
Get a configuration value by key with fallback to default.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key (str): Configuration key to look up
|
|
||||||
default: Default value if key is not found
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Value for the key or default if not found
|
|
||||||
"""
|
|
||||||
config = load_config()
|
|
||||||
return config.get(key, default)
|
|
993
data_service.py
993
data_service.py
@ -1,993 +0,0 @@
|
|||||||
"""
|
|
||||||
Data service module for fetching and processing mining data.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
import requests
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from models import OceanData, WorkerData, convert_to_ths
|
|
||||||
from config import get_timezone
|
|
||||||
|
|
||||||
class MiningDashboardService:
|
|
||||||
"""Service for fetching and processing mining dashboard data."""
|
|
||||||
|
|
||||||
def __init__(self, power_cost, power_usage, wallet, network_fee=0.0):
|
|
||||||
"""
|
|
||||||
Initialize the mining dashboard service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
power_cost (float): Cost of power in $ per kWh
|
|
||||||
power_usage (float): Power usage in watts
|
|
||||||
wallet (str): Bitcoin wallet address for Ocean.xyz
|
|
||||||
network_fee (float): Additional network fee percentage
|
|
||||||
"""
|
|
||||||
self.power_cost = power_cost
|
|
||||||
self.power_usage = power_usage
|
|
||||||
self.wallet = wallet
|
|
||||||
self.network_fee = network_fee
|
|
||||||
self.cache = {}
|
|
||||||
self.sats_per_btc = 100_000_000
|
|
||||||
self.previous_values = {}
|
|
||||||
self.session = requests.Session()
|
|
||||||
|
|
||||||
def fetch_metrics(self):
|
|
||||||
"""
|
|
||||||
Fetch metrics from Ocean.xyz and other sources.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Mining metrics data
|
|
||||||
"""
|
|
||||||
# Add execution time tracking
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
future_ocean = executor.submit(self.get_ocean_data)
|
|
||||||
future_btc = executor.submit(self.get_bitcoin_stats)
|
|
||||||
try:
|
|
||||||
ocean_data = future_ocean.result(timeout=15)
|
|
||||||
btc_stats = future_btc.result(timeout=15)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error fetching metrics concurrently: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if ocean_data is None:
|
|
||||||
logging.error("Failed to retrieve Ocean data")
|
|
||||||
return None
|
|
||||||
|
|
||||||
difficulty, network_hashrate, btc_price, block_count = btc_stats
|
|
||||||
|
|
||||||
# If we failed to get network hashrate, use a reasonable default to prevent division by zero
|
|
||||||
if network_hashrate is None:
|
|
||||||
logging.warning("Using default network hashrate")
|
|
||||||
network_hashrate = 500e18 # ~500 EH/s as a reasonable fallback
|
|
||||||
|
|
||||||
# If we failed to get BTC price, use a reasonable default
|
|
||||||
if btc_price is None:
|
|
||||||
logging.warning("Using default BTC price")
|
|
||||||
btc_price = 75000 # $75,000 as a reasonable fallback
|
|
||||||
|
|
||||||
# Convert hashrates to a common unit (TH/s) for consistency
|
|
||||||
hr3 = ocean_data.hashrate_3hr or 0
|
|
||||||
hr3_unit = (ocean_data.hashrate_3hr_unit or 'th/s').lower()
|
|
||||||
local_hashrate = convert_to_ths(hr3, hr3_unit) * 1e12 # Convert to H/s for calculation
|
|
||||||
|
|
||||||
hash_proportion = local_hashrate / network_hashrate if network_hashrate else 0
|
|
||||||
block_reward = 3.125
|
|
||||||
blocks_per_day = 86400 / 600
|
|
||||||
daily_btc_gross = hash_proportion * block_reward * blocks_per_day
|
|
||||||
|
|
||||||
# Use actual pool fees instead of hardcoded values
|
|
||||||
# Get the pool fee percentage from ocean_data, default to 2.0% if not available
|
|
||||||
pool_fee_percent = ocean_data.pool_fees_percentage if ocean_data.pool_fees_percentage is not None else 2.0
|
|
||||||
|
|
||||||
# Get the network fee from the configuration (default to 0.0% if not set)
|
|
||||||
from config import load_config
|
|
||||||
config = load_config()
|
|
||||||
network_fee_percent = config.get("network_fee", 0.0)
|
|
||||||
|
|
||||||
# Calculate total fee percentage (converting from percentage to decimal)
|
|
||||||
total_fee_rate = (pool_fee_percent + network_fee_percent) / 100.0
|
|
||||||
|
|
||||||
# Calculate net BTC accounting for actual fees
|
|
||||||
daily_btc_net = daily_btc_gross * (1 - total_fee_rate)
|
|
||||||
|
|
||||||
# Log the fee calculations for transparency
|
|
||||||
logging.info(f"Earnings calculation using pool fee: {pool_fee_percent}% + network fee: {network_fee_percent}%")
|
|
||||||
logging.info(f"Total fee rate: {total_fee_rate}, Daily BTC gross: {daily_btc_gross}, Daily BTC net: {daily_btc_net}")
|
|
||||||
|
|
||||||
daily_revenue = round(daily_btc_net * btc_price, 2) if btc_price is not None else None
|
|
||||||
daily_power_cost = round((self.power_usage / 1000) * self.power_cost * 24, 2)
|
|
||||||
daily_profit_usd = round(daily_revenue - daily_power_cost, 2) if daily_revenue is not None else None
|
|
||||||
monthly_profit_usd = round(daily_profit_usd * 30, 2) if daily_profit_usd is not None else None
|
|
||||||
|
|
||||||
daily_mined_sats = int(round(daily_btc_net * self.sats_per_btc))
|
|
||||||
monthly_mined_sats = daily_mined_sats * 30
|
|
||||||
|
|
||||||
# Use default 0 for earnings if scraping returned None.
|
|
||||||
estimated_earnings_per_day = ocean_data.estimated_earnings_per_day if ocean_data.estimated_earnings_per_day is not None else 0
|
|
||||||
estimated_earnings_next_block = ocean_data.estimated_earnings_next_block if ocean_data.estimated_earnings_next_block is not None else 0
|
|
||||||
estimated_rewards_in_window = ocean_data.estimated_rewards_in_window if ocean_data.estimated_rewards_in_window is not None else 0
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
'pool_total_hashrate': ocean_data.pool_total_hashrate,
|
|
||||||
'pool_total_hashrate_unit': ocean_data.pool_total_hashrate_unit,
|
|
||||||
'hashrate_24hr': ocean_data.hashrate_24hr,
|
|
||||||
'hashrate_24hr_unit': ocean_data.hashrate_24hr_unit,
|
|
||||||
'hashrate_3hr': ocean_data.hashrate_3hr,
|
|
||||||
'hashrate_3hr_unit': ocean_data.hashrate_3hr_unit,
|
|
||||||
'hashrate_10min': ocean_data.hashrate_10min,
|
|
||||||
'hashrate_10min_unit': ocean_data.hashrate_10min_unit,
|
|
||||||
'hashrate_5min': ocean_data.hashrate_5min,
|
|
||||||
'hashrate_5min_unit': ocean_data.hashrate_5min_unit,
|
|
||||||
'hashrate_60sec': ocean_data.hashrate_60sec,
|
|
||||||
'hashrate_60sec_unit': ocean_data.hashrate_60sec_unit,
|
|
||||||
'workers_hashing': ocean_data.workers_hashing,
|
|
||||||
'btc_price': btc_price,
|
|
||||||
'block_number': block_count,
|
|
||||||
'network_hashrate': (network_hashrate / 1e18) if network_hashrate else None,
|
|
||||||
'difficulty': difficulty,
|
|
||||||
'daily_btc_gross': daily_btc_gross,
|
|
||||||
'daily_btc_net': daily_btc_net,
|
|
||||||
'pool_fee_percent': pool_fee_percent,
|
|
||||||
'network_fee_percent': network_fee_percent,
|
|
||||||
'total_fee_rate': total_fee_rate,
|
|
||||||
'estimated_earnings_per_day': estimated_earnings_per_day,
|
|
||||||
'daily_revenue': daily_revenue,
|
|
||||||
'daily_power_cost': daily_power_cost,
|
|
||||||
'daily_profit_usd': daily_profit_usd,
|
|
||||||
'monthly_profit_usd': monthly_profit_usd,
|
|
||||||
'daily_mined_sats': daily_mined_sats,
|
|
||||||
'monthly_mined_sats': monthly_mined_sats,
|
|
||||||
'estimated_earnings_next_block': estimated_earnings_next_block,
|
|
||||||
'estimated_rewards_in_window': estimated_rewards_in_window,
|
|
||||||
'unpaid_earnings': ocean_data.unpaid_earnings,
|
|
||||||
'est_time_to_payout': ocean_data.est_time_to_payout,
|
|
||||||
'last_block_height': ocean_data.last_block_height,
|
|
||||||
'last_block_time': ocean_data.last_block_time,
|
|
||||||
'total_last_share': ocean_data.total_last_share,
|
|
||||||
'blocks_found': ocean_data.blocks_found or "0",
|
|
||||||
'last_block_earnings': ocean_data.last_block_earnings,
|
|
||||||
'pool_fees_percentage': ocean_data.pool_fees_percentage,
|
|
||||||
}
|
|
||||||
metrics['estimated_earnings_per_day_sats'] = int(round(estimated_earnings_per_day * self.sats_per_btc))
|
|
||||||
metrics['estimated_earnings_next_block_sats'] = int(round(estimated_earnings_next_block * self.sats_per_btc))
|
|
||||||
metrics['estimated_rewards_in_window_sats'] = int(round(estimated_rewards_in_window * self.sats_per_btc))
|
|
||||||
|
|
||||||
# --- Add server timestamps to the response in Los Angeles Time ---
|
|
||||||
metrics["server_timestamp"] = datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
metrics["server_start_time"] = datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
|
|
||||||
# Log execution time
|
|
||||||
execution_time = time.time() - start_time
|
|
||||||
metrics["execution_time"] = execution_time
|
|
||||||
if execution_time > 10:
|
|
||||||
logging.warning(f"Metrics fetch took {execution_time:.2f} seconds")
|
|
||||||
else:
|
|
||||||
logging.info(f"Metrics fetch completed in {execution_time:.2f} seconds")
|
|
||||||
|
|
||||||
return metrics
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Unexpected error in fetch_metrics: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_ocean_data(self):
|
|
||||||
"""
|
|
||||||
Get mining data from Ocean.xyz.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OceanData: Ocean.xyz mining data
|
|
||||||
"""
|
|
||||||
base_url = "https://ocean.xyz"
|
|
||||||
stats_url = f"{base_url}/stats/{self.wallet}"
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0',
|
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
||||||
'Cache-Control': 'no-cache'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create an empty data object to populate
|
|
||||||
data = OceanData()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(stats_url, headers=headers, timeout=10)
|
|
||||||
if not response.ok:
|
|
||||||
logging.error(f"Error fetching ocean data: status code {response.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
|
|
||||||
# Safely extract pool status information
|
|
||||||
try:
|
|
||||||
pool_status = soup.find("p", id="pool-status-item")
|
|
||||||
if pool_status:
|
|
||||||
text = pool_status.get_text(strip=True)
|
|
||||||
m_total = re.search(r'HASHRATE:\s*([\d\.]+)\s*(\w+/s)', text, re.IGNORECASE)
|
|
||||||
if m_total:
|
|
||||||
raw_val = float(m_total.group(1))
|
|
||||||
unit = m_total.group(2)
|
|
||||||
data.pool_total_hashrate = raw_val
|
|
||||||
data.pool_total_hashrate_unit = unit
|
|
||||||
span = pool_status.find("span", class_="pool-status-newline")
|
|
||||||
if span:
|
|
||||||
last_block_text = span.get_text(strip=True)
|
|
||||||
m_block = re.search(r'LAST BLOCK:\s*(\d+\s*\(.*\))', last_block_text, re.IGNORECASE)
|
|
||||||
if m_block:
|
|
||||||
full_last_block = m_block.group(1)
|
|
||||||
data.last_block = full_last_block
|
|
||||||
match = re.match(r'(\d+)\s*\((.*?)\)', full_last_block)
|
|
||||||
if match:
|
|
||||||
data.last_block_height = match.group(1)
|
|
||||||
data.last_block_time = match.group(2)
|
|
||||||
else:
|
|
||||||
data.last_block_height = full_last_block
|
|
||||||
data.last_block_time = ""
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing pool status: {e}")
|
|
||||||
|
|
||||||
# Parse the earnings value from the earnings table and convert to sats.
|
|
||||||
try:
|
|
||||||
earnings_table = soup.find('tbody', id='earnings-tablerows')
|
|
||||||
if earnings_table:
|
|
||||||
latest_row = earnings_table.find('tr', class_='table-row')
|
|
||||||
if latest_row:
|
|
||||||
cells = latest_row.find_all('td', class_='table-cell')
|
|
||||||
if len(cells) >= 4: # Ensure there are enough cells for earnings and pool fees
|
|
||||||
earnings_text = cells[2].get_text(strip=True)
|
|
||||||
pool_fees_text = cells[3].get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse earnings and pool fees
|
|
||||||
earnings_value = earnings_text.replace('BTC', '').strip()
|
|
||||||
pool_fees_value = pool_fees_text.replace('BTC', '').strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Convert earnings to BTC and sats
|
|
||||||
btc_earnings = float(earnings_value)
|
|
||||||
sats = int(round(btc_earnings * 100_000_000))
|
|
||||||
data.last_block_earnings = str(sats)
|
|
||||||
|
|
||||||
# Calculate percentage lost to pool fees
|
|
||||||
btc_pool_fees = float(pool_fees_value)
|
|
||||||
percentage_lost = (btc_pool_fees / btc_earnings) * 100 if btc_earnings > 0 else 0
|
|
||||||
data.pool_fees_percentage = round(percentage_lost, 2)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error converting earnings or calculating percentage: {e}")
|
|
||||||
data.last_block_earnings = earnings_value
|
|
||||||
data.pool_fees_percentage = None
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing earnings data: {e}")
|
|
||||||
|
|
||||||
# Parse hashrate data from the hashrates table
|
|
||||||
try:
|
|
||||||
time_mapping = {
|
|
||||||
'24 hrs': ('hashrate_24hr', 'hashrate_24hr_unit'),
|
|
||||||
'3 hrs': ('hashrate_3hr', 'hashrate_3hr_unit'),
|
|
||||||
'10 min': ('hashrate_10min', 'hashrate_10min_unit'),
|
|
||||||
'5 min': ('hashrate_5min', 'hashrate_5min_unit'),
|
|
||||||
'60 sec': ('hashrate_60sec', 'hashrate_60sec_unit')
|
|
||||||
}
|
|
||||||
hashrate_table = soup.find('tbody', id='hashrates-tablerows')
|
|
||||||
if hashrate_table:
|
|
||||||
for row in hashrate_table.find_all('tr', class_='table-row'):
|
|
||||||
cells = row.find_all('td', class_='table-cell')
|
|
||||||
if len(cells) >= 2:
|
|
||||||
period_text = cells[0].get_text(strip=True).lower()
|
|
||||||
hashrate_str = cells[1].get_text(strip=True).lower()
|
|
||||||
try:
|
|
||||||
parts = hashrate_str.split()
|
|
||||||
hashrate_val = float(parts[0])
|
|
||||||
unit = parts[1] if len(parts) > 1 else 'th/s'
|
|
||||||
for key, (attr, unit_attr) in time_mapping.items():
|
|
||||||
if key.lower() in period_text:
|
|
||||||
setattr(data, attr, hashrate_val)
|
|
||||||
setattr(data, unit_attr, unit)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing hashrate '{hashrate_str}': {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing hashrate table: {e}")
|
|
||||||
|
|
||||||
# Parse lifetime stats data
|
|
||||||
try:
|
|
||||||
lifetime_snap = soup.find('div', id='lifetimesnap-statcards')
|
|
||||||
if lifetime_snap:
|
|
||||||
for container in lifetime_snap.find_all('div', class_='blocks dashboard-container'):
|
|
||||||
label_div = container.find('div', class_='blocks-label')
|
|
||||||
if label_div:
|
|
||||||
label_text = label_div.get_text(strip=True).lower()
|
|
||||||
earnings_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
|
|
||||||
if earnings_span:
|
|
||||||
span_text = earnings_span.get_text(strip=True)
|
|
||||||
try:
|
|
||||||
earnings_value = float(span_text.split()[0].replace(',', ''))
|
|
||||||
if "earnings" in label_text and "day" in label_text:
|
|
||||||
data.estimated_earnings_per_day = earnings_value
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing lifetime stats: {e}")
|
|
||||||
|
|
||||||
# Parse payout stats data
|
|
||||||
try:
|
|
||||||
payout_snap = soup.find('div', id='payoutsnap-statcards')
|
|
||||||
if payout_snap:
|
|
||||||
for container in payout_snap.find_all('div', class_='blocks dashboard-container'):
|
|
||||||
label_div = container.find('div', class_='blocks-label')
|
|
||||||
if label_div:
|
|
||||||
label_text = label_div.get_text(strip=True).lower()
|
|
||||||
earnings_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
|
|
||||||
if earnings_span:
|
|
||||||
span_text = earnings_span.get_text(strip=True)
|
|
||||||
try:
|
|
||||||
earnings_value = float(span_text.split()[0].replace(',', ''))
|
|
||||||
if "earnings" in label_text and "block" in label_text:
|
|
||||||
data.estimated_earnings_next_block = earnings_value
|
|
||||||
elif "rewards" in label_text and "window" in label_text:
|
|
||||||
data.estimated_rewards_in_window = earnings_value
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing payout stats: {e}")
|
|
||||||
|
|
||||||
# Parse user stats data
|
|
||||||
try:
|
|
||||||
usersnap = soup.find('div', id='usersnap-statcards')
|
|
||||||
if usersnap:
|
|
||||||
for container in usersnap.find_all('div', class_='blocks dashboard-container'):
|
|
||||||
label_div = container.find('div', class_='blocks-label')
|
|
||||||
if label_div:
|
|
||||||
label_text = label_div.get_text(strip=True).lower()
|
|
||||||
value_span = label_div.find_next('span', class_=lambda x: x != 'tooltiptext')
|
|
||||||
if value_span:
|
|
||||||
span_text = value_span.get_text(strip=True)
|
|
||||||
if "workers currently hashing" in label_text:
|
|
||||||
try:
|
|
||||||
data.workers_hashing = int(span_text.replace(",", ""))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif "unpaid earnings" in label_text and "btc" in span_text.lower():
|
|
||||||
try:
|
|
||||||
data.unpaid_earnings = float(span_text.split()[0].replace(',', ''))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif "estimated time until minimum payout" in label_text:
|
|
||||||
data.est_time_to_payout = span_text
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing user stats: {e}")
|
|
||||||
|
|
||||||
# Parse blocks found data
|
|
||||||
try:
|
|
||||||
blocks_container = soup.find(lambda tag: tag.name == "div" and "blocks found" in tag.get_text(strip=True).lower())
|
|
||||||
if blocks_container:
|
|
||||||
span = blocks_container.find_next_sibling("span")
|
|
||||||
if span:
|
|
||||||
num_match = re.search(r'(\d+)', span.get_text(strip=True))
|
|
||||||
if num_match:
|
|
||||||
data.blocks_found = num_match.group(1)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing blocks found: {e}")
|
|
||||||
|
|
||||||
# Parse last share time data
|
|
||||||
try:
|
|
||||||
workers_table = soup.find("tbody", id="workers-tablerows")
|
|
||||||
if workers_table:
|
|
||||||
for row in workers_table.find_all("tr", class_="table-row"):
|
|
||||||
cells = row.find_all("td")
|
|
||||||
if cells and cells[0].get_text(strip=True).lower().startswith("total"):
|
|
||||||
last_share_str = cells[2].get_text(strip=True)
|
|
||||||
try:
|
|
||||||
naive_dt = datetime.strptime(last_share_str, "%Y-%m-%d %H:%M")
|
|
||||||
utc_dt = naive_dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
||||||
la_dt = utc_dt.astimezone(ZoneInfo(get_timezone()))
|
|
||||||
data.total_last_share = la_dt.strftime("%Y-%m-%d %I:%M %p")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error converting last share time '{last_share_str}': {e}")
|
|
||||||
data.total_last_share = last_share_str
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing last share time: {e}")
|
|
||||||
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error fetching Ocean data: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def debug_dump_table(self, table_element, max_rows=3):
|
|
||||||
"""
|
|
||||||
Helper method to dump the structure of an HTML table for debugging.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
table_element: BeautifulSoup element representing the table
|
|
||||||
max_rows (int): Maximum number of rows to output
|
|
||||||
"""
|
|
||||||
if not table_element:
|
|
||||||
logging.debug("Table element is None - cannot dump structure")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = table_element.find_all('tr', class_='table-row')
|
|
||||||
logging.debug(f"Found {len(rows)} rows in table")
|
|
||||||
|
|
||||||
# Dump header row if present
|
|
||||||
header_row = table_element.find_parent('table').find('thead')
|
|
||||||
if header_row:
|
|
||||||
header_cells = header_row.find_all('th')
|
|
||||||
header_texts = [cell.get_text(strip=True) for cell in header_cells]
|
|
||||||
logging.debug(f"Header: {header_texts}")
|
|
||||||
|
|
||||||
# Dump a sample of the data rows
|
|
||||||
for i, row in enumerate(rows[:max_rows]):
|
|
||||||
cells = row.find_all('td', class_='table-cell')
|
|
||||||
cell_texts = [cell.get_text(strip=True) for cell in cells]
|
|
||||||
logging.debug(f"Row {i}: {cell_texts}")
|
|
||||||
|
|
||||||
# Also look at raw HTML for problematic cells
|
|
||||||
for j, cell in enumerate(cells):
|
|
||||||
logging.debug(f"Row {i}, Cell {j} HTML: {cell}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error dumping table structure: {e}")
|
|
||||||
|
|
||||||
def fetch_url(self, url: str, timeout: int = 5):
|
|
||||||
"""
|
|
||||||
Fetch URL with error handling.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): URL to fetch
|
|
||||||
timeout (int): Timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: Request response or None if failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self.session.get(url, timeout=timeout)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error fetching {url}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_bitcoin_stats(self):
|
|
||||||
"""
|
|
||||||
Fetch Bitcoin network statistics with improved error handling and caching.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (difficulty, network_hashrate, btc_price, block_count)
|
|
||||||
"""
|
|
||||||
urls = {
|
|
||||||
"difficulty": "https://blockchain.info/q/getdifficulty",
|
|
||||||
"hashrate": "https://blockchain.info/q/hashrate",
|
|
||||||
"ticker": "https://blockchain.info/ticker",
|
|
||||||
"blockcount": "https://blockchain.info/q/getblockcount"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use previous cached values as defaults if available
|
|
||||||
difficulty = self.cache.get("difficulty")
|
|
||||||
network_hashrate = self.cache.get("network_hashrate")
|
|
||||||
btc_price = self.cache.get("btc_price")
|
|
||||||
block_count = self.cache.get("block_count")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
||||||
futures = {key: executor.submit(self.fetch_url, url) for key, url in urls.items()}
|
|
||||||
responses = {key: futures[key].result(timeout=5) for key in futures}
|
|
||||||
|
|
||||||
# Process each response individually with error handling
|
|
||||||
if responses["difficulty"] and responses["difficulty"].ok:
|
|
||||||
try:
|
|
||||||
difficulty = float(responses["difficulty"].text)
|
|
||||||
self.cache["difficulty"] = difficulty
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logging.error(f"Error parsing difficulty: {e}")
|
|
||||||
|
|
||||||
if responses["hashrate"] and responses["hashrate"].ok:
|
|
||||||
try:
|
|
||||||
network_hashrate = float(responses["hashrate"].text) * 1e9
|
|
||||||
self.cache["network_hashrate"] = network_hashrate
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logging.error(f"Error parsing network hashrate: {e}")
|
|
||||||
|
|
||||||
if responses["ticker"] and responses["ticker"].ok:
|
|
||||||
try:
|
|
||||||
ticker_data = responses["ticker"].json()
|
|
||||||
btc_price = float(ticker_data.get("USD", {}).get("last", btc_price))
|
|
||||||
self.cache["btc_price"] = btc_price
|
|
||||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
|
||||||
logging.error(f"Error parsing BTC price: {e}")
|
|
||||||
|
|
||||||
if responses["blockcount"] and responses["blockcount"].ok:
|
|
||||||
try:
|
|
||||||
block_count = int(responses["blockcount"].text)
|
|
||||||
self.cache["block_count"] = block_count
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logging.error(f"Error parsing block count: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error fetching Bitcoin stats: {e}")
|
|
||||||
|
|
||||||
return difficulty, network_hashrate, btc_price, block_count
|
|
||||||
|
|
||||||
def get_all_worker_rows(self):
|
|
||||||
"""
|
|
||||||
Iterate through wpage parameter values to collect all worker table rows.
|
|
||||||
Limited to 10 pages to balance between showing enough workers and maintaining performance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: A list of BeautifulSoup row elements containing worker data.
|
|
||||||
"""
|
|
||||||
all_rows = []
|
|
||||||
page_num = 0
|
|
||||||
max_pages = 10 # Limit to 10 pages of worker data
|
|
||||||
|
|
||||||
while page_num < max_pages: # Only fetch up to max_pages
|
|
||||||
url = f"https://ocean.xyz/stats/{self.wallet}?wpage={page_num}#workers-fulltable"
|
|
||||||
logging.info(f"Fetching worker data from: {url} (page {page_num+1} of max {max_pages})")
|
|
||||||
response = self.session.get(url, timeout=15)
|
|
||||||
if not response.ok:
|
|
||||||
logging.error(f"Error fetching page {page_num}: status code {response.status_code}")
|
|
||||||
break
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
workers_table = soup.find('tbody', id='workers-tablerows')
|
|
||||||
if not workers_table:
|
|
||||||
logging.debug(f"No workers table found on page {page_num}")
|
|
||||||
break
|
|
||||||
|
|
||||||
rows = workers_table.find_all("tr", class_="table-row")
|
|
||||||
if not rows:
|
|
||||||
logging.debug(f"No worker rows found on page {page_num}, stopping pagination")
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info(f"Found {len(rows)} worker rows on page {page_num}")
|
|
||||||
all_rows.extend(rows)
|
|
||||||
page_num += 1
|
|
||||||
|
|
||||||
if page_num >= max_pages:
|
|
||||||
logging.info(f"Reached maximum page limit ({max_pages}). Collected {len(all_rows)} worker rows total.")
|
|
||||||
else:
|
|
||||||
logging.info(f"Completed fetching all available worker data. Collected {len(all_rows)} worker rows from {page_num} pages.")
|
|
||||||
|
|
||||||
return all_rows
|
|
||||||
|
|
||||||
def get_worker_data(self):
|
|
||||||
"""
|
|
||||||
Get worker data from Ocean.xyz using multiple parsing strategies.
|
|
||||||
Tries different approaches to handle changes in the website structure.
|
|
||||||
Validates worker names to ensure they're not status indicators.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Worker data dictionary with stats and list of workers
|
|
||||||
"""
|
|
||||||
logging.info("Attempting to get worker data from Ocean.xyz")
|
|
||||||
|
|
||||||
# First try the alternative method as it's more robust
|
|
||||||
result = self.get_worker_data_alternative()
|
|
||||||
|
|
||||||
# Check if alternative method succeeded and found workers with valid names
|
|
||||||
if result and result.get('workers') and len(result['workers']) > 0:
|
|
||||||
# Validate workers - check for invalid names
|
|
||||||
has_valid_workers = False
|
|
||||||
for worker in result['workers']:
|
|
||||||
name = worker.get('name', '').lower()
|
|
||||||
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
|
|
||||||
has_valid_workers = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if has_valid_workers:
|
|
||||||
logging.info(f"Alternative worker data method successful: {len(result['workers'])} workers with valid names")
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
logging.warning("Alternative method found workers but with invalid names")
|
|
||||||
|
|
||||||
# If alternative method failed or found workers with invalid names, try the original method
|
|
||||||
logging.info("Trying original worker data method")
|
|
||||||
result = self.get_worker_data_original()
|
|
||||||
|
|
||||||
# Check if original method succeeded and found workers with valid names
|
|
||||||
if result and result.get('workers') and len(result['workers']) > 0:
|
|
||||||
# Validate workers - check for invalid names
|
|
||||||
has_valid_workers = False
|
|
||||||
for worker in result['workers']:
|
|
||||||
name = worker.get('name', '').lower()
|
|
||||||
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
|
|
||||||
has_valid_workers = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if has_valid_workers:
|
|
||||||
logging.info(f"Original worker data method successful: {len(result['workers'])} workers with valid names")
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
logging.warning("Original method found workers but with invalid names")
|
|
||||||
|
|
||||||
# If both methods failed or found workers with invalid names, use fallback data
|
|
||||||
logging.warning("Both worker data fetch methods failed to get valid names, using fallback data")
|
|
||||||
|
|
||||||
# Try to get worker count from cached metrics
|
|
||||||
workers_count = 0
|
|
||||||
if hasattr(self, 'cached_metrics') and self.cached_metrics:
|
|
||||||
workers_count = self.cached_metrics.get('workers_hashing', 0)
|
|
||||||
|
|
||||||
# If no cached metrics, try to get from somewhere else
|
|
||||||
if workers_count <= 0 and result and result.get('workers_total'):
|
|
||||||
workers_count = result.get('workers_total')
|
|
||||||
|
|
||||||
# Ensure we have at least 1 worker
|
|
||||||
workers_count = max(1, workers_count)
|
|
||||||
|
|
||||||
logging.info(f"Using fallback data generation with {workers_count} workers")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Rename the original method to get_worker_data_original
|
|
||||||
def get_worker_data_original(self):
|
|
||||||
"""
|
|
||||||
Original implementation to get worker data from Ocean.xyz.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Worker data dictionary with stats and list of workers
|
|
||||||
"""
|
|
||||||
base_url = "https://ocean.xyz"
|
|
||||||
stats_url = f"{base_url}/stats/{self.wallet}"
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0',
|
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
||||||
'Cache-Control': 'no-cache'
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info(f"Fetching worker data from {stats_url}")
|
|
||||||
response = self.session.get(stats_url, headers=headers, timeout=15)
|
|
||||||
if not response.ok:
|
|
||||||
logging.error(f"Error fetching ocean worker data: status code {response.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
|
|
||||||
# Parse worker data from the workers table
|
|
||||||
workers = []
|
|
||||||
total_hashrate = 0
|
|
||||||
total_earnings = 0
|
|
||||||
|
|
||||||
workers_table = soup.find('tbody', id='workers-tablerows')
|
|
||||||
if not workers_table:
|
|
||||||
logging.error("Workers table not found in Ocean.xyz page")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Debug: Dump table structure to help diagnose parsing issues
|
|
||||||
self.debug_dump_table(workers_table)
|
|
||||||
|
|
||||||
# Find total worker counts
|
|
||||||
workers_online = 0
|
|
||||||
workers_offline = 0
|
|
||||||
|
|
||||||
# Iterate through worker rows in the table
|
|
||||||
for row in workers_table.find_all('tr', class_='table-row'):
|
|
||||||
cells = row.find_all('td', class_='table-cell')
|
|
||||||
|
|
||||||
# Skip rows that don't have enough cells for basic info
|
|
||||||
if len(cells) < 3:
|
|
||||||
logging.warning(f"Worker row has too few cells: {len(cells)}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract worker name from the first cell
|
|
||||||
name_cell = cells[0]
|
|
||||||
name_text = name_cell.get_text(strip=True)
|
|
||||||
|
|
||||||
# Skip the total row
|
|
||||||
if name_text.lower() == 'total':
|
|
||||||
logging.debug("Skipping total row")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logging.debug(f"Processing worker: {name_text}")
|
|
||||||
|
|
||||||
# Create worker object with safer extraction
|
|
||||||
worker = {
|
|
||||||
"name": name_text.strip(),
|
|
||||||
"status": "offline", # Default to offline
|
|
||||||
"type": "ASIC", # Default type
|
|
||||||
"model": "Unknown",
|
|
||||||
"hashrate_60sec": 0,
|
|
||||||
"hashrate_60sec_unit": "TH/s",
|
|
||||||
"hashrate_3hr": 0,
|
|
||||||
"hashrate_3hr_unit": "TH/s",
|
|
||||||
"efficiency": 90.0, # Default efficiency
|
|
||||||
"last_share": "N/A",
|
|
||||||
"earnings": 0,
|
|
||||||
"power_consumption": 0,
|
|
||||||
"temperature": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse status from second cell if available
|
|
||||||
if len(cells) > 1:
|
|
||||||
status_cell = cells[1]
|
|
||||||
status_text = status_cell.get_text(strip=True).lower()
|
|
||||||
worker["status"] = "online" if "online" in status_text else "offline"
|
|
||||||
|
|
||||||
# Update counter based on status
|
|
||||||
if worker["status"] == "online":
|
|
||||||
workers_online += 1
|
|
||||||
else:
|
|
||||||
workers_offline += 1
|
|
||||||
|
|
||||||
# Parse last share time
|
|
||||||
if len(cells) > 2:
|
|
||||||
last_share_cell = cells[2]
|
|
||||||
worker["last_share"] = last_share_cell.get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse 60sec hashrate if available
|
|
||||||
if len(cells) > 3:
|
|
||||||
hashrate_60s_cell = cells[3]
|
|
||||||
hashrate_60s_text = hashrate_60s_cell.get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse hashrate_60sec and unit with more robust handling
|
|
||||||
try:
|
|
||||||
parts = hashrate_60s_text.split()
|
|
||||||
if parts and len(parts) > 0:
|
|
||||||
# First part should be the number
|
|
||||||
try:
|
|
||||||
numeric_value = float(parts[0])
|
|
||||||
worker["hashrate_60sec"] = numeric_value
|
|
||||||
|
|
||||||
# Second part should be the unit if it exists
|
|
||||||
if len(parts) > 1 and 'btc' not in parts[1].lower():
|
|
||||||
worker["hashrate_60sec_unit"] = parts[1]
|
|
||||||
except ValueError:
|
|
||||||
# If we can't convert to float, it might be a non-numeric value
|
|
||||||
logging.warning(f"Could not parse 60s hashrate value: {parts[0]}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing 60s hashrate '{hashrate_60s_text}': {e}")
|
|
||||||
|
|
||||||
# Parse 3hr hashrate if available
|
|
||||||
if len(cells) > 4:
|
|
||||||
hashrate_3hr_cell = cells[4]
|
|
||||||
hashrate_3hr_text = hashrate_3hr_cell.get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse hashrate_3hr and unit with more robust handling
|
|
||||||
try:
|
|
||||||
parts = hashrate_3hr_text.split()
|
|
||||||
if parts and len(parts) > 0:
|
|
||||||
# First part should be the number
|
|
||||||
try:
|
|
||||||
numeric_value = float(parts[0])
|
|
||||||
worker["hashrate_3hr"] = numeric_value
|
|
||||||
|
|
||||||
# Second part should be the unit if it exists
|
|
||||||
if len(parts) > 1 and 'btc' not in parts[1].lower():
|
|
||||||
worker["hashrate_3hr_unit"] = parts[1]
|
|
||||||
|
|
||||||
# Add to total hashrate (normalized to TH/s for consistency)
|
|
||||||
total_hashrate += convert_to_ths(worker["hashrate_3hr"], worker["hashrate_3hr_unit"])
|
|
||||||
except ValueError:
|
|
||||||
# If we can't convert to float, it might be a non-numeric value
|
|
||||||
logging.warning(f"Could not parse 3hr hashrate value: {parts[0]}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing 3hr hashrate '{hashrate_3hr_text}': {e}")
|
|
||||||
|
|
||||||
# Parse earnings if available
|
|
||||||
if len(cells) > 5:
|
|
||||||
earnings_cell = cells[5]
|
|
||||||
earnings_text = earnings_cell.get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse earnings with more robust handling
|
|
||||||
try:
|
|
||||||
# Remove BTC or other text, keep only the number
|
|
||||||
earnings_value = earnings_text.replace('BTC', '').strip()
|
|
||||||
try:
|
|
||||||
worker["earnings"] = float(earnings_value)
|
|
||||||
total_earnings += worker["earnings"]
|
|
||||||
except ValueError:
|
|
||||||
logging.warning(f"Could not parse earnings value: {earnings_value}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing earnings '{earnings_text}': {e}")
|
|
||||||
|
|
||||||
# Set worker type based on name (if it can be inferred)
|
|
||||||
lower_name = worker["name"].lower()
|
|
||||||
if 'antminer' in lower_name:
|
|
||||||
worker["type"] = 'ASIC'
|
|
||||||
worker["model"] = 'Bitmain Antminer'
|
|
||||||
elif 'whatsminer' in lower_name:
|
|
||||||
worker["type"] = 'ASIC'
|
|
||||||
worker["model"] = 'MicroBT Whatsminer'
|
|
||||||
elif 'bitaxe' in lower_name or 'nerdqaxe' in lower_name:
|
|
||||||
worker["type"] = 'Bitaxe'
|
|
||||||
worker["model"] = 'BitAxe Gamma 601'
|
|
||||||
|
|
||||||
workers.append(worker)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing worker row: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get daily sats from the ocean data
|
|
||||||
daily_sats = 0
|
|
||||||
try:
|
|
||||||
# Try to get this from the payoutsnap card
|
|
||||||
payout_snap = soup.find('div', id='payoutsnap-statcards')
|
|
||||||
if payout_snap:
|
|
||||||
for container in payout_snap.find_all('div', class_='blocks dashboard-container'):
|
|
||||||
label_div = container.find('div', class_='blocks-label')
|
|
||||||
if label_div and "earnings per day" in label_div.get_text(strip=True).lower():
|
|
||||||
value_span = label_div.find_next('span')
|
|
||||||
if value_span:
|
|
||||||
value_text = value_span.get_text(strip=True)
|
|
||||||
try:
|
|
||||||
btc_per_day = float(value_text.split()[0])
|
|
||||||
daily_sats = int(btc_per_day * self.sats_per_btc)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing daily sats: {e}")
|
|
||||||
|
|
||||||
# Check if we found any workers
|
|
||||||
if not workers:
|
|
||||||
logging.warning("No workers found in the table, possibly a parsing issue")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Return worker stats dictionary
|
|
||||||
result = {
|
|
||||||
'workers': workers,
|
|
||||||
'total_hashrate': total_hashrate,
|
|
||||||
'hashrate_unit': 'TH/s', # Always use TH/s for consistent display
|
|
||||||
'workers_total': len(workers),
|
|
||||||
'workers_online': workers_online,
|
|
||||||
'workers_offline': workers_offline,
|
|
||||||
'total_earnings': total_earnings,
|
|
||||||
'daily_sats': daily_sats,
|
|
||||||
'timestamp': datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"Successfully retrieved worker data: {len(workers)} workers")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error fetching Ocean worker data: {e}")
|
|
||||||
import traceback
|
|
||||||
logging.error(traceback.format_exc())
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_worker_data_alternative(self):
|
|
||||||
"""
|
|
||||||
Alternative implementation to get worker data from Ocean.xyz.
|
|
||||||
This version consolidates worker rows from all pages using the wpage parameter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Worker data dictionary with stats and list of workers.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logging.info("Fetching worker data across multiple pages (alternative method)")
|
|
||||||
# Get all worker rows from every page
|
|
||||||
rows = self.get_all_worker_rows()
|
|
||||||
if not rows:
|
|
||||||
logging.error("No worker rows found across any pages")
|
|
||||||
return None
|
|
||||||
|
|
||||||
workers = []
|
|
||||||
total_hashrate = 0
|
|
||||||
total_earnings = 0
|
|
||||||
workers_online = 0
|
|
||||||
workers_offline = 0
|
|
||||||
invalid_names = ['online', 'offline', 'status', 'worker', 'total']
|
|
||||||
|
|
||||||
# Process each row from all pages
|
|
||||||
for row_idx, row in enumerate(rows):
|
|
||||||
cells = row.find_all(['td', 'th'])
|
|
||||||
if not cells or len(cells) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
first_cell_text = cells[0].get_text(strip=True)
|
|
||||||
if first_cell_text.lower() in invalid_names:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
worker_name = first_cell_text or f"Worker_{row_idx+1}"
|
|
||||||
worker = {
|
|
||||||
"name": worker_name,
|
|
||||||
"status": "online", # Default assumption
|
|
||||||
"type": "ASIC",
|
|
||||||
"model": "Unknown",
|
|
||||||
"hashrate_60sec": 0,
|
|
||||||
"hashrate_60sec_unit": "TH/s",
|
|
||||||
"hashrate_3hr": 0,
|
|
||||||
"hashrate_3hr_unit": "TH/s",
|
|
||||||
"efficiency": 90.0,
|
|
||||||
"last_share": "N/A",
|
|
||||||
"earnings": 0,
|
|
||||||
"power_consumption": 0,
|
|
||||||
"temperature": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract status from second cell if available
|
|
||||||
if len(cells) > 1:
|
|
||||||
status_text = cells[1].get_text(strip=True).lower()
|
|
||||||
worker["status"] = "online" if "online" in status_text else "offline"
|
|
||||||
if worker["status"] == "online":
|
|
||||||
workers_online += 1
|
|
||||||
else:
|
|
||||||
workers_offline += 1
|
|
||||||
|
|
||||||
# Parse last share from third cell if available
|
|
||||||
if len(cells) > 2:
|
|
||||||
worker["last_share"] = cells[2].get_text(strip=True)
|
|
||||||
|
|
||||||
# Parse 60sec hashrate from fourth cell if available
|
|
||||||
if len(cells) > 3:
|
|
||||||
hashrate_60s_text = cells[3].get_text(strip=True)
|
|
||||||
try:
|
|
||||||
parts = hashrate_60s_text.split()
|
|
||||||
if parts:
|
|
||||||
worker["hashrate_60sec"] = float(parts[0])
|
|
||||||
if len(parts) > 1:
|
|
||||||
worker["hashrate_60sec_unit"] = parts[1]
|
|
||||||
except ValueError:
|
|
||||||
logging.warning(f"Could not parse 60-sec hashrate: {hashrate_60s_text}")
|
|
||||||
|
|
||||||
# Parse 3hr hashrate from fifth cell if available
|
|
||||||
if len(cells) > 4:
|
|
||||||
hashrate_3hr_text = cells[4].get_text(strip=True)
|
|
||||||
try:
|
|
||||||
parts = hashrate_3hr_text.split()
|
|
||||||
if parts:
|
|
||||||
worker["hashrate_3hr"] = float(parts[0])
|
|
||||||
if len(parts) > 1:
|
|
||||||
worker["hashrate_3hr_unit"] = parts[1]
|
|
||||||
# Normalize and add to total hashrate (using your convert_to_ths helper)
|
|
||||||
total_hashrate += convert_to_ths(worker["hashrate_3hr"], worker["hashrate_3hr_unit"])
|
|
||||||
except ValueError:
|
|
||||||
logging.warning(f"Could not parse 3hr hashrate: {hashrate_3hr_text}")
|
|
||||||
|
|
||||||
# Look for earnings in any cell containing 'btc'
|
|
||||||
for cell in cells:
|
|
||||||
cell_text = cell.get_text(strip=True)
|
|
||||||
if "btc" in cell_text.lower():
|
|
||||||
try:
|
|
||||||
earnings_match = re.search(r'([\d\.]+)', cell_text)
|
|
||||||
if earnings_match:
|
|
||||||
worker["earnings"] = float(earnings_match.group(1))
|
|
||||||
total_earnings += worker["earnings"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Set worker type based on name
|
|
||||||
lower_name = worker["name"].lower()
|
|
||||||
if 'antminer' in lower_name:
|
|
||||||
worker["type"] = 'ASIC'
|
|
||||||
worker["model"] = 'Bitmain Antminer'
|
|
||||||
elif 'whatsminer' in lower_name:
|
|
||||||
worker["type"] = 'ASIC'
|
|
||||||
worker["model"] = 'MicroBT Whatsminer'
|
|
||||||
elif 'bitaxe' in lower_name or 'nerdqaxe' in lower_name:
|
|
||||||
worker["type"] = 'Bitaxe'
|
|
||||||
worker["model"] = 'BitAxe Gamma 601'
|
|
||||||
|
|
||||||
if worker["name"].lower() not in invalid_names:
|
|
||||||
workers.append(worker)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error parsing worker row: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not workers:
|
|
||||||
logging.error("No valid worker data parsed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'workers': workers,
|
|
||||||
'total_hashrate': total_hashrate,
|
|
||||||
'hashrate_unit': 'TH/s',
|
|
||||||
'workers_total': len(workers),
|
|
||||||
'workers_online': workers_online,
|
|
||||||
'workers_offline': workers_offline,
|
|
||||||
'total_earnings': total_earnings,
|
|
||||||
'timestamp': datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
}
|
|
||||||
logging.info(f"Successfully retrieved {len(workers)} workers across multiple pages")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error in alternative worker data fetch: {e}")
|
|
||||||
return None
|
|
@ -1,375 +0,0 @@
|
|||||||
# Deployment Guide
|
|
||||||
|
|
||||||
This guide provides comprehensive instructions for deploying the Bitcoin Mining Dashboard application in various environments, from development to production.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python 3.9 or higher
|
|
||||||
- Redis server (optional, for persistent state and improved reliability)
|
|
||||||
- Docker and Docker Compose (optional, for containerized deployment)
|
|
||||||
- Network access to Ocean.xyz API endpoints
|
|
||||||
- Modern web browser (Chrome, Firefox, Edge recommended)
|
|
||||||
|
|
||||||
## Installation Options
|
|
||||||
|
|
||||||
### Option 1: Standard Installation (Development)
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Djobleezy/DeepSea-Dashboard.git
|
|
||||||
cd DeepSea-Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create a virtual environment (recommended):
|
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Install dependencies:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Run the setup script to organize files:
|
|
||||||
```bash
|
|
||||||
python setup.py
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the application:
|
|
||||||
```bash
|
|
||||||
python App.py
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Access the dashboard at `http://localhost:5000`
|
|
||||||
|
|
||||||
### Option 2: Production Deployment with Gunicorn
|
|
||||||
|
|
||||||
For better performance and reliability in production environments:
|
|
||||||
|
|
||||||
1. Follow steps 1-5 from standard installation
|
|
||||||
|
|
||||||
2. Install Gunicorn if not already installed:
|
|
||||||
```bash
|
|
||||||
pip install gunicorn
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start with Gunicorn:
|
|
||||||
```bash
|
|
||||||
gunicorn -b 0.0.0.0:5000 App:app --workers=1 --threads=12 --timeout=600 --keep-alive=5
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Important**: Use only 1 worker to maintain shared state. Use threads for concurrency.
|
|
||||||
|
|
||||||
4. For a more robust setup, create a systemd service:
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/systemd/system/mining-dashboard.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the following content:
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=Bitcoin Mining Dashboard
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=your_username
|
|
||||||
WorkingDirectory=/path/to/bitcoin-mining-dashboard
|
|
||||||
ExecStart=/path/to/venv/bin/gunicorn -b 0.0.0.0:5000 App:app --workers=1 --threads=12 --timeout=600 --keep-alive=5
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Enable and start the service:
|
|
||||||
```bash
|
|
||||||
sudo systemctl enable mining-dashboard
|
|
||||||
sudo systemctl start mining-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: Docker Deployment
|
|
||||||
|
|
||||||
1. Build the Docker image:
|
|
||||||
```bash
|
|
||||||
docker build -t bitcoin-mining-dashboard .
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Run the container:
|
|
||||||
```bash
|
|
||||||
docker run -d -p 5000:5000 \
|
|
||||||
-e WALLET=your-wallet-address \
|
|
||||||
-e POWER_COST=0.12 \
|
|
||||||
-e POWER_USAGE=3450 \
|
|
||||||
-v $(pwd)/logs:/app/logs \
|
|
||||||
--name mining-dashboard \
|
|
||||||
bitcoin-mining-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Access the dashboard at `http://localhost:5000`
|
|
||||||
|
|
||||||
### Option 4: Docker Compose with Redis Persistence
|
|
||||||
|
|
||||||
1. Create a `docker-compose.yml` file:
|
|
||||||
```yaml
|
|
||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
|
|
||||||
dashboard:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
environment:
|
|
||||||
- REDIS_URL=redis://redis:6379
|
|
||||||
- WALLET=your-wallet-address
|
|
||||||
- POWER_COST=0.12
|
|
||||||
- POWER_USAGE=3450
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Launch the services:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Access the dashboard at `http://localhost:5000`
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
The application can be configured using environment variables:
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `REDIS_URL` | Redis connection URL for persistent state | None |
|
|
||||||
| `WALLET` | Ocean.xyz wallet address | From config.json |
|
|
||||||
| `POWER_COST` | Electricity cost per kWh | From config.json |
|
|
||||||
| `POWER_USAGE` | Power consumption in watts | From config.json |
|
|
||||||
| `FLASK_ENV` | Application environment | development |
|
|
||||||
| `LOG_LEVEL` | Logging level | INFO |
|
|
||||||
| `PORT` | Application port | 5000 |
|
|
||||||
|
|
||||||
## Reverse Proxy Configuration
|
|
||||||
|
|
||||||
For production deployments, it's recommended to use a reverse proxy like Nginx:
|
|
||||||
|
|
||||||
1. Install Nginx:
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create a configuration file:
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/nginx/sites-available/mining-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Add the following configuration:
|
|
||||||
```
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:5000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Create a symbolic link:
|
|
||||||
```bash
|
|
||||||
sudo ln -s /etc/nginx/sites-available/mining-dashboard /etc/nginx/sites-enabled/
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Test and restart Nginx:
|
|
||||||
```bash
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
6. (Optional) Add SSL with Certbot:
|
|
||||||
```bash
|
|
||||||
sudo apt install certbot python3-certbot-nginx
|
|
||||||
sudo certbot --nginx -d your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
Logs are stored in the `logs` directory by default. Monitor these logs for errors and warnings:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -f logs/dashboard.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Common log patterns to watch for:
|
|
||||||
- `ERROR fetching metrics` - Indicates issues with Ocean.xyz API
|
|
||||||
- `Failed to connect to Redis` - Redis connection problems
|
|
||||||
- `Scheduler stopped unexpectedly` - Background job issues
|
|
||||||
|
|
||||||
### Health Monitoring
|
|
||||||
|
|
||||||
#### Health Check Endpoint
|
|
||||||
|
|
||||||
A health check endpoint is available at `/api/health` that returns:
|
|
||||||
- Application status (healthy, degraded, unhealthy)
|
|
||||||
- Uptime information
|
|
||||||
- Memory usage
|
|
||||||
- Data freshness
|
|
||||||
- Redis connection status
|
|
||||||
- Scheduler status
|
|
||||||
|
|
||||||
Example health check command:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/api/health | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scheduler Health
|
|
||||||
|
|
||||||
To monitor the scheduler:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/api/scheduler-health | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Tuning
|
|
||||||
|
|
||||||
1. **Redis Configuration**: For high-traffic deployments, tune Redis:
|
|
||||||
```
|
|
||||||
maxmemory 256mb
|
|
||||||
maxmemory-policy allkeys-lru
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Gunicorn Threads**: Adjust thread count based on CPU cores:
|
|
||||||
```
|
|
||||||
--threads=$(( 2 * $(nproc) ))
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Browser Cache Headers**: Already optimized in the application
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Application not updating data**:
|
|
||||||
- Check network connectivity to Ocean.xyz
|
|
||||||
- Verify scheduler health:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/api/scheduler-health
|
|
||||||
```
|
|
||||||
- Force a data refresh:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:5000/api/force-refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **High memory usage**:
|
|
||||||
- Check for memory leaks in log files
|
|
||||||
- Restart the application
|
|
||||||
- Enable Redis for better state management
|
|
||||||
|
|
||||||
3. **Scheduler failures**:
|
|
||||||
- Fix the scheduler:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:5000/api/fix-scheduler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Workers not showing**:
|
|
||||||
- Verify your wallet address is correct
|
|
||||||
- Check worker data:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/api/workers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recovery Procedures
|
|
||||||
|
|
||||||
If the application becomes unresponsive:
|
|
||||||
|
|
||||||
1. Check the logs for error messages
|
|
||||||
2. Restart the application:
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart mining-dashboard
|
|
||||||
```
|
|
||||||
3. If Redis is used and may be corrupted:
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart redis
|
|
||||||
```
|
|
||||||
4. For Docker deployments:
|
|
||||||
```bash
|
|
||||||
docker-compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
To update the application:
|
|
||||||
|
|
||||||
1. Pull the latest changes:
|
|
||||||
```bash
|
|
||||||
git pull origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update dependencies:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt --upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the setup script:
|
|
||||||
```bash
|
|
||||||
python setup.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Restart the application:
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart mining-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Update Procedure
|
|
||||||
|
|
||||||
1. Pull the latest changes:
|
|
||||||
```bash
|
|
||||||
git pull origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Rebuild and restart:
|
|
||||||
```bash
|
|
||||||
docker-compose build
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup Strategy
|
|
||||||
|
|
||||||
1. **Configuration**: Regularly backup your `config.json` file
|
|
||||||
2. **Redis Data**: If using Redis, set up regular RDB snapshots
|
|
||||||
3. **Logs**: Implement log rotation and archiving
|
|
||||||
|
|
||||||
## Security Recommendations
|
|
||||||
|
|
||||||
1. **Run as Non-Root User**: Always run the application as a non-root user
|
|
||||||
2. **Firewall Configuration**: Restrict access to ports 5000 and 6379 (Redis)
|
|
||||||
3. **Redis Authentication**: Enable Redis password authentication:
|
|
||||||
```
|
|
||||||
requirepass your_strong_password
|
|
||||||
```
|
|
||||||
4. **HTTPS**: Use SSL/TLS for all production deployments
|
|
||||||
5. **Regular Updates**: Keep all dependencies updated
|
|
@ -1,42 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
dashboard:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
environment:
|
|
||||||
- REDIS_URL=redis://redis:6379
|
|
||||||
- WALLET=35eS5Lsqw8NCjFJ8zhp9JaEmyvLDwg6XtS
|
|
||||||
- POWER_COST=0
|
|
||||||
- POWER_USAGE=0
|
|
||||||
- NETWORK_FEE=0
|
|
||||||
- TIMEZONE=America/Los_Angeles
|
|
||||||
- LOG_LEVEL=INFO
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis_data:
|
|
42
dockerfile
42
dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM python:3.9.18-slim
|
FROM python:3.9-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -8,50 +8,40 @@ RUN apt-get update && \
|
|||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies first to leverage Docker cache
|
# Install dependencies first to leverage Docker cache.
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy the application files
|
# Copy the entire application.
|
||||||
COPY *.py .
|
COPY . .
|
||||||
COPY config.json .
|
|
||||||
COPY setup.py .
|
|
||||||
|
|
||||||
# Create all necessary directories in one command
|
# Run the minifier to process HTML templates.
|
||||||
RUN mkdir -p static/css static/js templates logs /app/logs
|
|
||||||
|
|
||||||
# Copy static files and templates
|
|
||||||
COPY static/css/*.css static/css/
|
|
||||||
COPY static/js/*.js static/js/
|
|
||||||
COPY templates/*.html templates/
|
|
||||||
|
|
||||||
# Run the setup script to ensure proper organization
|
|
||||||
RUN python setup.py
|
|
||||||
|
|
||||||
# Run the minifier to process HTML templates
|
|
||||||
RUN python minify.py
|
RUN python minify.py
|
||||||
|
|
||||||
# Create a non-root user for better security
|
# Create a non-root user first.
|
||||||
RUN adduser --disabled-password --gecos '' appuser
|
RUN adduser --disabled-password --gecos '' appuser
|
||||||
|
|
||||||
# Change ownership of the /app directory so appuser can write files
|
# Change ownership of the /app directory so that appuser can write files.
|
||||||
RUN chown -R appuser:appuser /app
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
# Switch to non-root user
|
# Create a directory for logs with proper permissions
|
||||||
|
RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose the application port
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Set environment variables
|
# Add environment variables for app configuration
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHON_UNBUFFERED=1
|
||||||
|
|
||||||
# Add healthcheck
|
# Improve healthcheck reliability - use new health endpoint
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD curl -f http://localhost:5000/api/health || exit 1
|
CMD curl -f http://localhost:5000/api/health || exit 1
|
||||||
|
|
||||||
# Use Gunicorn as the production WSGI server
|
# Use Gunicorn as the production WSGI server with improved settings
|
||||||
|
# For shared global state, we need to keep the single worker model but optimize other parameters
|
||||||
CMD ["gunicorn", "-b", "0.0.0.0:5000", "App:app", \
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "App:app", \
|
||||||
"--workers=1", \
|
"--workers=1", \
|
||||||
"--threads=12", \
|
"--threads=12", \
|
||||||
@ -62,4 +52,4 @@ CMD ["gunicorn", "-b", "0.0.0.0:5000", "App:app", \
|
|||||||
"--error-logfile=-", \
|
"--error-logfile=-", \
|
||||||
"--log-file=-", \
|
"--log-file=-", \
|
||||||
"--graceful-timeout=60", \
|
"--graceful-timeout=60", \
|
||||||
"--worker-tmp-dir=/dev/shm"]
|
"--worker-tmp-dir=/dev/shm"]
|
161
error.html
Normal file
161
error.html
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Error - Mining Dashboard</title>
|
||||||
|
<!-- Include both Orbitron and VT323 fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #0a0a0a;
|
||||||
|
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
|
||||||
|
--primary-color: #f7931a;
|
||||||
|
--text-color: white;
|
||||||
|
--terminal-font: 'VT323', monospace;
|
||||||
|
--header-font: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT Screen Effect */
|
||||||
|
body::before {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0; right: 0;
|
||||||
|
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
||||||
|
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
||||||
|
background-size: 100% 2px, 3px 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flicker Animation */
|
||||||
|
@keyframes flicker {
|
||||||
|
0% { opacity: 0.97; }
|
||||||
|
5% { opacity: 0.95; }
|
||||||
|
10% { opacity: 0.97; }
|
||||||
|
15% { opacity: 0.94; }
|
||||||
|
20% { opacity: 0.98; }
|
||||||
|
50% { opacity: 0.95; }
|
||||||
|
80% { opacity: 0.96; }
|
||||||
|
90% { opacity: 0.94; }
|
||||||
|
100% { opacity: 0.98; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding-top: 50px;
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: black;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: var(--header-font);
|
||||||
|
text-shadow: none;
|
||||||
|
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn-primary:hover {
|
||||||
|
background-color: #ffa64d;
|
||||||
|
box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced error container with scanlines */
|
||||||
|
.error-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: flicker 4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanline effect for error container */
|
||||||
|
.error-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1),
|
||||||
|
rgba(0, 0, 0, 0.1) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 2px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-family: var(--header-font);
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 10px var(--primary-color);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
color: #ff5555;
|
||||||
|
text-shadow: 0 0 8px rgba(255, 85, 85, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor blink for terminal feel */
|
||||||
|
.terminal-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #f7931a;
|
||||||
|
margin-left: 2px;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error code styling */
|
||||||
|
.error-code {
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #00dfff;
|
||||||
|
text-shadow: 0 0 10px #00dfff, 0 0 20px #00dfff;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="error-container">
|
||||||
|
<h1>ERROR</h1>
|
||||||
|
<div class="error-code">CODE: SYS_EXCEPTION_0x45</div>
|
||||||
|
<p>{{ message }}<span class="terminal-cursor"></span></p>
|
||||||
|
<a href="/" class="btn btn-primary">Return to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
1071
index.html
Normal file
1071
index.html
Normal file
File diff suppressed because it is too large
Load Diff
801
main.js
Normal file
801
main.js
Normal file
@ -0,0 +1,801 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Global variables
|
||||||
|
let previousMetrics = {};
|
||||||
|
let persistentArrows = {};
|
||||||
|
let serverTimeOffset = 0;
|
||||||
|
let serverStartTime = null;
|
||||||
|
let latestMetrics = null;
|
||||||
|
let initialLoad = true;
|
||||||
|
let trendData = [];
|
||||||
|
let trendLabels = [];
|
||||||
|
let trendChart = null;
|
||||||
|
let connectionRetryCount = 0;
|
||||||
|
let maxRetryCount = 10;
|
||||||
|
let reconnectionDelay = 1000; // Start with 1 second
|
||||||
|
let pingInterval = null;
|
||||||
|
let lastPingTime = Date.now();
|
||||||
|
let connectionLostTimeout = null;
|
||||||
|
|
||||||
|
// Bitcoin-themed progress bar functionality
|
||||||
|
let progressInterval;
|
||||||
|
let currentProgress = 0;
|
||||||
|
let lastUpdateTime = Date.now();
|
||||||
|
let expectedUpdateInterval = 60000; // Expected server update interval (60 seconds)
|
||||||
|
const PROGRESS_MAX = 60; // 60 seconds for a complete cycle
|
||||||
|
|
||||||
|
// Initialize the progress bar and start the animation
|
||||||
|
function initProgressBar() {
|
||||||
|
// Clear any existing interval
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set last update time to now
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
// Reset progress with initial offset
|
||||||
|
currentProgress = 1; // Start at 1 instead of 0 for offset
|
||||||
|
updateProgressBar(currentProgress);
|
||||||
|
|
||||||
|
// Start the interval
|
||||||
|
progressInterval = setInterval(function() {
|
||||||
|
// Calculate elapsed time since last update
|
||||||
|
const elapsedTime = Date.now() - lastUpdateTime;
|
||||||
|
|
||||||
|
// Calculate progress percentage based on elapsed time with +1 second offset
|
||||||
|
const secondsElapsed = Math.floor(elapsedTime / 1000) + 1; // Add 1 second offset
|
||||||
|
|
||||||
|
// If we've gone past the expected update time
|
||||||
|
if (secondsElapsed >= PROGRESS_MAX) {
|
||||||
|
// Keep the progress bar full but show waiting state
|
||||||
|
currentProgress = PROGRESS_MAX;
|
||||||
|
} else {
|
||||||
|
// Normal progress with offset
|
||||||
|
currentProgress = secondsElapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressBar(currentProgress);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the progress bar display
|
||||||
|
function updateProgressBar(seconds) {
|
||||||
|
const progressPercent = (seconds / PROGRESS_MAX) * 100;
|
||||||
|
$("#bitcoin-progress-inner").css("width", progressPercent + "%");
|
||||||
|
|
||||||
|
// Add glowing effect when close to completion
|
||||||
|
if (progressPercent > 80) {
|
||||||
|
$("#bitcoin-progress-inner").addClass("glow-effect");
|
||||||
|
} else {
|
||||||
|
$("#bitcoin-progress-inner").removeClass("glow-effect");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update remaining seconds text - more precise calculation
|
||||||
|
let remainingSeconds = PROGRESS_MAX - seconds;
|
||||||
|
|
||||||
|
// When we're past the expected time, show "Waiting for update..."
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
$("#progress-text").text("Waiting for update...");
|
||||||
|
$("#bitcoin-progress-inner").addClass("waiting-for-update");
|
||||||
|
} else {
|
||||||
|
$("#progress-text").text(remainingSeconds + "s to next update");
|
||||||
|
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Chart.js annotation plugin if available
|
||||||
|
if (window['chartjs-plugin-annotation']) {
|
||||||
|
Chart.register(window['chartjs-plugin-annotation']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE Connection with Error Handling and Reconnection Logic
|
||||||
|
function setupEventSource() {
|
||||||
|
console.log("Setting up EventSource connection...");
|
||||||
|
|
||||||
|
if (window.eventSource) {
|
||||||
|
console.log("Closing existing EventSource connection");
|
||||||
|
window.eventSource.close();
|
||||||
|
window.eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use absolute URL with origin to ensure it works from any path
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const streamUrl = `${baseUrl}/stream`;
|
||||||
|
|
||||||
|
console.log("Current path:", window.location.pathname);
|
||||||
|
console.log("Using stream URL:", streamUrl);
|
||||||
|
|
||||||
|
// Clear any existing ping interval
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any connection lost timeout
|
||||||
|
if (connectionLostTimeout) {
|
||||||
|
clearTimeout(connectionLostTimeout);
|
||||||
|
connectionLostTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
|
eventSource.onopen = function(e) {
|
||||||
|
console.log("EventSource connection opened successfully");
|
||||||
|
connectionRetryCount = 0; // Reset retry count on successful connection
|
||||||
|
reconnectionDelay = 1000; // Reset reconnection delay
|
||||||
|
hideConnectionIssue();
|
||||||
|
|
||||||
|
// Start ping interval to detect dead connections
|
||||||
|
lastPingTime = Date.now();
|
||||||
|
pingInterval = setInterval(function() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPingTime > 60000) { // 60 seconds without data
|
||||||
|
console.warn("No data received for 60 seconds, reconnecting...");
|
||||||
|
showConnectionIssue("Connection stalled");
|
||||||
|
eventSource.close();
|
||||||
|
setupEventSource();
|
||||||
|
}
|
||||||
|
}, 10000); // Check every 10 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = function(e) {
|
||||||
|
console.log("SSE message received");
|
||||||
|
lastPingTime = Date.now(); // Update ping time on any message
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
if (data.type === "ping") {
|
||||||
|
console.log("Ping received:", data);
|
||||||
|
// Update connection count if available
|
||||||
|
if (data.connections !== undefined) {
|
||||||
|
console.log(`Active connections: ${data.connections}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "timeout_warning") {
|
||||||
|
console.log(`Connection timeout warning: ${data.remaining}s remaining`);
|
||||||
|
// If less than 30 seconds remaining, prepare for reconnection
|
||||||
|
if (data.remaining < 30) {
|
||||||
|
console.log("Preparing for reconnection due to upcoming timeout");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "timeout") {
|
||||||
|
console.log("Connection timeout from server:", data.message);
|
||||||
|
eventSource.close();
|
||||||
|
// If reconnect flag is true, reconnect immediately
|
||||||
|
if (data.reconnect) {
|
||||||
|
console.log("Server requested reconnection");
|
||||||
|
setTimeout(setupEventSource, 500);
|
||||||
|
} else {
|
||||||
|
setupEventSource();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error("Server reported error:", data.error);
|
||||||
|
showConnectionIssue(data.error);
|
||||||
|
|
||||||
|
// If retry time provided, use it, otherwise use default
|
||||||
|
const retryTime = data.retry || 5000;
|
||||||
|
setTimeout(function() {
|
||||||
|
manualRefresh();
|
||||||
|
}, retryTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process regular data update
|
||||||
|
latestMetrics = data;
|
||||||
|
updateUI();
|
||||||
|
hideConnectionIssue();
|
||||||
|
|
||||||
|
// Also explicitly trigger a data refresh event
|
||||||
|
$(document).trigger('dataRefreshed');
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error processing SSE data:", err);
|
||||||
|
showConnectionIssue("Data processing error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = function(e) {
|
||||||
|
console.error("SSE connection error", e);
|
||||||
|
showConnectionIssue("Connection lost");
|
||||||
|
|
||||||
|
eventSource.close();
|
||||||
|
|
||||||
|
// Implement exponential backoff for reconnection
|
||||||
|
connectionRetryCount++;
|
||||||
|
|
||||||
|
if (connectionRetryCount > maxRetryCount) {
|
||||||
|
console.log("Maximum retry attempts reached, switching to polling mode");
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to regular polling
|
||||||
|
showConnectionIssue("Using polling mode");
|
||||||
|
setInterval(manualRefresh, 30000); // Poll every 30 seconds
|
||||||
|
manualRefresh(); // Do an immediate refresh
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff with jitter
|
||||||
|
const jitter = Math.random() * 0.3 + 0.85; // 0.85-1.15
|
||||||
|
reconnectionDelay = Math.min(30000, reconnectionDelay * 1.5 * jitter);
|
||||||
|
|
||||||
|
console.log(`Reconnecting in ${(reconnectionDelay/1000).toFixed(1)} seconds... (attempt ${connectionRetryCount}/${maxRetryCount})`);
|
||||||
|
setTimeout(setupEventSource, reconnectionDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.eventSource = eventSource;
|
||||||
|
console.log("EventSource setup complete");
|
||||||
|
|
||||||
|
// Set a timeout to detect if connection is established
|
||||||
|
connectionLostTimeout = setTimeout(function() {
|
||||||
|
if (eventSource.readyState !== 1) { // 1 = OPEN
|
||||||
|
console.warn("Connection not established within timeout, switching to manual refresh");
|
||||||
|
showConnectionIssue("Connection timeout");
|
||||||
|
eventSource.close();
|
||||||
|
manualRefresh();
|
||||||
|
}
|
||||||
|
}, 10000); // 10 seconds timeout to establish connection
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create EventSource:", error);
|
||||||
|
showConnectionIssue("Connection setup failed");
|
||||||
|
setTimeout(setupEventSource, 5000); // Try again in 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page visibility change listener
|
||||||
|
// This helps reconnect when user returns to the tab after it's been inactive
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle page visibility changes
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (!document.hidden) {
|
||||||
|
console.log("Page became visible, checking connection");
|
||||||
|
if (!window.eventSource || window.eventSource.readyState !== 1) {
|
||||||
|
console.log("Connection not active, reestablishing");
|
||||||
|
setupEventSource();
|
||||||
|
}
|
||||||
|
manualRefresh(); // Always refresh data when page becomes visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show connection issues to the user
|
||||||
|
function showConnectionIssue(message) {
|
||||||
|
let $connectionStatus = $("#connectionStatus");
|
||||||
|
if (!$connectionStatus.length) {
|
||||||
|
$("body").append('<div id="connectionStatus" style="position: fixed; top: 10px; right: 10px; background: rgba(255,0,0,0.7); color: white; padding: 10px; border-radius: 5px; z-index: 9999;"></div>');
|
||||||
|
$connectionStatus = $("#connectionStatus");
|
||||||
|
}
|
||||||
|
$connectionStatus.html(`<i class="fas fa-exclamation-triangle"></i> ${message}`).show();
|
||||||
|
|
||||||
|
// Show manual refresh button when there are connection issues
|
||||||
|
$("#refreshButton").show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to hide connection issue message
|
||||||
|
function hideConnectionIssue() {
|
||||||
|
$("#connectionStatus").hide();
|
||||||
|
$("#refreshButton").hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved manual refresh function as fallback
|
||||||
|
function manualRefresh() {
|
||||||
|
console.log("Manually refreshing data...");
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/metrics',
|
||||||
|
method: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 15000, // 15 second timeout
|
||||||
|
success: function(data) {
|
||||||
|
console.log("Manual refresh successful");
|
||||||
|
lastPingTime = Date.now(); // Update ping time
|
||||||
|
latestMetrics = data;
|
||||||
|
updateUI();
|
||||||
|
hideConnectionIssue();
|
||||||
|
|
||||||
|
// Explicitly trigger data refresh event
|
||||||
|
$(document).trigger('dataRefreshed');
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error("Manual refresh failed:", error);
|
||||||
|
showConnectionIssue("Manual refresh failed");
|
||||||
|
|
||||||
|
// Try again with exponential backoff
|
||||||
|
const retryDelay = Math.min(30000, 1000 * Math.pow(1.5, Math.min(5, connectionRetryCount)));
|
||||||
|
connectionRetryCount++;
|
||||||
|
setTimeout(manualRefresh, retryDelay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Chart.js with Error Handling
|
||||||
|
function initializeChart() {
|
||||||
|
try {
|
||||||
|
const ctx = document.getElementById('trendGraph').getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
console.error("Could not find trend graph canvas");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.Chart) {
|
||||||
|
console.error("Chart.js not loaded");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Chart.js plugin is available
|
||||||
|
const hasAnnotationPlugin = window['chartjs-plugin-annotation'] !== undefined;
|
||||||
|
|
||||||
|
return new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: '60s Hashrate Trend (TH/s)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#f7931a',
|
||||||
|
backgroundColor: 'rgba(247,147,26,0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.2,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 0 // Disable animations for better performance
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: {
|
||||||
|
ticks: { color: 'white' },
|
||||||
|
grid: { color: '#333' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
annotation: hasAnnotationPlugin ? {
|
||||||
|
annotations: {
|
||||||
|
averageLine: {
|
||||||
|
type: 'line',
|
||||||
|
yMin: 0,
|
||||||
|
yMax: 0,
|
||||||
|
borderColor: '#f7931a',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [6, 6],
|
||||||
|
label: {
|
||||||
|
enabled: true,
|
||||||
|
content: '24hr Avg: 0 TH/s',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
color: '#f7931a',
|
||||||
|
font: { weight: 'bold', size: 13 },
|
||||||
|
position: 'start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing chart:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to safely format numbers with commas
|
||||||
|
function numberWithCommas(x) {
|
||||||
|
if (x == null) return "N/A";
|
||||||
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server time update via polling
|
||||||
|
function updateServerTime() {
|
||||||
|
$.ajax({
|
||||||
|
url: "/api/time",
|
||||||
|
method: "GET",
|
||||||
|
timeout: 5000,
|
||||||
|
success: function(data) {
|
||||||
|
serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now();
|
||||||
|
serverStartTime = new Date(data.server_start_time).getTime();
|
||||||
|
},
|
||||||
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
|
console.error("Error fetching server time:", textStatus, errorThrown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update uptime display
|
||||||
|
function updateUptime() {
|
||||||
|
if (serverStartTime) {
|
||||||
|
const currentServerTime = Date.now() + serverTimeOffset;
|
||||||
|
const diff = currentServerTime - serverStartTime;
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
$("#uptimeTimer").html("<strong>Uptime:</strong> " + hours + "h " + minutes + "m " + seconds + "s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI indicators (arrows)
|
||||||
|
function updateIndicators(newMetrics) {
|
||||||
|
const keys = [
|
||||||
|
"pool_total_hashrate", "hashrate_24hr", "hashrate_3hr", "hashrate_10min",
|
||||||
|
"hashrate_60sec", "block_number", "btc_price", "network_hashrate",
|
||||||
|
"difficulty", "daily_revenue", "daily_power_cost", "daily_profit_usd",
|
||||||
|
"monthly_profit_usd", "daily_mined_sats", "monthly_mined_sats", "unpaid_earnings",
|
||||||
|
"estimated_earnings_per_day_sats", "estimated_earnings_next_block_sats", "estimated_rewards_in_window_sats",
|
||||||
|
"workers_hashing"
|
||||||
|
];
|
||||||
|
|
||||||
|
keys.forEach(function(key) {
|
||||||
|
const newVal = parseFloat(newMetrics[key]);
|
||||||
|
if (isNaN(newVal)) return;
|
||||||
|
|
||||||
|
const oldVal = parseFloat(previousMetrics[key]);
|
||||||
|
if (!isNaN(oldVal)) {
|
||||||
|
if (newVal > oldVal) {
|
||||||
|
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-up bounce-up' style='color: green;'></i>";
|
||||||
|
} else if (newVal < oldVal) {
|
||||||
|
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-down bounce-down' style='color: red; position: relative; top: -2px;'></i>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (newMetrics.arrow_history && newMetrics.arrow_history[key] && newMetrics.arrow_history[key].length > 0) {
|
||||||
|
const historyArr = newMetrics.arrow_history[key];
|
||||||
|
for (let i = historyArr.length - 1; i >= 0; i--) {
|
||||||
|
if (historyArr[i].arrow !== "") {
|
||||||
|
if (historyArr[i].arrow === "↑") {
|
||||||
|
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-up bounce-up' style='color: green;'></i>";
|
||||||
|
} else if (historyArr[i].arrow === "↓") {
|
||||||
|
persistentArrows[key] = "<i class='arrow chevron fa-solid fa-angle-double-down bounce-down' style='color: red; position: relative; top: -2px;'></i>";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicator = document.getElementById("indicator_" + key);
|
||||||
|
if (indicator) {
|
||||||
|
indicator.innerHTML = persistentArrows[key] || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
previousMetrics = { ...newMetrics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to safely update element text content
|
||||||
|
function updateElementText(elementId, text) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to safely update element HTML content
|
||||||
|
function updateElementHTML(elementId, html) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
element.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workers_hashing value from metrics, but don't try to access worker details
|
||||||
|
function updateWorkersCount() {
|
||||||
|
if (latestMetrics && latestMetrics.workers_hashing !== undefined) {
|
||||||
|
$("#workers_hashing").text(latestMetrics.workers_hashing || 0);
|
||||||
|
|
||||||
|
// Update miner status with online/offline indicator based on worker count
|
||||||
|
if (latestMetrics.workers_hashing > 0) {
|
||||||
|
updateElementHTML("miner_status", "<span class='status-green'>ONLINE</span> <span class='online-dot'></span>");
|
||||||
|
} else {
|
||||||
|
updateElementHTML("miner_status", "<span class='status-red'>OFFLINE</span> <span class='offline-dot'></span>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for block updates and show congratulatory messages
|
||||||
|
function checkForBlockUpdates(data) {
|
||||||
|
if (previousMetrics.last_block_height !== undefined &&
|
||||||
|
data.last_block_height !== previousMetrics.last_block_height) {
|
||||||
|
showCongrats("Congrats! New Block Found: " + data.last_block_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousMetrics.blocks_found !== undefined &&
|
||||||
|
data.blocks_found !== previousMetrics.blocks_found) {
|
||||||
|
showCongrats("Congrats! Blocks Found updated: " + data.blocks_found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show congratulatory messages
|
||||||
|
function showCongrats(message) {
|
||||||
|
const $congrats = $("#congratsMessage");
|
||||||
|
$congrats.text(message).fadeIn(500, function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
$congrats.fadeOut(500);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main UI update function
|
||||||
|
function updateUI() {
|
||||||
|
if (!latestMetrics) {
|
||||||
|
console.warn("No metrics data available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = latestMetrics;
|
||||||
|
|
||||||
|
// If there's execution time data, log it
|
||||||
|
if (data.execution_time) {
|
||||||
|
console.log(`Server metrics fetch took ${data.execution_time.toFixed(2)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache jQuery selectors for performance and use safe update methods
|
||||||
|
updateElementText("pool_total_hashrate",
|
||||||
|
(data.pool_total_hashrate != null ? data.pool_total_hashrate : "N/A") + " " +
|
||||||
|
(data.pool_total_hashrate_unit ? data.pool_total_hashrate_unit.slice(0,-2).toUpperCase() + data.pool_total_hashrate_unit.slice(-2) : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("hashrate_24hr",
|
||||||
|
(data.hashrate_24hr != null ? data.hashrate_24hr : "N/A") + " " +
|
||||||
|
(data.hashrate_24hr_unit ? data.hashrate_24hr_unit.slice(0,-2).toUpperCase() + data.hashrate_24hr_unit.slice(-2) : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("hashrate_3hr",
|
||||||
|
(data.hashrate_3hr != null ? data.hashrate_3hr : "N/A") + " " +
|
||||||
|
(data.hashrate_3hr_unit ? data.hashrate_3hr_unit.slice(0,-2).toUpperCase() + data.hashrate_3hr_unit.slice(-2) : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("hashrate_10min",
|
||||||
|
(data.hashrate_10min != null ? data.hashrate_10min : "N/A") + " " +
|
||||||
|
(data.hashrate_10min_unit ? data.hashrate_10min_unit.slice(0,-2).toUpperCase() + data.hashrate_10min_unit.slice(-2) : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("hashrate_60sec",
|
||||||
|
(data.hashrate_60sec != null ? data.hashrate_60sec : "N/A") + " " +
|
||||||
|
(data.hashrate_60sec_unit ? data.hashrate_60sec_unit.slice(0,-2).toUpperCase() + data.hashrate_60sec_unit.slice(-2) : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("block_number", numberWithCommas(data.block_number));
|
||||||
|
|
||||||
|
updateElementText("btc_price",
|
||||||
|
data.btc_price != null ? "$" + numberWithCommas(parseFloat(data.btc_price).toFixed(2)) : "N/A"
|
||||||
|
);
|
||||||
|
|
||||||
|
updateElementText("network_hashrate", numberWithCommas(Math.round(data.network_hashrate)) + " EH/s");
|
||||||
|
updateElementText("difficulty", numberWithCommas(Math.round(data.difficulty)));
|
||||||
|
updateElementText("daily_revenue", "$" + numberWithCommas(data.daily_revenue.toFixed(2)));
|
||||||
|
updateElementText("daily_power_cost", "$" + numberWithCommas(data.daily_power_cost.toFixed(2)));
|
||||||
|
updateElementText("daily_profit_usd", "$" + numberWithCommas(data.daily_profit_usd.toFixed(2)));
|
||||||
|
updateElementText("monthly_profit_usd", "$" + numberWithCommas(data.monthly_profit_usd.toFixed(2)));
|
||||||
|
updateElementText("daily_mined_sats", numberWithCommas(data.daily_mined_sats) + " sats");
|
||||||
|
updateElementText("monthly_mined_sats", numberWithCommas(data.monthly_mined_sats) + " sats");
|
||||||
|
|
||||||
|
// Update worker count from metrics (just the number, not full worker data)
|
||||||
|
updateWorkersCount();
|
||||||
|
|
||||||
|
updateElementText("unpaid_earnings", data.unpaid_earnings + " BTC");
|
||||||
|
|
||||||
|
// Update payout estimation with color coding
|
||||||
|
const payoutText = data.est_time_to_payout;
|
||||||
|
updateElementText("est_time_to_payout", payoutText);
|
||||||
|
|
||||||
|
if (payoutText && payoutText.toLowerCase().includes("next block")) {
|
||||||
|
$("#est_time_to_payout").css({
|
||||||
|
"color": "#32CD32",
|
||||||
|
"animation": "glowPulse 1s infinite"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const days = parseFloat(payoutText);
|
||||||
|
if (!isNaN(days)) {
|
||||||
|
if (days < 4) {
|
||||||
|
$("#est_time_to_payout").css({"color": "#32CD32", "animation": "none"});
|
||||||
|
} else if (days > 20) {
|
||||||
|
$("#est_time_to_payout").css({"color": "red", "animation": "none"});
|
||||||
|
} else {
|
||||||
|
$("#est_time_to_payout").css({"color": "#ffd700", "animation": "none"});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#est_time_to_payout").css({"color": "#ffd700", "animation": "none"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElementText("last_block_height", data.last_block_height || "");
|
||||||
|
updateElementText("last_block_time", data.last_block_time || "");
|
||||||
|
updateElementText("blocks_found", data.blocks_found || "0");
|
||||||
|
updateElementText("last_share", data.total_last_share || "");
|
||||||
|
|
||||||
|
// Update Estimated Earnings metrics
|
||||||
|
updateElementText("estimated_earnings_per_day_sats", numberWithCommas(data.estimated_earnings_per_day_sats) + " sats");
|
||||||
|
updateElementText("estimated_earnings_next_block_sats", numberWithCommas(data.estimated_earnings_next_block_sats) + " sats");
|
||||||
|
updateElementText("estimated_rewards_in_window_sats", numberWithCommas(data.estimated_rewards_in_window_sats) + " sats");
|
||||||
|
|
||||||
|
// Update last updated timestamp
|
||||||
|
const now = new Date(Date.now() + serverTimeOffset);
|
||||||
|
updateElementHTML("lastUpdated", "<strong>Last Updated:</strong> " + now.toLocaleString() + "<span id='terminal-cursor'></span>");
|
||||||
|
|
||||||
|
// Update chart if it exists
|
||||||
|
if (trendChart) {
|
||||||
|
try {
|
||||||
|
// Always update the 24hr average line even if we don't have data points yet
|
||||||
|
const avg24hr = parseFloat(data.hashrate_24hr || 0);
|
||||||
|
if (!isNaN(avg24hr) &&
|
||||||
|
trendChart.options.plugins.annotation &&
|
||||||
|
trendChart.options.plugins.annotation.annotations &&
|
||||||
|
trendChart.options.plugins.annotation.annotations.averageLine) {
|
||||||
|
const annotation = trendChart.options.plugins.annotation.annotations.averageLine;
|
||||||
|
annotation.yMin = avg24hr;
|
||||||
|
annotation.yMax = avg24hr;
|
||||||
|
annotation.label.content = '24hr Avg: ' + avg24hr + ' TH/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update data points if we have any (removed minimum length requirement)
|
||||||
|
if (data.arrow_history && data.arrow_history.hashrate_60sec) {
|
||||||
|
const historyData = data.arrow_history.hashrate_60sec;
|
||||||
|
if (historyData && historyData.length > 0) {
|
||||||
|
console.log(`Updating chart with ${historyData.length} data points`);
|
||||||
|
trendChart.data.labels = historyData.map(item => item.time);
|
||||||
|
trendChart.data.datasets[0].data = historyData.map(item => {
|
||||||
|
const val = parseFloat(item.value);
|
||||||
|
return isNaN(val) ? 0 : val;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("No history data points available yet");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No hashrate_60sec history available yet");
|
||||||
|
|
||||||
|
// If there's no history data, create a starting point using current hashrate
|
||||||
|
if (data.hashrate_60sec) {
|
||||||
|
const currentTime = new Date().toLocaleTimeString('en-US', {hour12: false, hour: '2-digit', minute: '2-digit'});
|
||||||
|
trendChart.data.labels = [currentTime];
|
||||||
|
trendChart.data.datasets[0].data = [parseFloat(data.hashrate_60sec) || 0];
|
||||||
|
console.log("Created initial data point with current hashrate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update the chart, even if we just updated the average line
|
||||||
|
trendChart.update('none');
|
||||||
|
} catch (chartError) {
|
||||||
|
console.error("Error updating chart:", chartError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update indicators and check for block updates
|
||||||
|
updateIndicators(data);
|
||||||
|
checkForBlockUpdates(data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating UI:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up refresh synchronization
|
||||||
|
function setupRefreshSync() {
|
||||||
|
// Listen for the dataRefreshed event
|
||||||
|
$(document).on('dataRefreshed', function() {
|
||||||
|
// Broadcast to any other open tabs/pages that the data has been refreshed
|
||||||
|
try {
|
||||||
|
// Store the current timestamp to localStorage
|
||||||
|
localStorage.setItem('dashboardRefreshTime', Date.now().toString());
|
||||||
|
|
||||||
|
// Create a custom event that can be detected across tabs/pages
|
||||||
|
localStorage.setItem('dashboardRefreshEvent', 'refresh-' + Date.now());
|
||||||
|
|
||||||
|
console.log("Dashboard refresh synchronized");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error synchronizing refresh:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document ready initialization
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Initialize the chart
|
||||||
|
trendChart = initializeChart();
|
||||||
|
|
||||||
|
// Initialize the progress bar
|
||||||
|
initProgressBar();
|
||||||
|
|
||||||
|
// Set up direct monitoring of data refreshes
|
||||||
|
$(document).on('dataRefreshed', function() {
|
||||||
|
console.log("Data refresh event detected, resetting progress bar");
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
currentProgress = 0;
|
||||||
|
updateProgressBar(currentProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap the updateUI function to detect changes and trigger events
|
||||||
|
const originalUpdateUI = updateUI;
|
||||||
|
updateUI = function() {
|
||||||
|
const previousMetricsTimestamp = latestMetrics ? latestMetrics.server_timestamp : null;
|
||||||
|
|
||||||
|
// Call the original function
|
||||||
|
originalUpdateUI.apply(this, arguments);
|
||||||
|
|
||||||
|
// Check if we got new data by comparing timestamps
|
||||||
|
if (latestMetrics && latestMetrics.server_timestamp !== previousMetricsTimestamp) {
|
||||||
|
console.log("New data detected, triggering refresh event");
|
||||||
|
$(document).trigger('dataRefreshed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up event source for SSE
|
||||||
|
setupEventSource();
|
||||||
|
|
||||||
|
// Start server time polling
|
||||||
|
updateServerTime();
|
||||||
|
setInterval(updateServerTime, 30000);
|
||||||
|
|
||||||
|
// Start uptime timer
|
||||||
|
setInterval(updateUptime, 1000);
|
||||||
|
updateUptime();
|
||||||
|
|
||||||
|
// Set up refresh synchronization with workers page
|
||||||
|
setupRefreshSync();
|
||||||
|
|
||||||
|
// Add a manual refresh button for fallback
|
||||||
|
$("body").append('<button id="refreshButton" style="position: fixed; bottom: 20px; left: 20px; z-index: 1000; background: #f7931a; color: black; border: none; padding: 8px 16px; display: none; border-radius: 4px; cursor: pointer;">Refresh Data</button>');
|
||||||
|
|
||||||
|
$("#refreshButton").on("click", function() {
|
||||||
|
$(this).text("Refreshing...");
|
||||||
|
$(this).prop("disabled", true);
|
||||||
|
manualRefresh();
|
||||||
|
setTimeout(function() {
|
||||||
|
$("#refreshButton").text("Refresh Data");
|
||||||
|
$("#refreshButton").prop("disabled", false);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force a data refresh when the page loads
|
||||||
|
manualRefresh();
|
||||||
|
|
||||||
|
// Add emergency refresh button functionality
|
||||||
|
$("#forceRefreshBtn").show().on("click", function() {
|
||||||
|
$(this).text("Refreshing...");
|
||||||
|
$(this).prop("disabled", true);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/force-refresh',
|
||||||
|
method: 'POST',
|
||||||
|
timeout: 15000,
|
||||||
|
success: function(data) {
|
||||||
|
console.log("Force refresh successful:", data);
|
||||||
|
manualRefresh(); // Immediately get the new data
|
||||||
|
$("#forceRefreshBtn").text("Force Refresh").prop("disabled", false);
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error("Force refresh failed:", error);
|
||||||
|
$("#forceRefreshBtn").text("Force Refresh").prop("disabled", false);
|
||||||
|
alert("Refresh failed: " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add stale data detection
|
||||||
|
setInterval(function() {
|
||||||
|
if (latestMetrics && latestMetrics.server_timestamp) {
|
||||||
|
const lastUpdate = new Date(latestMetrics.server_timestamp);
|
||||||
|
const timeSinceUpdate = Math.floor((Date.now() - lastUpdate.getTime()) / 1000);
|
||||||
|
if (timeSinceUpdate > 120) { // More than 2 minutes
|
||||||
|
showConnectionIssue(`Data stale (${timeSinceUpdate}s old). Use Force Refresh.`);
|
||||||
|
$("#forceRefreshBtn").show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
});
|
298
minify.py
298
minify.py
@ -1,246 +1,76 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
import os
|
||||||
import jsmin
|
|
||||||
import htmlmin
|
import htmlmin
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO,
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def minify_js_files():
|
TEMPLATES_DIR = "templates"
|
||||||
"""Minify JavaScript files."""
|
HTML_FILES = ["index.html", "error.html"]
|
||||||
js_dir = 'static/js'
|
|
||||||
min_dir = os.path.join(js_dir, 'min')
|
|
||||||
os.makedirs(min_dir, exist_ok=True)
|
|
||||||
|
|
||||||
minified_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
|
|
||||||
for js_file in os.listdir(js_dir):
|
|
||||||
if js_file.endswith('.js') and not js_file.endswith('.min.js'):
|
|
||||||
try:
|
|
||||||
input_path = os.path.join(js_dir, js_file)
|
|
||||||
output_path = os.path.join(min_dir, js_file.replace('.js', '.min.js'))
|
|
||||||
|
|
||||||
# Skip already minified files if they're newer than source
|
|
||||||
if os.path.exists(output_path) and \
|
|
||||||
os.path.getmtime(output_path) > os.path.getmtime(input_path):
|
|
||||||
logger.info(f"Skipping {js_file} (already up to date)")
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(input_path, 'r', encoding='utf-8') as f:
|
|
||||||
js_content = f.read()
|
|
||||||
|
|
||||||
# Minify the content
|
|
||||||
minified = jsmin.jsmin(js_content)
|
|
||||||
|
|
||||||
# Write minified content
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(minified)
|
|
||||||
|
|
||||||
size_original = len(js_content)
|
|
||||||
size_minified = len(minified)
|
|
||||||
reduction = (1 - size_minified / size_original) * 100 if size_original > 0 else 0
|
|
||||||
|
|
||||||
logger.info(f"Minified {js_file} - Reduced by {reduction:.1f}%")
|
|
||||||
minified_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {js_file}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"JavaScript minification: {minified_count} files minified, {skipped_count} files skipped")
|
|
||||||
return minified_count
|
|
||||||
|
|
||||||
def minify_css_files():
|
def minify_html_file(file_path):
|
||||||
"""Minify CSS files using simple compression techniques."""
|
"""
|
||||||
css_dir = 'static/css'
|
Minify an HTML file with error handling
|
||||||
min_dir = os.path.join(css_dir, 'min')
|
"""
|
||||||
os.makedirs(min_dir, exist_ok=True)
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
minified_count = 0
|
content = f.read()
|
||||||
skipped_count = 0
|
|
||||||
|
# Check if file has content
|
||||||
for css_file in os.listdir(css_dir):
|
if not content.strip():
|
||||||
if css_file.endswith('.css') and not css_file.endswith('.min.css'):
|
logging.warning(f"File {file_path} is empty. Skipping.")
|
||||||
try:
|
return
|
||||||
input_path = os.path.join(css_dir, css_file)
|
|
||||||
output_path = os.path.join(min_dir, css_file.replace('.css', '.min.css'))
|
# Minify the content
|
||||||
|
try:
|
||||||
|
minified = htmlmin.minify(content,
|
||||||
|
remove_comments=True,
|
||||||
|
remove_empty_space=True,
|
||||||
|
remove_all_empty_space=False,
|
||||||
|
reduce_boolean_attributes=True)
|
||||||
|
|
||||||
|
# Make sure minification worked and didn't remove everything
|
||||||
|
if not minified.strip():
|
||||||
|
logging.error(f"Minification of {file_path} resulted in empty content. Using original.")
|
||||||
|
minified = content
|
||||||
|
|
||||||
# Skip already minified files if they're newer than source
|
# Write back the minified content
|
||||||
if os.path.exists(output_path) and \
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
os.path.getmtime(output_path) > os.path.getmtime(input_path):
|
f.write(minified)
|
||||||
logger.info(f"Skipping {css_file} (already up to date)")
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(input_path, 'r', encoding='utf-8') as f:
|
logging.info(f"Minified {file_path}")
|
||||||
css_content = f.read()
|
|
||||||
|
except Exception as e:
|
||||||
# Simple CSS minification using string replacements
|
logging.error(f"Error minifying {file_path}: {e}")
|
||||||
# Remove comments
|
|
||||||
import re
|
except Exception as e:
|
||||||
css_minified = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
|
logging.error(f"Error reading file {file_path}: {e}")
|
||||||
# Remove whitespace
|
|
||||||
css_minified = re.sub(r'\s+', ' ', css_minified)
|
|
||||||
# Remove spaces around selectors
|
|
||||||
css_minified = re.sub(r'\s*{\s*', '{', css_minified)
|
|
||||||
css_minified = re.sub(r'\s*}\s*', '}', css_minified)
|
|
||||||
css_minified = re.sub(r'\s*;\s*', ';', css_minified)
|
|
||||||
css_minified = re.sub(r'\s*:\s*', ':', css_minified)
|
|
||||||
css_minified = re.sub(r'\s*,\s*', ',', css_minified)
|
|
||||||
# Remove last semicolons
|
|
||||||
css_minified = re.sub(r';}', '}', css_minified)
|
|
||||||
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(css_minified)
|
|
||||||
|
|
||||||
size_original = len(css_content)
|
|
||||||
size_minified = len(css_minified)
|
|
||||||
reduction = (1 - size_minified / size_original) * 100 if size_original > 0 else 0
|
|
||||||
|
|
||||||
logger.info(f"Minified {css_file} - Reduced by {reduction:.1f}%")
|
|
||||||
minified_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {css_file}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"CSS minification: {minified_count} files minified, {skipped_count} files skipped")
|
|
||||||
return minified_count
|
|
||||||
|
|
||||||
def minify_html_templates():
|
def ensure_templates_dir():
|
||||||
"""Minify HTML template files."""
|
"""
|
||||||
templates_dir = 'templates'
|
Ensure templates directory exists
|
||||||
minified_count = 0
|
"""
|
||||||
skipped_count = 0
|
if not os.path.exists(TEMPLATES_DIR):
|
||||||
|
try:
|
||||||
for html_file in os.listdir(templates_dir):
|
os.makedirs(TEMPLATES_DIR)
|
||||||
if html_file.endswith('.html'):
|
logging.info(f"Created templates directory: {TEMPLATES_DIR}")
|
||||||
try:
|
except Exception as e:
|
||||||
input_path = os.path.join(templates_dir, html_file)
|
logging.error(f"Error creating templates directory: {e}")
|
||||||
|
return False
|
||||||
with open(input_path, 'r', encoding='utf-8') as f:
|
return True
|
||||||
html_content = f.read()
|
|
||||||
|
|
||||||
# Minify HTML content while keeping important whitespace
|
|
||||||
minified = htmlmin.minify(html_content,
|
|
||||||
remove_comments=True,
|
|
||||||
remove_empty_space=True,
|
|
||||||
remove_all_empty_space=False,
|
|
||||||
reduce_boolean_attributes=True)
|
|
||||||
|
|
||||||
# Write back to the same file
|
|
||||||
with open(input_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(minified)
|
|
||||||
|
|
||||||
size_original = len(html_content)
|
|
||||||
size_minified = len(minified)
|
|
||||||
reduction = (1 - size_minified / size_original) * 100 if size_original > 0 else 0
|
|
||||||
|
|
||||||
logger.info(f"Minified {html_file} - Reduced by {reduction:.1f}%")
|
|
||||||
minified_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {html_file}: {e}")
|
|
||||||
|
|
||||||
logger.info(f"HTML minification: {minified_count} files minified, {skipped_count} files skipped")
|
|
||||||
return minified_count
|
|
||||||
|
|
||||||
def create_size_report():
|
|
||||||
"""Create a report of file sizes before and after minification."""
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Check JS files
|
|
||||||
js_dir = 'static/js'
|
|
||||||
min_dir = os.path.join(js_dir, 'min')
|
|
||||||
if os.path.exists(min_dir):
|
|
||||||
for js_file in os.listdir(js_dir):
|
|
||||||
if js_file.endswith('.js') and not js_file.endswith('.min.js'):
|
|
||||||
orig_path = os.path.join(js_dir, js_file)
|
|
||||||
min_path = os.path.join(min_dir, js_file.replace('.js', '.min.js'))
|
|
||||||
|
|
||||||
if os.path.exists(min_path):
|
|
||||||
orig_size = os.path.getsize(orig_path)
|
|
||||||
min_size = os.path.getsize(min_path)
|
|
||||||
reduction = (1 - min_size / orig_size) * 100 if orig_size > 0 else 0
|
|
||||||
results.append({
|
|
||||||
'file': js_file,
|
|
||||||
'type': 'JavaScript',
|
|
||||||
'original_size': orig_size,
|
|
||||||
'minified_size': min_size,
|
|
||||||
'reduction': reduction
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check CSS files
|
|
||||||
css_dir = 'static/css'
|
|
||||||
min_dir = os.path.join(css_dir, 'min')
|
|
||||||
if os.path.exists(min_dir):
|
|
||||||
for css_file in os.listdir(css_dir):
|
|
||||||
if css_file.endswith('.css') and not css_file.endswith('.min.css'):
|
|
||||||
orig_path = os.path.join(css_dir, css_file)
|
|
||||||
min_path = os.path.join(min_dir, css_file.replace('.css', '.min.css'))
|
|
||||||
|
|
||||||
if os.path.exists(min_path):
|
|
||||||
orig_size = os.path.getsize(orig_path)
|
|
||||||
min_size = os.path.getsize(min_path)
|
|
||||||
reduction = (1 - min_size / orig_size) * 100 if orig_size > 0 else 0
|
|
||||||
results.append({
|
|
||||||
'file': css_file,
|
|
||||||
'type': 'CSS',
|
|
||||||
'original_size': orig_size,
|
|
||||||
'minified_size': min_size,
|
|
||||||
'reduction': reduction
|
|
||||||
})
|
|
||||||
|
|
||||||
# Print the report
|
|
||||||
total_orig = sum(item['original_size'] for item in results)
|
|
||||||
total_min = sum(item['minified_size'] for item in results)
|
|
||||||
total_reduction = (1 - total_min / total_orig) * 100 if total_orig > 0 else 0
|
|
||||||
|
|
||||||
logger.info("\n" + "="*50)
|
|
||||||
logger.info("MINIFICATION REPORT")
|
|
||||||
logger.info("="*50)
|
|
||||||
logger.info(f"{'File':<30} {'Type':<10} {'Original':<10} {'Minified':<10} {'Reduction'}")
|
|
||||||
logger.info("-"*70)
|
|
||||||
|
|
||||||
for item in results:
|
|
||||||
logger.info(f"{item['file']:<30} {item['type']:<10} "
|
|
||||||
f"{item['original_size']/1024:.1f}KB {item['minified_size']/1024:.1f}KB "
|
|
||||||
f"{item['reduction']:.1f}%")
|
|
||||||
|
|
||||||
logger.info("-"*70)
|
|
||||||
logger.info(f"{'TOTAL:':<30} {'':<10} {total_orig/1024:.1f}KB {total_min/1024:.1f}KB {total_reduction:.1f}%")
|
|
||||||
logger.info("="*50)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function to run minification tasks."""
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Minify web assets')
|
|
||||||
parser.add_argument('--js', action='store_true', help='Minify JavaScript files')
|
|
||||||
parser.add_argument('--css', action='store_true', help='Minify CSS files')
|
|
||||||
parser.add_argument('--html', action='store_true', help='Minify HTML templates')
|
|
||||||
parser.add_argument('--all', action='store_true', help='Minify all assets')
|
|
||||||
parser.add_argument('--report', action='store_true', help='Generate size report only')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# If no arguments, default to --all
|
|
||||||
if not (args.js or args.css or args.html or args.report):
|
|
||||||
args.all = True
|
|
||||||
|
|
||||||
if args.all or args.js:
|
|
||||||
minify_js_files()
|
|
||||||
|
|
||||||
if args.all or args.css:
|
|
||||||
minify_css_files()
|
|
||||||
|
|
||||||
if args.all or args.html:
|
|
||||||
minify_html_templates()
|
|
||||||
|
|
||||||
# Always generate the report at the end if any minification was done
|
|
||||||
if args.report or args.all or args.js or args.css:
|
|
||||||
create_size_report()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
logging.info("Starting HTML minification process")
|
||||||
|
|
||||||
|
if not ensure_templates_dir():
|
||||||
|
logging.error("Templates directory does not exist and could not be created. Exiting.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
for filename in HTML_FILES:
|
||||||
|
file_path = os.path.join(TEMPLATES_DIR, filename)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
minify_html_file(file_path)
|
||||||
|
else:
|
||||||
|
logging.warning(f"File {file_path} not found.")
|
||||||
|
|
||||||
|
logging.info("HTML minification process completed")
|
183
models.py
183
models.py
@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Data models for the Bitcoin Mining Dashboard.
|
|
||||||
"""
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional, Dict, List, Union, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OceanData:
|
|
||||||
"""Data structure for Ocean.xyz pool mining data."""
|
|
||||||
# Keep original definitions with None default to maintain backward compatibility
|
|
||||||
pool_total_hashrate: float = None
|
|
||||||
pool_total_hashrate_unit: str = None
|
|
||||||
hashrate_24hr: float = None
|
|
||||||
hashrate_24hr_unit: str = None
|
|
||||||
hashrate_3hr: float = None
|
|
||||||
hashrate_3hr_unit: str = None
|
|
||||||
hashrate_10min: float = None
|
|
||||||
hashrate_10min_unit: str = None
|
|
||||||
hashrate_5min: float = None
|
|
||||||
hashrate_5min_unit: str = None
|
|
||||||
hashrate_60sec: float = None
|
|
||||||
hashrate_60sec_unit: str = None
|
|
||||||
estimated_earnings_per_day: float = None
|
|
||||||
estimated_earnings_next_block: float = None
|
|
||||||
estimated_rewards_in_window: float = None
|
|
||||||
workers_hashing: int = None
|
|
||||||
unpaid_earnings: float = None
|
|
||||||
est_time_to_payout: str = None
|
|
||||||
last_block: str = None
|
|
||||||
last_block_height: str = None
|
|
||||||
last_block_time: str = None
|
|
||||||
blocks_found: str = None
|
|
||||||
total_last_share: str = "N/A"
|
|
||||||
last_block_earnings: str = None
|
|
||||||
pool_fees_percentage: float = None # Added missing attribute
|
|
||||||
|
|
||||||
def get_normalized_hashrate(self, timeframe: str = "3hr") -> float:
|
|
||||||
"""
|
|
||||||
Get a normalized hashrate value in TH/s regardless of original units.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeframe: The timeframe to get ("24hr", "3hr", "10min", "5min", "60sec")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
float: Normalized hashrate in TH/s
|
|
||||||
"""
|
|
||||||
if timeframe == "24hr" and self.hashrate_24hr is not None:
|
|
||||||
return convert_to_ths(self.hashrate_24hr, self.hashrate_24hr_unit)
|
|
||||||
elif timeframe == "3hr" and self.hashrate_3hr is not None:
|
|
||||||
return convert_to_ths(self.hashrate_3hr, self.hashrate_3hr_unit)
|
|
||||||
elif timeframe == "10min" and self.hashrate_10min is not None:
|
|
||||||
return convert_to_ths(self.hashrate_10min, self.hashrate_10min_unit)
|
|
||||||
elif timeframe == "5min" and self.hashrate_5min is not None:
|
|
||||||
return convert_to_ths(self.hashrate_5min, self.hashrate_5min_unit)
|
|
||||||
elif timeframe == "60sec" and self.hashrate_60sec is not None:
|
|
||||||
return convert_to_ths(self.hashrate_60sec, self.hashrate_60sec_unit)
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Convert the OceanData object to a dictionary."""
|
|
||||||
return {k: v for k, v in self.__dict__.items()}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'OceanData':
|
|
||||||
"""Create an OceanData instance from a dictionary."""
|
|
||||||
filtered_data = {}
|
|
||||||
for k, v in data.items():
|
|
||||||
if k in cls.__annotations__:
|
|
||||||
filtered_data[k] = v
|
|
||||||
return cls(**filtered_data)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorkerData:
|
|
||||||
"""Data structure for individual worker information."""
|
|
||||||
name: str = None
|
|
||||||
status: str = "offline"
|
|
||||||
type: str = "ASIC" # ASIC or Bitaxe
|
|
||||||
model: str = "Unknown"
|
|
||||||
hashrate_60sec: float = 0
|
|
||||||
hashrate_60sec_unit: str = "TH/s"
|
|
||||||
hashrate_3hr: float = 0
|
|
||||||
hashrate_3hr_unit: str = "TH/s"
|
|
||||||
efficiency: float = 0
|
|
||||||
last_share: str = "N/A"
|
|
||||||
earnings: float = 0
|
|
||||||
acceptance_rate: float = 0
|
|
||||||
power_consumption: float = 0
|
|
||||||
temperature: float = 0
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""
|
|
||||||
Validate worker data after initialization.
|
|
||||||
Ensures values are within acceptable ranges and formats.
|
|
||||||
"""
|
|
||||||
# Ensure hashrates are non-negative
|
|
||||||
if self.hashrate_60sec is not None and self.hashrate_60sec < 0:
|
|
||||||
self.hashrate_60sec = 0
|
|
||||||
|
|
||||||
if self.hashrate_3hr is not None and self.hashrate_3hr < 0:
|
|
||||||
self.hashrate_3hr = 0
|
|
||||||
|
|
||||||
# Ensure status is valid, but don't raise exceptions for backward compatibility
|
|
||||||
if self.status not in ["online", "offline"]:
|
|
||||||
logging.warning(f"Worker {self.name}: Invalid status '{self.status}', using 'offline'")
|
|
||||||
self.status = "offline"
|
|
||||||
|
|
||||||
# Ensure type is valid, but don't raise exceptions for backward compatibility
|
|
||||||
if self.type not in ["ASIC", "Bitaxe"]:
|
|
||||||
logging.warning(f"Worker {self.name}: Invalid type '{self.type}', using 'ASIC'")
|
|
||||||
self.type = "ASIC"
|
|
||||||
|
|
||||||
def get_normalized_hashrate(self, timeframe: str = "3hr") -> float:
|
|
||||||
"""
|
|
||||||
Get normalized hashrate in TH/s.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeframe: The timeframe to get ("3hr" or "60sec")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
float: Normalized hashrate in TH/s
|
|
||||||
"""
|
|
||||||
if timeframe == "3hr":
|
|
||||||
return convert_to_ths(self.hashrate_3hr, self.hashrate_3hr_unit)
|
|
||||||
elif timeframe == "60sec":
|
|
||||||
return convert_to_ths(self.hashrate_60sec, self.hashrate_60sec_unit)
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Convert the WorkerData object to a dictionary."""
|
|
||||||
return {k: v for k, v in self.__dict__.items()}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'WorkerData':
|
|
||||||
"""Create a WorkerData instance from a dictionary."""
|
|
||||||
filtered_data = {}
|
|
||||||
for k, v in data.items():
|
|
||||||
if k in cls.__annotations__:
|
|
||||||
filtered_data[k] = v
|
|
||||||
return cls(**filtered_data)
|
|
||||||
|
|
||||||
class HashRateConversionError(Exception):
|
|
||||||
"""Exception raised for errors in hashrate unit conversion."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def convert_to_ths(value, unit):
|
|
||||||
"""
|
|
||||||
Convert any hashrate unit to TH/s equivalent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (float): The numerical value of the hashrate
|
|
||||||
unit (str): The unit of measurement (e.g., 'PH/s', 'EH/s', etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
float: The hashrate value in TH/s
|
|
||||||
"""
|
|
||||||
if value is None or value == 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
unit = unit.lower() if unit else 'th/s'
|
|
||||||
|
|
||||||
if 'ph/s' in unit:
|
|
||||||
return value * 1000 # 1 PH/s = 1000 TH/s
|
|
||||||
elif 'eh/s' in unit:
|
|
||||||
return value * 1000000 # 1 EH/s = 1,000,000 TH/s
|
|
||||||
elif 'gh/s' in unit:
|
|
||||||
return value / 1000 # 1 TH/s = 1000 GH/s
|
|
||||||
elif 'mh/s' in unit:
|
|
||||||
return value / 1000000 # 1 TH/s = 1,000,000 MH/s
|
|
||||||
elif 'kh/s' in unit:
|
|
||||||
return value / 1000000000 # 1 TH/s = 1,000,000,000 KH/s
|
|
||||||
elif 'h/s' in unit and not any(prefix in unit for prefix in ['th/s', 'ph/s', 'eh/s', 'gh/s', 'mh/s', 'kh/s']):
|
|
||||||
return value / 1000000000000 # 1 TH/s = 1,000,000,000,000 H/s
|
|
||||||
elif 'th/s' in unit:
|
|
||||||
return value
|
|
||||||
else:
|
|
||||||
# Log unexpected unit
|
|
||||||
logging.warning(f"Unexpected hashrate unit: {unit}, defaulting to treating as TH/s")
|
|
||||||
return value
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error in convert_to_ths: {e}")
|
|
||||||
return value # Return original value as fallback
|
|
@ -1,548 +0,0 @@
|
|||||||
# notification_service.py
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from enum import Enum
|
|
||||||
from collections import deque
|
|
||||||
from typing import List, Dict, Any, Optional, Union
|
|
||||||
|
|
||||||
# Constants to replace magic values
|
|
||||||
ONE_DAY_SECONDS = 86400
|
|
||||||
DEFAULT_TARGET_HOUR = 12
|
|
||||||
SIGNIFICANT_HASHRATE_CHANGE_PERCENT = 25
|
|
||||||
NOTIFICATION_WINDOW_MINUTES = 5
|
|
||||||
|
|
||||||
class NotificationLevel(Enum):
|
|
||||||
INFO = "info"
|
|
||||||
SUCCESS = "success"
|
|
||||||
WARNING = "warning"
|
|
||||||
ERROR = "error"
|
|
||||||
|
|
||||||
class NotificationCategory(Enum):
|
|
||||||
HASHRATE = "hashrate"
|
|
||||||
BLOCK = "block"
|
|
||||||
WORKER = "worker"
|
|
||||||
EARNINGS = "earnings"
|
|
||||||
SYSTEM = "system"
|
|
||||||
|
|
||||||
class NotificationService:
|
|
||||||
"""Service for managing mining dashboard notifications."""
|
|
||||||
|
|
||||||
def __init__(self, state_manager):
|
|
||||||
"""Initialize with state manager for persistence."""
|
|
||||||
self.state_manager = state_manager
|
|
||||||
self.notifications = []
|
|
||||||
self.daily_stats_time = "00:00:00" # When to post daily stats (midnight)
|
|
||||||
self.last_daily_stats = None
|
|
||||||
self.max_notifications = 100 # Maximum number to store
|
|
||||||
self.last_block_height = None # Track the last seen block height
|
|
||||||
self.last_payout_notification_time = None # Track the last payout notification time
|
|
||||||
self.last_estimated_payout_time = None # Track the last estimated payout time
|
|
||||||
|
|
||||||
# Load existing notifications from state
|
|
||||||
self._load_notifications()
|
|
||||||
|
|
||||||
# Load last block height from state
|
|
||||||
self._load_last_block_height()
|
|
||||||
|
|
||||||
def _get_redis_value(self, key: str, default: Any = None) -> Any:
|
|
||||||
"""Generic method to retrieve values from Redis."""
|
|
||||||
try:
|
|
||||||
if hasattr(self.state_manager, 'redis_client') and self.state_manager.redis_client:
|
|
||||||
value = self.state_manager.redis_client.get(key)
|
|
||||||
if value:
|
|
||||||
return value.decode('utf-8')
|
|
||||||
return default
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error retrieving {key} from Redis: {e}")
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _set_redis_value(self, key: str, value: Any) -> bool:
|
|
||||||
"""Generic method to set values in Redis."""
|
|
||||||
try:
|
|
||||||
if hasattr(self.state_manager, 'redis_client') and self.state_manager.redis_client:
|
|
||||||
self.state_manager.redis_client.set(key, str(value))
|
|
||||||
logging.info(f"[NotificationService] Saved {key} to Redis: {value}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error saving {key} to Redis: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _load_notifications(self) -> None:
|
|
||||||
"""Load notifications with enhanced error handling."""
|
|
||||||
try:
|
|
||||||
stored_notifications = self.state_manager.get_notifications()
|
|
||||||
if stored_notifications:
|
|
||||||
self.notifications = stored_notifications
|
|
||||||
logging.info(f"[NotificationService] Loaded {len(self.notifications)} notifications from storage")
|
|
||||||
else:
|
|
||||||
self.notifications = []
|
|
||||||
logging.info("[NotificationService] No notifications found in storage, starting with empty list")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error loading notifications: {e}")
|
|
||||||
self.notifications = [] # Ensure we have a valid list
|
|
||||||
|
|
||||||
def _load_last_block_height(self) -> None:
|
|
||||||
"""Load last block height from persistent storage."""
|
|
||||||
try:
|
|
||||||
self.last_block_height = self._get_redis_value("last_block_height")
|
|
||||||
if self.last_block_height:
|
|
||||||
logging.info(f"[NotificationService] Loaded last block height from storage: {self.last_block_height}")
|
|
||||||
else:
|
|
||||||
logging.info("[NotificationService] No last block height found, starting with None")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error loading last block height: {e}")
|
|
||||||
|
|
||||||
def _save_last_block_height(self) -> None:
|
|
||||||
"""Save last block height to persistent storage."""
|
|
||||||
if self.last_block_height:
|
|
||||||
self._set_redis_value("last_block_height", self.last_block_height)
|
|
||||||
|
|
||||||
def _save_notifications(self) -> None:
|
|
||||||
"""Save notifications with improved pruning."""
|
|
||||||
try:
|
|
||||||
# Sort by timestamp before pruning to ensure we keep the most recent
|
|
||||||
if len(self.notifications) > self.max_notifications:
|
|
||||||
self.notifications.sort(key=lambda n: n.get("timestamp", ""), reverse=True)
|
|
||||||
self.notifications = self.notifications[:self.max_notifications]
|
|
||||||
|
|
||||||
self.state_manager.save_notifications(self.notifications)
|
|
||||||
logging.info(f"[NotificationService] Saved {len(self.notifications)} notifications")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error saving notifications: {e}")
|
|
||||||
|
|
||||||
def add_notification(self,
|
|
||||||
message: str,
|
|
||||||
level: NotificationLevel = NotificationLevel.INFO,
|
|
||||||
category: NotificationCategory = NotificationCategory.SYSTEM,
|
|
||||||
data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Add a new notification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message (str): Notification message text
|
|
||||||
level (NotificationLevel): Severity level
|
|
||||||
category (NotificationCategory): Classification category
|
|
||||||
data (dict, optional): Additional data for the notification
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The created notification
|
|
||||||
"""
|
|
||||||
notification = {
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"message": message,
|
|
||||||
"level": level.value,
|
|
||||||
"category": category.value,
|
|
||||||
"read": False
|
|
||||||
}
|
|
||||||
|
|
||||||
if data:
|
|
||||||
notification["data"] = data
|
|
||||||
|
|
||||||
self.notifications.append(notification)
|
|
||||||
self._save_notifications()
|
|
||||||
|
|
||||||
logging.info(f"[NotificationService] Added notification: {message}")
|
|
||||||
return notification
|
|
||||||
|
|
||||||
def get_notifications(self,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
unread_only: bool = False,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
level: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get filtered notifications with optimized filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit (int): Maximum number to return
|
|
||||||
offset (int): Starting offset for pagination
|
|
||||||
unread_only (bool): Only return unread notifications
|
|
||||||
category (str): Filter by category
|
|
||||||
level (str): Filter by level
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Filtered notifications
|
|
||||||
"""
|
|
||||||
# Apply all filters in a single pass
|
|
||||||
filtered = [
|
|
||||||
n for n in self.notifications
|
|
||||||
if (not unread_only or not n.get("read", False)) and
|
|
||||||
(not category or n.get("category") == category) and
|
|
||||||
(not level or n.get("level") == level)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sort by timestamp (newest first)
|
|
||||||
filtered = sorted(filtered, key=lambda n: n.get("timestamp", ""), reverse=True)
|
|
||||||
|
|
||||||
# Apply pagination
|
|
||||||
return filtered[offset:offset + limit]
|
|
||||||
|
|
||||||
def get_unread_count(self) -> int:
|
|
||||||
"""Get count of unread notifications."""
|
|
||||||
return sum(1 for n in self.notifications if not n.get("read", False))
|
|
||||||
|
|
||||||
def mark_as_read(self, notification_id: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Mark notification(s) as read.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
notification_id (str, optional): ID of specific notification to mark read,
|
|
||||||
or None to mark all as read
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if successful
|
|
||||||
"""
|
|
||||||
if notification_id:
|
|
||||||
# Mark specific notification as read
|
|
||||||
for n in self.notifications:
|
|
||||||
if n.get("id") == notification_id:
|
|
||||||
n["read"] = True
|
|
||||||
logging.info(f"[NotificationService] Marked notification {notification_id} as read")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Mark all as read
|
|
||||||
for n in self.notifications:
|
|
||||||
n["read"] = True
|
|
||||||
logging.info(f"[NotificationService] Marked all {len(self.notifications)} notifications as read")
|
|
||||||
|
|
||||||
self._save_notifications()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def delete_notification(self, notification_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Delete a specific notification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
notification_id (str): ID of notification to delete
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if successful
|
|
||||||
"""
|
|
||||||
original_count = len(self.notifications)
|
|
||||||
self.notifications = [n for n in self.notifications if n.get("id") != notification_id]
|
|
||||||
deleted = original_count - len(self.notifications)
|
|
||||||
|
|
||||||
if deleted > 0:
|
|
||||||
logging.info(f"[NotificationService] Deleted notification {notification_id}")
|
|
||||||
self._save_notifications()
|
|
||||||
|
|
||||||
return deleted > 0
|
|
||||||
|
|
||||||
def clear_notifications(self, category: Optional[str] = None, older_than_days: Optional[int] = None) -> int:
|
|
||||||
"""
|
|
||||||
Clear notifications with optimized filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
category (str, optional): Only clear specific category
|
|
||||||
older_than_days (int, optional): Only clear notifications older than this
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of notifications cleared
|
|
||||||
"""
|
|
||||||
original_count = len(self.notifications)
|
|
||||||
|
|
||||||
cutoff_date = None
|
|
||||||
if older_than_days:
|
|
||||||
cutoff_date = datetime.now() - timedelta(days=older_than_days)
|
|
||||||
|
|
||||||
# Apply filters in a single pass
|
|
||||||
self.notifications = [
|
|
||||||
n for n in self.notifications
|
|
||||||
if (not category or n.get("category") != category) and
|
|
||||||
(not cutoff_date or datetime.fromisoformat(n.get("timestamp", datetime.now().isoformat())) >= cutoff_date)
|
|
||||||
]
|
|
||||||
|
|
||||||
cleared_count = original_count - len(self.notifications)
|
|
||||||
if cleared_count > 0:
|
|
||||||
logging.info(f"[NotificationService] Cleared {cleared_count} notifications")
|
|
||||||
self._save_notifications()
|
|
||||||
|
|
||||||
return cleared_count
|
|
||||||
|
|
||||||
def check_and_generate_notifications(self, current_metrics: Dict[str, Any], previous_metrics: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Check metrics and generate notifications for significant events.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_metrics: Current system metrics
|
|
||||||
previous_metrics: Previous system metrics for comparison
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Newly created notifications
|
|
||||||
"""
|
|
||||||
new_notifications = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Skip if no metrics
|
|
||||||
if not current_metrics:
|
|
||||||
logging.warning("[NotificationService] No current metrics available, skipping notification checks")
|
|
||||||
return new_notifications
|
|
||||||
|
|
||||||
# Check for block updates (using persistent storage)
|
|
||||||
last_block_height = current_metrics.get("last_block_height")
|
|
||||||
if last_block_height and last_block_height != "N/A":
|
|
||||||
if self.last_block_height is not None and self.last_block_height != last_block_height:
|
|
||||||
logging.info(f"[NotificationService] Block change detected: {self.last_block_height} -> {last_block_height}")
|
|
||||||
block_notification = self._generate_block_notification(current_metrics)
|
|
||||||
if block_notification:
|
|
||||||
new_notifications.append(block_notification)
|
|
||||||
|
|
||||||
# Always update the stored last block height when it changes
|
|
||||||
if self.last_block_height != last_block_height:
|
|
||||||
self.last_block_height = last_block_height
|
|
||||||
self._save_last_block_height()
|
|
||||||
|
|
||||||
# Regular comparison with previous metrics
|
|
||||||
if previous_metrics:
|
|
||||||
# Check for daily stats
|
|
||||||
if self._should_post_daily_stats():
|
|
||||||
stats_notification = self._generate_daily_stats(current_metrics)
|
|
||||||
if stats_notification:
|
|
||||||
new_notifications.append(stats_notification)
|
|
||||||
|
|
||||||
# Check for significant hashrate drop
|
|
||||||
hashrate_notification = self._check_hashrate_change(current_metrics, previous_metrics)
|
|
||||||
if hashrate_notification:
|
|
||||||
new_notifications.append(hashrate_notification)
|
|
||||||
|
|
||||||
# Check for earnings and payout progress
|
|
||||||
earnings_notification = self._check_earnings_progress(current_metrics, previous_metrics)
|
|
||||||
if earnings_notification:
|
|
||||||
new_notifications.append(earnings_notification)
|
|
||||||
|
|
||||||
return new_notifications
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error generating notifications: {e}")
|
|
||||||
error_notification = self.add_notification(
|
|
||||||
f"Error generating notifications: {str(e)}",
|
|
||||||
level=NotificationLevel.ERROR,
|
|
||||||
category=NotificationCategory.SYSTEM
|
|
||||||
)
|
|
||||||
return [error_notification]
|
|
||||||
|
|
||||||
def _should_post_daily_stats(self) -> bool:
|
|
||||||
"""Check if it's time to post daily stats with improved clarity."""
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
# Only proceed if we're in the target hour and within first 5 minutes
|
|
||||||
if now.hour != DEFAULT_TARGET_HOUR or now.minute >= NOTIFICATION_WINDOW_MINUTES:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# If we have a last_daily_stats timestamp, check if it's a different day
|
|
||||||
if self.last_daily_stats and now.date() <= self.last_daily_stats.date():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# All conditions met, update timestamp and return True
|
|
||||||
logging.info(f"[NotificationService] Posting daily stats at {now.hour}:{now.minute}")
|
|
||||||
self.last_daily_stats = now
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _generate_daily_stats(self, metrics: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Generate daily stats notification."""
|
|
||||||
try:
|
|
||||||
if not metrics:
|
|
||||||
logging.warning("[NotificationService] No metrics available for daily stats")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Format hashrate with appropriate unit
|
|
||||||
hashrate_24hr = metrics.get("hashrate_24hr", 0)
|
|
||||||
hashrate_unit = metrics.get("hashrate_24hr_unit", "TH/s")
|
|
||||||
|
|
||||||
# Format daily earnings
|
|
||||||
daily_mined_sats = metrics.get("daily_mined_sats", 0)
|
|
||||||
daily_profit_usd = metrics.get("daily_profit_usd", 0)
|
|
||||||
|
|
||||||
# Build message
|
|
||||||
message = f"Daily Mining Summary: {hashrate_24hr} {hashrate_unit} average hashrate, {daily_mined_sats} SATS mined (${daily_profit_usd:.2f})"
|
|
||||||
|
|
||||||
# Add notification
|
|
||||||
logging.info(f"[NotificationService] Generating daily stats notification: {message}")
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.INFO,
|
|
||||||
category=NotificationCategory.HASHRATE,
|
|
||||||
data={
|
|
||||||
"hashrate": hashrate_24hr,
|
|
||||||
"unit": hashrate_unit,
|
|
||||||
"daily_sats": daily_mined_sats,
|
|
||||||
"daily_profit": daily_profit_usd
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error generating daily stats notification: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _generate_block_notification(self, metrics: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Generate notification for a new block found."""
|
|
||||||
try:
|
|
||||||
last_block_height = metrics.get("last_block_height", "Unknown")
|
|
||||||
last_block_earnings = metrics.get("last_block_earnings", "0")
|
|
||||||
|
|
||||||
logging.info(f"[NotificationService] Generating block notification: height={last_block_height}, earnings={last_block_earnings}")
|
|
||||||
|
|
||||||
message = f"New block found by the pool! Block #{last_block_height}, earnings: {last_block_earnings} SATS"
|
|
||||||
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.SUCCESS,
|
|
||||||
category=NotificationCategory.BLOCK,
|
|
||||||
data={
|
|
||||||
"block_height": last_block_height,
|
|
||||||
"earnings": last_block_earnings
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error generating block notification: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _parse_numeric_value(self, value_str: Any) -> float:
|
|
||||||
"""Parse numeric values from strings that may include units."""
|
|
||||||
if isinstance(value_str, (int, float)):
|
|
||||||
return float(value_str)
|
|
||||||
|
|
||||||
if isinstance(value_str, str):
|
|
||||||
# Extract just the numeric part
|
|
||||||
parts = value_str.split()
|
|
||||||
try:
|
|
||||||
return float(parts[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def _check_hashrate_change(self, current: Dict[str, Any], previous: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Check for significant hashrate changes using 10-minute average."""
|
|
||||||
try:
|
|
||||||
# Get 10min hashrate values
|
|
||||||
current_10min = current.get("hashrate_10min", 0)
|
|
||||||
previous_10min = previous.get("hashrate_10min", 0)
|
|
||||||
|
|
||||||
# Log what we're comparing
|
|
||||||
logging.debug(f"[NotificationService] Comparing 10min hashrates - current: {current_10min}, previous: {previous_10min}")
|
|
||||||
|
|
||||||
# Skip if values are missing
|
|
||||||
if not current_10min or not previous_10min:
|
|
||||||
logging.debug("[NotificationService] Skipping hashrate check - missing values")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Parse values consistently
|
|
||||||
current_value = self._parse_numeric_value(current_10min)
|
|
||||||
previous_value = self._parse_numeric_value(previous_10min)
|
|
||||||
|
|
||||||
logging.debug(f"[NotificationService] Converted 10min hashrates - current: {current_value}, previous: {previous_value}")
|
|
||||||
|
|
||||||
# Skip if previous was zero (prevents division by zero)
|
|
||||||
if previous_value == 0:
|
|
||||||
logging.debug("[NotificationService] Skipping hashrate check - previous was zero")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate percentage change
|
|
||||||
percent_change = ((current_value - previous_value) / previous_value) * 100
|
|
||||||
logging.debug(f"[NotificationService] 10min hashrate change: {percent_change:.1f}%")
|
|
||||||
|
|
||||||
# Significant decrease
|
|
||||||
if percent_change <= -SIGNIFICANT_HASHRATE_CHANGE_PERCENT:
|
|
||||||
message = f"Significant 10min hashrate drop detected: {abs(percent_change):.1f}% decrease"
|
|
||||||
logging.info(f"[NotificationService] Generating hashrate notification: {message}")
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.WARNING,
|
|
||||||
category=NotificationCategory.HASHRATE,
|
|
||||||
data={
|
|
||||||
"previous": previous_value,
|
|
||||||
"current": current_value,
|
|
||||||
"change": percent_change,
|
|
||||||
"timeframe": "10min"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Significant increase
|
|
||||||
elif percent_change >= SIGNIFICANT_HASHRATE_CHANGE_PERCENT:
|
|
||||||
message = f"10min hashrate increase detected: {percent_change:.1f}% increase"
|
|
||||||
logging.info(f"[NotificationService] Generating hashrate notification: {message}")
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.SUCCESS,
|
|
||||||
category=NotificationCategory.HASHRATE,
|
|
||||||
data={
|
|
||||||
"previous": previous_value,
|
|
||||||
"current": current_value,
|
|
||||||
"change": percent_change,
|
|
||||||
"timeframe": "10min"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error checking hashrate change: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _check_earnings_progress(self, current: Dict[str, Any], previous: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Check for significant earnings progress or payout approach."""
|
|
||||||
try:
|
|
||||||
current_unpaid = self._parse_numeric_value(current.get("unpaid_earnings", "0"))
|
|
||||||
|
|
||||||
# Check if approaching payout
|
|
||||||
if current.get("est_time_to_payout"):
|
|
||||||
est_time = current.get("est_time_to_payout")
|
|
||||||
|
|
||||||
# If estimated time is a number of days
|
|
||||||
if est_time.isdigit() or (est_time[0] == '-' and est_time[1:].isdigit()):
|
|
||||||
days = int(est_time)
|
|
||||||
if 0 < days <= 1:
|
|
||||||
if self._should_send_payout_notification():
|
|
||||||
message = f"Payout approaching! Estimated within 1 day"
|
|
||||||
self.last_payout_notification_time = datetime.now()
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.SUCCESS,
|
|
||||||
category=NotificationCategory.EARNINGS,
|
|
||||||
data={"days_to_payout": days}
|
|
||||||
)
|
|
||||||
# If it says "next block"
|
|
||||||
elif "next block" in est_time.lower():
|
|
||||||
if self._should_send_payout_notification():
|
|
||||||
message = f"Payout expected with next block!"
|
|
||||||
self.last_payout_notification_time = datetime.now()
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.SUCCESS,
|
|
||||||
category=NotificationCategory.EARNINGS,
|
|
||||||
data={"payout_imminent": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for payout (unpaid balance reset)
|
|
||||||
if previous.get("unpaid_earnings"):
|
|
||||||
previous_unpaid = self._parse_numeric_value(previous.get("unpaid_earnings", "0"))
|
|
||||||
|
|
||||||
# If balance significantly decreased, likely a payout occurred
|
|
||||||
if previous_unpaid > 0 and current_unpaid < previous_unpaid * 0.5:
|
|
||||||
message = f"Payout received! Unpaid balance reset from {previous_unpaid} to {current_unpaid} BTC"
|
|
||||||
return self.add_notification(
|
|
||||||
message,
|
|
||||||
level=NotificationLevel.SUCCESS,
|
|
||||||
category=NotificationCategory.EARNINGS,
|
|
||||||
data={
|
|
||||||
"previous_balance": previous_unpaid,
|
|
||||||
"current_balance": current_unpaid,
|
|
||||||
"payout_amount": previous_unpaid - current_unpaid
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[NotificationService] Error checking earnings progress: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _should_send_payout_notification(self) -> bool:
|
|
||||||
"""Check if enough time has passed since the last payout notification."""
|
|
||||||
if self.last_payout_notification_time is None:
|
|
||||||
return True
|
|
||||||
time_since_last_notification = datetime.now() - self.last_payout_notification_time
|
|
||||||
return time_since_last_notification.total_seconds() > ONE_DAY_SECONDS
|
|
@ -1,279 +0,0 @@
|
|||||||
# Enhanced Project Structure Documentation
|
|
||||||
|
|
||||||
This document provides a comprehensive overview of the Bitcoin Mining Dashboard project architecture, component relationships, and technical design decisions.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
DeepSea-Dashboard/
|
|
||||||
│
|
|
||||||
├── App.py # Main application entry point
|
|
||||||
├── config.py # Configuration management
|
|
||||||
├── config.json # Configuration file
|
|
||||||
├── data_service.py # Service for fetching mining data
|
|
||||||
├── models.py # Data models
|
|
||||||
├── state_manager.py # Manager for persistent state
|
|
||||||
├── worker_service.py # Service for worker data management
|
|
||||||
├── notification_service.py # Service for notifications
|
|
||||||
├── minify.py # Script for minifying assets
|
|
||||||
├── setup.py # Setup script for organizing files
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── Dockerfile # Docker configuration
|
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
|
||||||
│
|
|
||||||
├── templates/ # HTML templates
|
|
||||||
│ ├── base.html # Base template with common elements
|
|
||||||
│ ├── boot.html # Boot sequence animation
|
|
||||||
│ ├── dashboard.html # Main dashboard template
|
|
||||||
│ ├── workers.html # Workers dashboard template
|
|
||||||
│ ├── blocks.html # Bitcoin blocks template
|
|
||||||
│ ├── notifications.html # Notifications template
|
|
||||||
│ └── error.html # Error page template
|
|
||||||
│
|
|
||||||
├── static/ # Static assets
|
|
||||||
│ ├── css/ # CSS files
|
|
||||||
│ │ ├── common.css # Shared styles across all pages
|
|
||||||
│ │ ├── dashboard.css # Main dashboard styles
|
|
||||||
│ │ ├── workers.css # Workers page styles
|
|
||||||
│ │ ├── boot.css # Boot sequence styles
|
|
||||||
│ │ ├── blocks.css # Blocks page styles
|
|
||||||
│ │ ├── notifications.css # Notifications page styles
|
|
||||||
│ │ ├── error.css # Error page styles
|
|
||||||
│ │ ├── retro-refresh.css # Floating refresh bar styles
|
|
||||||
│ │ └── theme-toggle.css # Theme toggle styles
|
|
||||||
│ │
|
|
||||||
│ └── js/ # JavaScript files
|
|
||||||
│ ├── main.js # Main dashboard functionality
|
|
||||||
│ ├── workers.js # Workers page functionality
|
|
||||||
│ ├── blocks.js # Blocks page functionality
|
|
||||||
│ ├── notifications.js # Notifications functionality
|
|
||||||
│ ├── block-animation.js # Block mining animation
|
|
||||||
│ ├── BitcoinProgressBar.js # System monitor functionality
|
|
||||||
│ └── theme.js # Theme toggle functionality
|
|
||||||
│
|
|
||||||
├── deployment_steps.md # Deployment guide
|
|
||||||
├── project_structure.md # Additional structure documentation
|
|
||||||
├── LICENSE.md # License information
|
|
||||||
└── logs/ # Application logs (generated at runtime)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### Backend Services
|
|
||||||
|
|
||||||
#### App.py
|
|
||||||
The main Flask application that serves as the entry point. It:
|
|
||||||
- Initializes the application and its components
|
|
||||||
- Configures routes and middleware
|
|
||||||
- Sets up the background scheduler for data updates
|
|
||||||
- Manages Server-Sent Events (SSE) connections
|
|
||||||
- Handles error recovery and graceful shutdown
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
- Custom middleware for error handling
|
|
||||||
- Connection limiting for SSE to prevent resource exhaustion
|
|
||||||
- Watchdog process for scheduler health
|
|
||||||
- Metrics caching with controlled update frequency
|
|
||||||
|
|
||||||
#### data_service.py
|
|
||||||
Service responsible for fetching data from external sources:
|
|
||||||
- Retrieves mining statistics from Ocean.xyz
|
|
||||||
- Collects Bitcoin network data (price, difficulty, hashrate)
|
|
||||||
- Calculates profitability metrics
|
|
||||||
- Handles connection issues and retries
|
|
||||||
|
|
||||||
Notable implementations:
|
|
||||||
- Concurrent API requests using ThreadPoolExecutor
|
|
||||||
- Multiple parsing strategies for resilience against HTML changes
|
|
||||||
- Intelligent caching to reduce API load
|
|
||||||
- Unit normalization for consistent display
|
|
||||||
|
|
||||||
#### worker_service.py
|
|
||||||
Service for managing worker data:
|
|
||||||
- Fetches worker statistics from Ocean.xyz
|
|
||||||
- Simulates worker data when real data is unavailable
|
|
||||||
- Provides filtering and search capabilities
|
|
||||||
- Tracks worker status and performance
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
- Fallback data generation for testing or connectivity issues
|
|
||||||
- Smart worker count synchronization
|
|
||||||
- Hashrate normalization across different units
|
|
||||||
|
|
||||||
#### state_manager.py
|
|
||||||
Manager for application state and history:
|
|
||||||
- Maintains hashrate history and metrics over time
|
|
||||||
- Provides persistence via Redis (optional)
|
|
||||||
- Implements data pruning to prevent memory growth
|
|
||||||
- Records indicator arrows for value changes
|
|
||||||
|
|
||||||
Implementation details:
|
|
||||||
- Thread-safe collections with locking
|
|
||||||
- Optimized storage format for Redis
|
|
||||||
- Data compression techniques for large state objects
|
|
||||||
- Automatic recovery of critical state
|
|
||||||
|
|
||||||
### Frontend Components
|
|
||||||
|
|
||||||
#### Templates
|
|
||||||
The application uses Jinja2 templates with a retro-themed design:
|
|
||||||
- **base.html**: Defines the common layout, navigation, and includes shared assets
|
|
||||||
- **dashboard.html**: Main metrics display with hashrate chart and financial calculations
|
|
||||||
- **workers.html**: Grid layout of worker cards with filtering controls
|
|
||||||
- **blocks.html**: Bitcoin block explorer with detailed information
|
|
||||||
- **boot.html**: Animated terminal boot sequence
|
|
||||||
- **error.html**: Styled error page with technical information
|
|
||||||
|
|
||||||
#### JavaScript Modules
|
|
||||||
Client-side functionality is organized into modular JavaScript files:
|
|
||||||
- **main.js**: Dashboard functionality, real-time updates, and chart rendering
|
|
||||||
- **workers.js**: Worker grid rendering, filtering, and mini-chart creation
|
|
||||||
- **blocks.js**: Block explorer with data fetching from mempool.guide
|
|
||||||
- **block-animation.js**: Interactive block mining animation
|
|
||||||
- **BitcoinProgressBar.js**: Floating system monitor with uptime and connection status
|
|
||||||
|
|
||||||
Key client-side features:
|
|
||||||
- Real-time data updates via Server-Sent Events (SSE)
|
|
||||||
- Automatic reconnection with exponential backoff
|
|
||||||
- Cross-tab synchronization using localStorage
|
|
||||||
- Data normalization for consistent unit display
|
|
||||||
- Animated UI elements for status changes
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
1. **Data Acquisition**:
|
|
||||||
- `data_service.py` fetches data from Ocean.xyz and blockchain sources
|
|
||||||
- Data is normalized, converted, and enriched with calculated metrics
|
|
||||||
- Results are cached in memory
|
|
||||||
|
|
||||||
2. **State Management**:
|
|
||||||
- `state_manager.py` tracks historical data points
|
|
||||||
- Maintains arrow indicators for value changes
|
|
||||||
- Optionally persists state to Redis
|
|
||||||
|
|
||||||
3. **Background Updates**:
|
|
||||||
- Scheduler runs periodic updates (typically once per minute)
|
|
||||||
- Updates are throttled to prevent API overload
|
|
||||||
- Watchdog monitors scheduler health
|
|
||||||
|
|
||||||
4. **Real-time Distribution**:
|
|
||||||
- New data is pushed to clients via Server-Sent Events
|
|
||||||
- Clients process and render updates without page reloads
|
|
||||||
- Connection management prevents resource exhaustion
|
|
||||||
|
|
||||||
5. **Client Rendering**:
|
|
||||||
- Browser receives and processes JSON updates
|
|
||||||
- Chart.js visualizes hashrate trends
|
|
||||||
- DOM updates show changes with visual indicators
|
|
||||||
- BitcoinProgressBar shows system status
|
|
||||||
|
|
||||||
### System Resilience
|
|
||||||
|
|
||||||
The application implements multiple resilience mechanisms:
|
|
||||||
|
|
||||||
#### Server-Side Resilience
|
|
||||||
- **Scheduler Recovery**: Auto-detects and restarts failed schedulers
|
|
||||||
- **Memory Management**: Prunes old data to prevent memory growth
|
|
||||||
- **Connection Limiting**: Caps maximum concurrent SSE connections
|
|
||||||
- **Graceful Degradation**: Falls back to simpler data when sources are unavailable
|
|
||||||
- **Adaptive Parsing**: Multiple strategies to handle API and HTML changes
|
|
||||||
|
|
||||||
#### Client-Side Resilience
|
|
||||||
- **Connection Recovery**: Automatic reconnection with exponential backoff
|
|
||||||
- **Fallback Polling**: Switches to traditional AJAX if SSE fails
|
|
||||||
- **Local Storage Synchronization**: Shares data across browser tabs
|
|
||||||
- **Visibility Handling**: Optimizes updates based on page visibility
|
|
||||||
|
|
||||||
### Technical Design Decisions
|
|
||||||
|
|
||||||
#### Server-Sent Events vs WebSockets
|
|
||||||
The application uses SSE instead of WebSockets because:
|
|
||||||
- Data flow is primarily one-directional (server to client)
|
|
||||||
- SSE has better reconnection handling
|
|
||||||
- Simpler implementation without additional dependencies
|
|
||||||
- Better compatibility with proxy servers
|
|
||||||
|
|
||||||
#### Single Worker Model
|
|
||||||
The application uses a single Gunicorn worker with multiple threads because:
|
|
||||||
- Shared in-memory state is simpler than distributed state
|
|
||||||
- Reduces complexity of synchronization
|
|
||||||
- Most operations are I/O bound, making threads effective
|
|
||||||
- Typical deployments have moderate user counts
|
|
||||||
|
|
||||||
#### Optional Redis Integration
|
|
||||||
Redis usage is optional because:
|
|
||||||
- Small deployments don't require persistence
|
|
||||||
- Makes local development simpler
|
|
||||||
- Allows for flexible deployment options
|
|
||||||
|
|
||||||
#### Hashrate Normalization
|
|
||||||
All hashrates are normalized to TH/s internally because:
|
|
||||||
- Provides consistent basis for comparisons
|
|
||||||
- Simplifies trend calculations and charts
|
|
||||||
- Allows for unit conversion on display
|
|
||||||
|
|
||||||
## Component Interactions
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ Ocean.xyz API │ │ blockchain.info │ │ mempool.guide │
|
|
||||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ data_service.py │
|
|
||||||
└────────────────────────────────┬───────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ App.py │
|
|
||||||
├────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ worker_service │ │ state_manager │ │ Background Jobs │ │
|
|
||||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Flask Routes & SSE │
|
|
||||||
└───────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ Browser Tab 1 │ │ Browser Tab N │
|
|
||||||
└─────────────────┘ └─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Memory Usage
|
|
||||||
- Arrow history is pruned to prevent unbounded growth
|
|
||||||
- Older data points are stored at reduced resolution
|
|
||||||
- Regular garbage collection cycles are scheduled
|
|
||||||
- Memory usage is logged for monitoring
|
|
||||||
|
|
||||||
### Network Optimization
|
|
||||||
- Data is cached to reduce API calls
|
|
||||||
- Updates are throttled to reasonable frequencies
|
|
||||||
- SSE connections have a maximum lifetime
|
|
||||||
- Failed connections use exponential backoff
|
|
||||||
|
|
||||||
### Browser Performance
|
|
||||||
- Charts use optimized rendering with limited animation
|
|
||||||
- DOM updates are batched where possible
|
|
||||||
- Data is processed in small chunks
|
|
||||||
- CSS transitions are used for smooth animations
|
|
||||||
|
|
||||||
## Future Enhancement Areas
|
|
||||||
|
|
||||||
1. **Database Integration**: Option for SQL database for long-term metrics storage
|
|
||||||
2. **User Authentication**: Multi-user support with separate configurations
|
|
||||||
3. **Mining Pool Expansion**: Support for additional mining pools beyond Ocean.xyz
|
|
||||||
4. **Mobile App**: Dedicated mobile application with push notifications
|
|
||||||
5. **Advanced Analytics**: Profitability projections and historical analysis
|
|
@ -6,17 +6,4 @@ gunicorn==22.0.0
|
|||||||
htmlmin==0.1.12
|
htmlmin==0.1.12
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
psutil==5.9.5
|
psutil==5.9.5
|
||||||
Werkzeug==2.3.7
|
|
||||||
Jinja2==3.1.2
|
|
||||||
itsdangerous==2.1.2
|
|
||||||
MarkupSafe==2.1.3
|
|
||||||
soupsieve==2.5
|
|
||||||
tzdata==2023.3
|
|
||||||
pytz==2023.3
|
|
||||||
tzlocal==5.0.1
|
|
||||||
urllib3==2.0.7
|
|
||||||
idna==3.4
|
|
||||||
certifi==2023.7.22
|
|
||||||
six==1.16.0
|
|
||||||
jsmin==3.0.1
|
|
@ -70,6 +70,7 @@ body {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1rem; /* Match card header font size */
|
font-size: 1.1rem; /* Match card header font size */
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
text-shadow: 0 0 5px var(--primary-color);
|
||||||
animation: flicker 4s infinite; /* Add flicker animation from card headers */
|
animation: flicker 4s infinite; /* Add flicker animation from card headers */
|
||||||
font-family: var(--header-font); /* Use the same font variable */
|
font-family: var(--header-font); /* Use the same font variable */
|
||||||
padding: 0.3rem 0; /* Match card header padding */
|
padding: 0.3rem 0; /* Match card header padding */
|
||||||
@ -231,6 +232,7 @@ body {
|
|||||||
#retro-terminal-bar #progress-text {
|
#retro-terminal-bar #progress-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--terminal-text);
|
color: var(--terminal-text);
|
||||||
|
text-shadow: 0 0 5px var(--terminal-text);
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -240,6 +242,7 @@ body {
|
|||||||
#retro-terminal-bar #uptimeTimer {
|
#retro-terminal-bar #uptimeTimer {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--terminal-text);
|
color: var(--terminal-text);
|
||||||
|
text-shadow: 0 0 5px var(--terminal-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@ -351,7 +354,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-title {
|
.terminal-title {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dot {
|
.terminal-dot {
|
||||||
@ -363,76 +366,4 @@ body {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Add these styles to retro-refresh.css to make the progress bar transitions smoother */
|
|
||||||
|
|
||||||
/* Smooth transition for progress bar width */
|
|
||||||
#retro-terminal-bar #bitcoin-progress-inner {
|
|
||||||
transition: width 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add a will-change property to optimize the animation */
|
|
||||||
#retro-terminal-bar .bitcoin-progress-container {
|
|
||||||
will-change: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth transition when changing from waiting state */
|
|
||||||
#retro-terminal-bar #bitcoin-progress-inner.waiting-for-update {
|
|
||||||
transition: width 0.3s ease-out, box-shadow 1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure the scan line stays smooth during transitions */
|
|
||||||
#retro-terminal-bar .scan-line {
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
/* Improve mobile centering for collapsed system monitor */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
/* Target both possible selectors to ensure we catch the right one */
|
|
||||||
#retro-terminal-bar.collapsed,
|
|
||||||
.bitcoin-terminal.collapsed,
|
|
||||||
.retro-terminal-bar.collapsed,
|
|
||||||
div[id*="terminal"].collapsed {
|
|
||||||
left: 50% !important;
|
|
||||||
right: auto !important;
|
|
||||||
transform: translateX(-50%) !important;
|
|
||||||
width: auto !important;
|
|
||||||
max-width: 300px !important; /* Smaller max-width for mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure consistent height for minimized view */
|
|
||||||
.terminal-minimized {
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make the terminal draggable in desktop view */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
/* Target both possible selectors to handle all cases */
|
|
||||||
#bitcoin-terminal,
|
|
||||||
.bitcoin-terminal,
|
|
||||||
#retro-terminal-bar {
|
|
||||||
cursor: grab; /* Show a grab cursor to indicate draggability */
|
|
||||||
user-select: none; /* Prevent text selection during drag */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Change cursor during active dragging */
|
|
||||||
#bitcoin-terminal.dragging,
|
|
||||||
.bitcoin-terminal.dragging,
|
|
||||||
#retro-terminal-bar.dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style for drag handle in the header */
|
|
||||||
.terminal-header {
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-header::before {
|
|
||||||
content: "⋮⋮"; /* Add drag indicator */
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
238
retro-refresh.js
Normal file
238
retro-refresh.js
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
// This script integrates the retro floating refresh bar
|
||||||
|
// with the existing dashboard and workers page functionality
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Create the retro terminal bar if it doesn't exist yet
|
||||||
|
if (!document.getElementById('retro-terminal-bar')) {
|
||||||
|
createRetroTerminalBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the original refresh container
|
||||||
|
const originalRefreshUptime = document.getElementById('refreshUptime');
|
||||||
|
if (originalRefreshUptime) {
|
||||||
|
originalRefreshUptime.style.visibility = 'hidden';
|
||||||
|
originalRefreshUptime.style.height = '0';
|
||||||
|
originalRefreshUptime.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Important: We keep the original elements and just hide them
|
||||||
|
// This ensures all existing JavaScript functions still work
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra space at the bottom of the page to prevent the floating bar from covering content
|
||||||
|
const extraSpace = document.createElement('div');
|
||||||
|
extraSpace.style.height = '100px';
|
||||||
|
document.body.appendChild(extraSpace);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to create the retro terminal bar
|
||||||
|
function createRetroTerminalBar() {
|
||||||
|
// Get the HTML content from the shared CSS/HTML
|
||||||
|
const html = `
|
||||||
|
<div id="retro-terminal-bar">
|
||||||
|
<div class="terminal-header">
|
||||||
|
<div class="terminal-title">SYSTEM MONITOR v0.1</div>
|
||||||
|
<div class="terminal-controls">
|
||||||
|
<div class="terminal-dot minimize" title="Minimize" onclick="toggleTerminal()"></div>
|
||||||
|
<div class="terminal-dot close" title="Close" onclick="hideTerminal()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-content">
|
||||||
|
<div class="status-indicators">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<div class="status-dot connected"></div>
|
||||||
|
<span>LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span id="data-refresh-time">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="refreshContainer">
|
||||||
|
<!-- Enhanced progress bar with tick marks -->
|
||||||
|
<div class="bitcoin-progress-container">
|
||||||
|
<div id="bitcoin-progress-inner">
|
||||||
|
<div class="scan-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-ticks">
|
||||||
|
<span>0s</span>
|
||||||
|
<span>15s</span>
|
||||||
|
<span>30s</span>
|
||||||
|
<span>45s</span>
|
||||||
|
<span>60s</span>
|
||||||
|
</div>
|
||||||
|
<!-- Add tick marks every 5 seconds -->
|
||||||
|
<div class="tick-mark major" style="left: 0%"></div>
|
||||||
|
<div class="tick-mark" style="left: 8.33%"></div>
|
||||||
|
<div class="tick-mark" style="left: 16.67%"></div>
|
||||||
|
<div class="tick-mark major" style="left: 25%"></div>
|
||||||
|
<div class="tick-mark" style="left: 33.33%"></div>
|
||||||
|
<div class="tick-mark" style="left: 41.67%"></div>
|
||||||
|
<div class="tick-mark major" style="left: 50%"></div>
|
||||||
|
<div class="tick-mark" style="left: 58.33%"></div>
|
||||||
|
<div class="tick-mark" style="left: 66.67%"></div>
|
||||||
|
<div class="tick-mark major" style="left: 75%"></div>
|
||||||
|
<div class="tick-mark" style="left: 83.33%"></div>
|
||||||
|
<div class="tick-mark" style="left: 91.67%"></div>
|
||||||
|
<div class="tick-mark major" style="left: 100%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progress-text">60s to next update</div>
|
||||||
|
<div id="uptimeTimer"><strong>Uptime:</strong> 0h 0m 0s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create a container for the HTML
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Append to the body
|
||||||
|
document.body.appendChild(container.firstElementChild);
|
||||||
|
|
||||||
|
// Start the clock update
|
||||||
|
updateTerminalClock();
|
||||||
|
setInterval(updateTerminalClock, 1000);
|
||||||
|
|
||||||
|
// Check if terminal should be collapsed based on previous state
|
||||||
|
const isCollapsed = localStorage.getItem('terminalCollapsed') === 'true';
|
||||||
|
if (isCollapsed) {
|
||||||
|
document.getElementById('retro-terminal-bar').classList.add('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update the terminal clock
|
||||||
|
function updateTerminalClock() {
|
||||||
|
const clockElement = document.getElementById('data-refresh-time');
|
||||||
|
if (clockElement) {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
clockElement.textContent = `${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose these functions globally for the onclick handlers
|
||||||
|
window.toggleTerminal = function() {
|
||||||
|
const terminal = document.getElementById('retro-terminal-bar');
|
||||||
|
terminal.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
// Store state in localStorage
|
||||||
|
localStorage.setItem('terminalCollapsed', terminal.classList.contains('collapsed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.hideTerminal = function() {
|
||||||
|
document.getElementById('retro-terminal-bar').style.display = 'none';
|
||||||
|
|
||||||
|
// Create a show button that appears at the bottom right
|
||||||
|
const showButton = document.createElement('button');
|
||||||
|
showButton.id = 'show-terminal-button';
|
||||||
|
showButton.textContent = 'Show Monitor';
|
||||||
|
showButton.style.position = 'fixed';
|
||||||
|
showButton.style.bottom = '10px';
|
||||||
|
showButton.style.right = '10px';
|
||||||
|
showButton.style.zIndex = '1000';
|
||||||
|
showButton.style.backgroundColor = '#f7931a';
|
||||||
|
showButton.style.color = '#000';
|
||||||
|
showButton.style.border = 'none';
|
||||||
|
showButton.style.padding = '8px 12px';
|
||||||
|
showButton.style.cursor = 'pointer';
|
||||||
|
showButton.style.fontFamily = "'VT323', monospace";
|
||||||
|
showButton.style.fontSize = '14px';
|
||||||
|
showButton.onclick = function() {
|
||||||
|
document.getElementById('retro-terminal-bar').style.display = 'block';
|
||||||
|
this.remove();
|
||||||
|
};
|
||||||
|
document.body.appendChild(showButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirect original progress bar updates to our new floating bar
|
||||||
|
// This Observer will listen for changes to the original #bitcoin-progress-inner
|
||||||
|
// and replicate them to our new floating bar version
|
||||||
|
const initProgressObserver = function() {
|
||||||
|
// Setup a MutationObserver to watch for style changes on the original progress bar
|
||||||
|
const originalProgressBar = document.querySelector('#refreshUptime #bitcoin-progress-inner');
|
||||||
|
const newProgressBar = document.querySelector('#retro-terminal-bar #bitcoin-progress-inner');
|
||||||
|
|
||||||
|
if (originalProgressBar && newProgressBar) {
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.attributeName === 'style') {
|
||||||
|
// Get the width from the original progress bar
|
||||||
|
const width = originalProgressBar.style.width;
|
||||||
|
if (width) {
|
||||||
|
// Apply it to our new progress bar
|
||||||
|
newProgressBar.style.width = width;
|
||||||
|
|
||||||
|
// Also copy any classes (like glow-effect)
|
||||||
|
if (originalProgressBar.classList.contains('glow-effect') &&
|
||||||
|
!newProgressBar.classList.contains('glow-effect')) {
|
||||||
|
newProgressBar.classList.add('glow-effect');
|
||||||
|
} else if (!originalProgressBar.classList.contains('glow-effect') &&
|
||||||
|
newProgressBar.classList.contains('glow-effect')) {
|
||||||
|
newProgressBar.classList.remove('glow-effect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy waiting-for-update class
|
||||||
|
if (originalProgressBar.classList.contains('waiting-for-update') &&
|
||||||
|
!newProgressBar.classList.contains('waiting-for-update')) {
|
||||||
|
newProgressBar.classList.add('waiting-for-update');
|
||||||
|
} else if (!originalProgressBar.classList.contains('waiting-for-update') &&
|
||||||
|
newProgressBar.classList.contains('waiting-for-update')) {
|
||||||
|
newProgressBar.classList.remove('waiting-for-update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing
|
||||||
|
observer.observe(originalProgressBar, { attributes: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also watch for changes to the progress text
|
||||||
|
const originalProgressText = document.querySelector('#refreshUptime #progress-text');
|
||||||
|
const newProgressText = document.querySelector('#retro-terminal-bar #progress-text');
|
||||||
|
|
||||||
|
if (originalProgressText && newProgressText) {
|
||||||
|
const textObserver = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
// Update the text in our new bar
|
||||||
|
newProgressText.textContent = originalProgressText.textContent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing
|
||||||
|
textObserver.observe(originalProgressText, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes to the uptime timer
|
||||||
|
const originalUptimeTimer = document.querySelector('#refreshUptime #uptimeTimer');
|
||||||
|
const newUptimeTimer = document.querySelector('#retro-terminal-bar #uptimeTimer');
|
||||||
|
|
||||||
|
if (originalUptimeTimer && newUptimeTimer) {
|
||||||
|
const uptimeObserver = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
// Update the text in our new bar
|
||||||
|
newUptimeTimer.innerHTML = originalUptimeTimer.innerHTML;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing
|
||||||
|
uptimeObserver.observe(originalUptimeTimer, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the observer once the page is fully loaded
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
// Give a short delay to ensure all elements are rendered
|
||||||
|
setTimeout(initProgressObserver, 500);
|
||||||
|
});
|
||||||
|
})();
|
520
setup.py
520
setup.py
@ -1,520 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Enhanced setup script for Bitcoin Mining Dashboard.
|
|
||||||
|
|
||||||
This script prepares the project structure, installs dependencies,
|
|
||||||
verifies configuration, and provides system checks for optimal operation.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import shutil
|
|
||||||
import logging
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configure logging with color support
|
|
||||||
try:
|
|
||||||
import colorlog
|
|
||||||
|
|
||||||
handler = colorlog.StreamHandler()
|
|
||||||
handler.setFormatter(colorlog.ColoredFormatter(
|
|
||||||
'%(log_color)s%(asctime)s - %(levelname)s - %(message)s',
|
|
||||||
log_colors={
|
|
||||||
'DEBUG': 'cyan',
|
|
||||||
'INFO': 'green',
|
|
||||||
'WARNING': 'yellow',
|
|
||||||
'ERROR': 'red',
|
|
||||||
'CRITICAL': 'red,bg_white',
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
logger = colorlog.getLogger()
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
except ImportError:
|
|
||||||
# Fallback to standard logging if colorlog is not available
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger()
|
|
||||||
|
|
||||||
# Directory structure to create
|
|
||||||
DIRECTORIES = [
|
|
||||||
'static/css',
|
|
||||||
'static/js',
|
|
||||||
'static/js/min', # For minified JS files
|
|
||||||
'templates',
|
|
||||||
'logs',
|
|
||||||
'data' # For temporary data storage
|
|
||||||
]
|
|
||||||
|
|
||||||
# Files to move to their correct locations
|
|
||||||
FILE_MAPPINGS = {
|
|
||||||
# CSS files
|
|
||||||
'common.css': 'static/css/common.css',
|
|
||||||
'dashboard.css': 'static/css/dashboard.css',
|
|
||||||
'workers.css': 'static/css/workers.css',
|
|
||||||
'boot.css': 'static/css/boot.css',
|
|
||||||
'error.css': 'static/css/error.css',
|
|
||||||
'retro-refresh.css': 'static/css/retro-refresh.css',
|
|
||||||
'blocks.css': 'static/css/blocks.css',
|
|
||||||
'notifications.css': 'static/css/notifications.css',
|
|
||||||
'theme-toggle.css': 'static/css/theme-toggle.css', # Added theme-toggle.css
|
|
||||||
|
|
||||||
# JS files
|
|
||||||
'main.js': 'static/js/main.js',
|
|
||||||
'workers.js': 'static/js/workers.js',
|
|
||||||
'blocks.js': 'static/js/blocks.js',
|
|
||||||
'BitcoinProgressBar.js': 'static/js/BitcoinProgressBar.js',
|
|
||||||
'notifications.js': 'static/js/notifications.js',
|
|
||||||
'theme.js': 'static/js/theme.js', # Added theme.js
|
|
||||||
|
|
||||||
# Template files
|
|
||||||
'base.html': 'templates/base.html',
|
|
||||||
'dashboard.html': 'templates/dashboard.html',
|
|
||||||
'workers.html': 'templates/workers.html',
|
|
||||||
'boot.html': 'templates/boot.html',
|
|
||||||
'error.html': 'templates/error.html',
|
|
||||||
'blocks.html': 'templates/blocks.html',
|
|
||||||
'notifications.html': 'templates/notifications.html',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default configuration
|
|
||||||
DEFAULT_CONFIG = {
|
|
||||||
"power_cost": 0.0,
|
|
||||||
"power_usage": 0.0,
|
|
||||||
"wallet": "yourwallethere",
|
|
||||||
"timezone": "America/Los_Angeles", # Added default timezone
|
|
||||||
"network_fee": 0.0 # Added default network fee
|
|
||||||
}
|
|
||||||
|
|
||||||
def parse_arguments():
|
|
||||||
"""Parse command line arguments."""
|
|
||||||
parser = argparse.ArgumentParser(description='Setup the Bitcoin Mining Dashboard')
|
|
||||||
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
|
|
||||||
parser.add_argument('--wallet', type=str, help='Set your Ocean.xyz wallet address')
|
|
||||||
parser.add_argument('--power-cost', type=float, help='Set your electricity cost per kWh')
|
|
||||||
parser.add_argument('--power-usage', type=float, help='Set your power consumption in watts')
|
|
||||||
parser.add_argument('--network-fee', type=float, help='Set your network fee percentage') # Added network fee parameter
|
|
||||||
parser.add_argument('--timezone', type=str, help='Set your timezone (e.g., America/Los_Angeles)') # Added timezone parameter
|
|
||||||
parser.add_argument('--skip-checks', action='store_true', help='Skip dependency checks')
|
|
||||||
parser.add_argument('--force', action='store_true', help='Force file overwrite')
|
|
||||||
parser.add_argument('--config', type=str, help='Path to custom config.json')
|
|
||||||
parser.add_argument('--minify', action='store_true', help='Minify JavaScript files')
|
|
||||||
parser.add_argument('--theme', choices=['bitcoin', 'deepsea'], help='Set the default UI theme') # Added theme parameter
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
def create_directory_structure():
|
|
||||||
"""Create the necessary directory structure."""
|
|
||||||
logger.info("Creating directory structure...")
|
|
||||||
success = True
|
|
||||||
|
|
||||||
for directory in DIRECTORIES:
|
|
||||||
try:
|
|
||||||
os.makedirs(directory, exist_ok=True)
|
|
||||||
logger.debug(f"Created directory: {directory}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create directory {directory}: {str(e)}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info("✓ Directory structure created successfully")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠ Some directories could not be created")
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
def move_files(force=False):
|
|
||||||
"""
|
|
||||||
Move files to their correct locations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force (bool): Force overwriting of existing files
|
|
||||||
"""
|
|
||||||
logger.info("Moving files to their correct locations...")
|
|
||||||
success = True
|
|
||||||
moved_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
missing_count = 0
|
|
||||||
|
|
||||||
for source, destination in FILE_MAPPINGS.items():
|
|
||||||
if os.path.exists(source):
|
|
||||||
# Create the directory if it doesn't exist
|
|
||||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
||||||
|
|
||||||
# Check if destination exists and handle according to force flag
|
|
||||||
if os.path.exists(destination) and not force:
|
|
||||||
logger.debug(f"Skipped {source} (destination already exists)")
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Copy the file to its destination
|
|
||||||
shutil.copy2(source, destination)
|
|
||||||
logger.debug(f"Moved {source} to {destination}")
|
|
||||||
moved_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to copy {source} to {destination}: {str(e)}")
|
|
||||||
success = False
|
|
||||||
else:
|
|
||||||
logger.warning(f"Source file not found: {source}")
|
|
||||||
missing_count += 1
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"✓ File movement completed: {moved_count} moved, {skipped_count} skipped, {missing_count} missing")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠ Some files could not be moved")
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
def minify_js_files():
|
|
||||||
"""Minify JavaScript files."""
|
|
||||||
logger.info("Minifying JavaScript files...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import jsmin
|
|
||||||
except ImportError:
|
|
||||||
logger.error("jsmin package not found. Installing...")
|
|
||||||
try:
|
|
||||||
subprocess.run([sys.executable, "-m", "pip", "install", "jsmin"], check=True)
|
|
||||||
import jsmin
|
|
||||||
logger.info("✓ jsmin package installed successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to install jsmin: {str(e)}")
|
|
||||||
logger.error("Please run: pip install jsmin")
|
|
||||||
return False
|
|
||||||
|
|
||||||
js_dir = 'static/js'
|
|
||||||
min_dir = os.path.join(js_dir, 'min')
|
|
||||||
os.makedirs(min_dir, exist_ok=True)
|
|
||||||
|
|
||||||
minified_count = 0
|
|
||||||
for js_file in os.listdir(js_dir):
|
|
||||||
if js_file.endswith('.js') and not js_file.endswith('.min.js'):
|
|
||||||
input_path = os.path.join(js_dir, js_file)
|
|
||||||
output_path = os.path.join(min_dir, js_file.replace('.js', '.min.js'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(input_path, 'r') as f:
|
|
||||||
js_content = f.read()
|
|
||||||
|
|
||||||
# Minify the content
|
|
||||||
minified = jsmin.jsmin(js_content)
|
|
||||||
|
|
||||||
# Write minified content
|
|
||||||
with open(output_path, 'w') as f:
|
|
||||||
f.write(minified)
|
|
||||||
|
|
||||||
minified_count += 1
|
|
||||||
logger.debug(f"Minified {js_file}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to minify {js_file}: {str(e)}")
|
|
||||||
|
|
||||||
logger.info(f"✓ JavaScript minification completed: {minified_count} files processed")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def validate_wallet_address(wallet):
|
|
||||||
"""
|
|
||||||
Validate Bitcoin wallet address format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wallet (str): Bitcoin wallet address
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if valid, False otherwise
|
|
||||||
"""
|
|
||||||
# Basic validation patterns for different Bitcoin address formats
|
|
||||||
patterns = [
|
|
||||||
r'^1[a-km-zA-HJ-NP-Z1-9]{25,34}$', # Legacy
|
|
||||||
r'^3[a-km-zA-HJ-NP-Z1-9]{25,34}$', # P2SH
|
|
||||||
r'^bc1[a-zA-Z0-9]{39,59}$', # Bech32
|
|
||||||
r'^bc1p[a-zA-Z0-9]{39,59}$', # Taproot
|
|
||||||
r'^bc1p[a-z0-9]{73,107}$' # Longform Taproot
|
|
||||||
]
|
|
||||||
|
|
||||||
# Check if the wallet matches any of the patterns
|
|
||||||
for pattern in patterns:
|
|
||||||
if re.match(pattern, wallet):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def create_config(args):
|
|
||||||
"""
|
|
||||||
Create or update config.json file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Command line arguments
|
|
||||||
"""
|
|
||||||
config_file = args.config if args.config else 'config.json'
|
|
||||||
config = DEFAULT_CONFIG.copy()
|
|
||||||
|
|
||||||
# Load existing config if available
|
|
||||||
if os.path.exists(config_file):
|
|
||||||
try:
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
existing_config = json.load(f)
|
|
||||||
config.update(existing_config)
|
|
||||||
logger.info(f"Loaded existing configuration from {config_file}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f"Invalid JSON in {config_file}, using default configuration")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error reading {config_file}: {str(e)}")
|
|
||||||
|
|
||||||
# Update config from command line arguments
|
|
||||||
if args.wallet:
|
|
||||||
if validate_wallet_address(args.wallet):
|
|
||||||
config["wallet"] = args.wallet
|
|
||||||
else:
|
|
||||||
logger.warning(f"Invalid wallet address format: {args.wallet}")
|
|
||||||
logger.warning("Using default or existing wallet address")
|
|
||||||
|
|
||||||
if args.power_cost is not None:
|
|
||||||
if args.power_cost >= 0:
|
|
||||||
config["power_cost"] = args.power_cost
|
|
||||||
else:
|
|
||||||
logger.warning("Power cost cannot be negative, using default or existing value")
|
|
||||||
|
|
||||||
if args.power_usage is not None:
|
|
||||||
if args.power_usage >= 0:
|
|
||||||
config["power_usage"] = args.power_usage
|
|
||||||
else:
|
|
||||||
logger.warning("Power usage cannot be negative, using default or existing value")
|
|
||||||
|
|
||||||
# Update config from command line arguments
|
|
||||||
if args.timezone:
|
|
||||||
config["timezone"] = args.timezone
|
|
||||||
|
|
||||||
if args.network_fee is not None:
|
|
||||||
if args.network_fee >= 0:
|
|
||||||
config["network_fee"] = args.network_fee
|
|
||||||
else:
|
|
||||||
logger.warning("Network fee cannot be negative, using default or existing value")
|
|
||||||
|
|
||||||
if args.theme:
|
|
||||||
config["theme"] = args.theme
|
|
||||||
|
|
||||||
# Save the configuration
|
|
||||||
try:
|
|
||||||
with open(config_file, 'w') as f:
|
|
||||||
json.dump(config, f, indent=2, sort_keys=True)
|
|
||||||
logger.info(f"✓ Configuration saved to {config_file}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save configuration: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Print current configuration
|
|
||||||
logger.info("Current configuration:")
|
|
||||||
logger.info(f" ├── Wallet address: {config['wallet']}")
|
|
||||||
logger.info(f" ├── Power cost: ${config['power_cost']} per kWh")
|
|
||||||
logger.info(f" ├── Power usage: {config['power_usage']} watts")
|
|
||||||
logger.info(f" ├── Network fee: {config['network_fee']}%")
|
|
||||||
logger.info(f" └── Timezone: {config['timezone']}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def check_dependencies(skip=False):
|
|
||||||
"""
|
|
||||||
Check if required Python dependencies are installed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skip (bool): Skip the dependency check
|
|
||||||
"""
|
|
||||||
if skip:
|
|
||||||
logger.info("Skipping dependency check")
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Checking dependencies...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if pip is available
|
|
||||||
subprocess.run([sys.executable, "-m", "pip", "--version"],
|
|
||||||
check=True, capture_output=True, text=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Pip is not available: {str(e)}")
|
|
||||||
logger.error("Please install pip before continuing")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if requirements.txt exists
|
|
||||||
if not os.path.exists('requirements.txt'):
|
|
||||||
logger.error("requirements.txt not found")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check currently installed packages
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "freeze"],
|
|
||||||
check=True, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
installed_output = result.stdout
|
|
||||||
installed_packages = {
|
|
||||||
line.split('==')[0].lower(): line.split('==')[1] if '==' in line else ''
|
|
||||||
for line in installed_output.splitlines()
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to check installed packages: {str(e)}")
|
|
||||||
installed_packages = {}
|
|
||||||
|
|
||||||
# Read requirements
|
|
||||||
try:
|
|
||||||
with open('requirements.txt', 'r') as f:
|
|
||||||
requirements = f.read().splitlines()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to read requirements.txt: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check each requirement
|
|
||||||
missing_packages = []
|
|
||||||
for req in requirements:
|
|
||||||
if req and not req.startswith('#'):
|
|
||||||
package = req.split('==')[0].lower()
|
|
||||||
if package not in installed_packages:
|
|
||||||
missing_packages.append(req)
|
|
||||||
|
|
||||||
if missing_packages:
|
|
||||||
logger.warning(f"Missing {len(missing_packages)} required packages")
|
|
||||||
logger.info("Installing missing packages...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
|
||||||
check=True, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
logger.info("✓ Dependencies installed successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to install dependencies: {str(e)}")
|
|
||||||
logger.error("Please run: pip install -r requirements.txt")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
logger.info("✓ All required packages are installed")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def check_redis():
|
|
||||||
"""Check if Redis is available."""
|
|
||||||
logger.info("Checking Redis availability...")
|
|
||||||
|
|
||||||
redis_url = os.environ.get("REDIS_URL")
|
|
||||||
if not redis_url:
|
|
||||||
logger.info("⚠ Redis URL not configured (REDIS_URL environment variable not set)")
|
|
||||||
logger.info(" └── The dashboard will run without persistent state")
|
|
||||||
logger.info(" └── Set REDIS_URL for better reliability")
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
import redis
|
|
||||||
client = redis.Redis.from_url(redis_url)
|
|
||||||
client.ping()
|
|
||||||
logger.info(f"✓ Successfully connected to Redis at {redis_url}")
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("Redis Python package not installed")
|
|
||||||
logger.info(" └── Run: pip install redis")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to connect to Redis: {str(e)}")
|
|
||||||
logger.info(f" └── Check that Redis is running and accessible at {redis_url}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def perform_system_checks():
|
|
||||||
"""Perform system checks and provide recommendations."""
|
|
||||||
logger.info("Performing system checks...")
|
|
||||||
|
|
||||||
# Check Python version
|
|
||||||
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
||||||
if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 9):
|
|
||||||
logger.warning(f"⚠ Python version {python_version} is below recommended (3.9+)")
|
|
||||||
else:
|
|
||||||
logger.info(f"✓ Python version {python_version} is compatible")
|
|
||||||
|
|
||||||
# Check available memory
|
|
||||||
try:
|
|
||||||
import psutil
|
|
||||||
memory = psutil.virtual_memory()
|
|
||||||
memory_gb = memory.total / (1024**3)
|
|
||||||
if memory_gb < 1:
|
|
||||||
logger.warning(f"⚠ Low system memory: {memory_gb:.2f} GB (recommended: 1+ GB)")
|
|
||||||
else:
|
|
||||||
logger.info(f"✓ System memory: {memory_gb:.2f} GB")
|
|
||||||
except ImportError:
|
|
||||||
logger.debug("psutil not available, skipping memory check")
|
|
||||||
|
|
||||||
# Check write permissions
|
|
||||||
log_dir = 'logs'
|
|
||||||
try:
|
|
||||||
test_file = os.path.join(log_dir, 'test_write.tmp')
|
|
||||||
with open(test_file, 'w') as f:
|
|
||||||
f.write('test')
|
|
||||||
os.remove(test_file)
|
|
||||||
logger.info(f"✓ Write permissions for logs directory")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠ Cannot write to logs directory: {str(e)}")
|
|
||||||
|
|
||||||
# Check port availability
|
|
||||||
port = 5000 # Default port
|
|
||||||
try:
|
|
||||||
import socket
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.bind(('localhost', port))
|
|
||||||
s.close()
|
|
||||||
logger.info(f"✓ Port {port} is available")
|
|
||||||
except Exception:
|
|
||||||
logger.warning(f"⚠ Port {port} is already in use")
|
|
||||||
|
|
||||||
logger.info("System checks completed")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main setup function."""
|
|
||||||
args = parse_arguments()
|
|
||||||
|
|
||||||
# Set logging level
|
|
||||||
if args.debug:
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
logger.debug("Debug logging enabled")
|
|
||||||
|
|
||||||
logger.info("=== Bitcoin Mining Dashboard Setup ===")
|
|
||||||
|
|
||||||
# Check dependencies
|
|
||||||
if not check_dependencies(args.skip_checks):
|
|
||||||
logger.error("Dependency check failed. Please install required packages and retry.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Create directory structure
|
|
||||||
if not create_directory_structure():
|
|
||||||
logger.error("Failed to create directory structure.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Move files to their correct locations
|
|
||||||
if not move_files(args.force):
|
|
||||||
logger.warning("Some files could not be moved, but continuing...")
|
|
||||||
|
|
||||||
# Create or update configuration
|
|
||||||
if not create_config(args):
|
|
||||||
logger.error("Failed to create configuration file.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Minify JavaScript files if requested
|
|
||||||
if args.minify:
|
|
||||||
if not minify_js_files():
|
|
||||||
logger.warning("JavaScript minification failed, but continuing...")
|
|
||||||
|
|
||||||
# Check Redis if available
|
|
||||||
check_redis()
|
|
||||||
|
|
||||||
# Perform system checks
|
|
||||||
if not args.skip_checks:
|
|
||||||
perform_system_checks()
|
|
||||||
|
|
||||||
logger.info("=== Setup completed successfully ===")
|
|
||||||
logger.info("")
|
|
||||||
logger.info("Next steps:")
|
|
||||||
logger.info("1. Verify configuration in config.json")
|
|
||||||
logger.info("2. Start the application with: python App.py")
|
|
||||||
logger.info("3. Access the dashboard at: http://localhost:5000")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
exit_code = main()
|
|
||||||
sys.exit(exit_code)
|
|
469
state_manager.py
469
state_manager.py
@ -1,469 +0,0 @@
|
|||||||
"""
|
|
||||||
State manager module for handling persistent state and history.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import gc
|
|
||||||
import threading
|
|
||||||
import redis
|
|
||||||
from config import get_timezone
|
|
||||||
|
|
||||||
# Global variables for arrow history, legacy hashrate history, and a log of full metrics snapshots.
|
|
||||||
arrow_history = {} # stored per second
|
|
||||||
hashrate_history = []
|
|
||||||
metrics_log = []
|
|
||||||
|
|
||||||
# Limits for data collections to prevent memory growth
|
|
||||||
MAX_HISTORY_ENTRIES = 180 # 3 hours worth at 1 min intervals
|
|
||||||
|
|
||||||
# Lock for thread safety
|
|
||||||
state_lock = threading.Lock()
|
|
||||||
|
|
||||||
class StateManager:
|
|
||||||
"""Manager for persistent state and history data."""
|
|
||||||
|
|
||||||
def __init__(self, redis_url=None):
|
|
||||||
"""
|
|
||||||
Initialize the state manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
redis_url (str, optional): Redis URL for persistent storage
|
|
||||||
"""
|
|
||||||
self.redis_client = self._connect_to_redis(redis_url) if redis_url else None
|
|
||||||
self.STATE_KEY = "graph_state"
|
|
||||||
self.last_save_time = 0
|
|
||||||
|
|
||||||
# Load state if available
|
|
||||||
self.load_graph_state()
|
|
||||||
|
|
||||||
def _connect_to_redis(self, redis_url):
|
|
||||||
"""
|
|
||||||
Connect to Redis with retry logic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
redis_url (str): Redis URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
redis.Redis: Redis client or None if connection failed
|
|
||||||
"""
|
|
||||||
if not redis_url:
|
|
||||||
logging.info("Redis URL not configured, using in-memory state only.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
retry_count = 0
|
|
||||||
max_retries = 3
|
|
||||||
|
|
||||||
while retry_count < max_retries:
|
|
||||||
try:
|
|
||||||
client = redis.Redis.from_url(redis_url)
|
|
||||||
client.ping() # Test the connection
|
|
||||||
logging.info(f"Connected to Redis at {redis_url}")
|
|
||||||
return client
|
|
||||||
except Exception as e:
|
|
||||||
retry_count += 1
|
|
||||||
if retry_count < max_retries:
|
|
||||||
logging.warning(f"Redis connection attempt {retry_count} failed: {e}. Retrying...")
|
|
||||||
time.sleep(1) # Wait before retrying
|
|
||||||
else:
|
|
||||||
logging.error(f"Could not connect to Redis after {max_retries} attempts: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_graph_state(self):
|
|
||||||
"""Load graph state from Redis with support for the optimized format."""
|
|
||||||
global arrow_history, hashrate_history, metrics_log
|
|
||||||
|
|
||||||
if not self.redis_client:
|
|
||||||
logging.info("Redis not available, using in-memory state.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check version to handle format changes
|
|
||||||
version = self.redis_client.get(f"{self.STATE_KEY}_version")
|
|
||||||
version = version.decode('utf-8') if version else "1.0"
|
|
||||||
|
|
||||||
state_json = self.redis_client.get(self.STATE_KEY)
|
|
||||||
if state_json:
|
|
||||||
state = json.loads(state_json)
|
|
||||||
|
|
||||||
# Handle different versions of the data format
|
|
||||||
if version == "2.0": # Optimized format
|
|
||||||
# Restore arrow_history
|
|
||||||
compact_arrow_history = state.get("arrow_history", {})
|
|
||||||
for key, values in compact_arrow_history.items():
|
|
||||||
arrow_history[key] = [
|
|
||||||
{"time": entry.get("t", ""),
|
|
||||||
"value": entry.get("v", 0),
|
|
||||||
"arrow": entry.get("a", "")} # Use saved arrow value
|
|
||||||
for entry in values
|
|
||||||
]
|
|
||||||
|
|
||||||
# Restore hashrate_history
|
|
||||||
hashrate_history = state.get("hashrate_history", [])
|
|
||||||
|
|
||||||
# Restore metrics_log
|
|
||||||
compact_metrics_log = state.get("metrics_log", [])
|
|
||||||
metrics_log = []
|
|
||||||
for entry in compact_metrics_log:
|
|
||||||
metrics_log.append({
|
|
||||||
"timestamp": entry.get("ts", ""),
|
|
||||||
"metrics": entry.get("m", {})
|
|
||||||
})
|
|
||||||
else: # Original format
|
|
||||||
arrow_history = state.get("arrow_history", {})
|
|
||||||
hashrate_history = state.get("hashrate_history", [])
|
|
||||||
metrics_log = state.get("metrics_log", [])
|
|
||||||
|
|
||||||
logging.info(f"Loaded graph state from Redis (format version {version}).")
|
|
||||||
else:
|
|
||||||
logging.info("No previous graph state found in Redis.")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error loading graph state from Redis: {e}")
|
|
||||||
|
|
||||||
def save_graph_state(self):
|
|
||||||
"""Save graph state to Redis with optimized frequency, pruning, and data reduction."""
|
|
||||||
if not self.redis_client:
|
|
||||||
logging.info("Redis not available, skipping state save.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if we've saved recently to avoid too frequent saves
|
|
||||||
# Only save at most once every 5 minutes
|
|
||||||
current_time = time.time()
|
|
||||||
if hasattr(self, 'last_save_time') and current_time - self.last_save_time < 300: # 300 seconds = 5 minutes
|
|
||||||
logging.debug("Skipping Redis save - last save was less than 5 minutes ago")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update the last save time
|
|
||||||
self.last_save_time = current_time
|
|
||||||
|
|
||||||
# Prune data first to reduce volume
|
|
||||||
self.prune_old_data()
|
|
||||||
|
|
||||||
# Create compact versions of the data structures for Redis storage
|
|
||||||
try:
|
|
||||||
# 1. Create compact arrow_history with minimal data
|
|
||||||
compact_arrow_history = {}
|
|
||||||
for key, values in arrow_history.items():
|
|
||||||
if isinstance(values, list) and values:
|
|
||||||
# Only store recent history (last 2 hours)
|
|
||||||
recent_values = values[-180:] if len(values) > 180 else values
|
|
||||||
# Use shorter field names and preserve arrow directions
|
|
||||||
compact_arrow_history[key] = [
|
|
||||||
{"t": entry["time"], "v": entry["value"], "a": entry["arrow"]}
|
|
||||||
for entry in recent_values
|
|
||||||
]
|
|
||||||
|
|
||||||
# 2. Only keep essential hashrate_history
|
|
||||||
compact_hashrate_history = hashrate_history[-60:] if len(hashrate_history) > 60 else hashrate_history
|
|
||||||
|
|
||||||
# 3. Only keep recent metrics_log entries (last 30 minutes)
|
|
||||||
# This is typically the largest data structure
|
|
||||||
compact_metrics_log = []
|
|
||||||
if metrics_log:
|
|
||||||
# Keep only last 30 entries (30 minutes assuming 1-minute updates)
|
|
||||||
recent_logs = metrics_log[-30:]
|
|
||||||
|
|
||||||
for entry in recent_logs:
|
|
||||||
# Only keep necessary fields from each metrics entry
|
|
||||||
if "metrics" in entry and "timestamp" in entry:
|
|
||||||
metrics_copy = {}
|
|
||||||
original_metrics = entry["metrics"]
|
|
||||||
|
|
||||||
# Only copy the most important metrics for historical tracking
|
|
||||||
essential_keys = [
|
|
||||||
"hashrate_60sec", "hashrate_24hr", "btc_price",
|
|
||||||
"workers_hashing", "unpaid_earnings", "difficulty",
|
|
||||||
"network_hashrate", "daily_profit_usd"
|
|
||||||
]
|
|
||||||
|
|
||||||
for key in essential_keys:
|
|
||||||
if key in original_metrics:
|
|
||||||
metrics_copy[key] = original_metrics[key]
|
|
||||||
|
|
||||||
# Skip arrow_history within metrics as we already stored it separately
|
|
||||||
compact_metrics_log.append({
|
|
||||||
"ts": entry["timestamp"],
|
|
||||||
"m": metrics_copy
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create the final state object
|
|
||||||
state = {
|
|
||||||
"arrow_history": compact_arrow_history,
|
|
||||||
"hashrate_history": compact_hashrate_history,
|
|
||||||
"metrics_log": compact_metrics_log
|
|
||||||
}
|
|
||||||
|
|
||||||
# Convert to JSON once to reuse and measure size
|
|
||||||
state_json = json.dumps(state)
|
|
||||||
data_size_kb = len(state_json) / 1024
|
|
||||||
|
|
||||||
# Log data size for monitoring
|
|
||||||
logging.info(f"Saving graph state to Redis: {data_size_kb:.2f} KB (optimized format)")
|
|
||||||
|
|
||||||
# Only save if data size is reasonable (adjust threshold as needed)
|
|
||||||
if data_size_kb > 2000: # 2MB warning threshold (reduced from 5MB)
|
|
||||||
logging.warning(f"Redis save data size is still large: {data_size_kb:.2f} KB")
|
|
||||||
|
|
||||||
# Store version info to handle future format changes
|
|
||||||
self.redis_client.set(f"{self.STATE_KEY}_version", "2.0")
|
|
||||||
self.redis_client.set(self.STATE_KEY, state_json)
|
|
||||||
logging.info(f"Successfully saved graph state to Redis ({data_size_kb:.2f} KB)")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error saving graph state to Redis: {e}")
|
|
||||||
|
|
||||||
def prune_old_data(self):
|
|
||||||
"""Remove old data to prevent memory growth with optimized strategy."""
|
|
||||||
global arrow_history, metrics_log
|
|
||||||
|
|
||||||
with state_lock:
|
|
||||||
# Prune arrow_history with more sophisticated approach
|
|
||||||
for key in arrow_history:
|
|
||||||
if isinstance(arrow_history[key], list):
|
|
||||||
if len(arrow_history[key]) > MAX_HISTORY_ENTRIES:
|
|
||||||
# For most recent data (last hour) - keep every point
|
|
||||||
recent_data = arrow_history[key][-60:]
|
|
||||||
|
|
||||||
# For older data, reduce resolution by keeping every other point
|
|
||||||
older_data = arrow_history[key][:-60]
|
|
||||||
if len(older_data) > 0:
|
|
||||||
sparse_older_data = [older_data[i] for i in range(0, len(older_data), 2)]
|
|
||||||
arrow_history[key] = sparse_older_data + recent_data
|
|
||||||
else:
|
|
||||||
arrow_history[key] = recent_data
|
|
||||||
|
|
||||||
logging.info(f"Pruned {key} history from {len(arrow_history[key])} to {len(sparse_older_data + recent_data) if older_data else len(recent_data)} entries")
|
|
||||||
|
|
||||||
# Prune metrics_log more aggressively
|
|
||||||
if len(metrics_log) > MAX_HISTORY_ENTRIES:
|
|
||||||
# Keep most recent entries at full resolution
|
|
||||||
recent_logs = metrics_log[-60:]
|
|
||||||
|
|
||||||
# Reduce resolution of older entries
|
|
||||||
older_logs = metrics_log[:-60]
|
|
||||||
if len(older_logs) > 0:
|
|
||||||
sparse_older_logs = [older_logs[i] for i in range(0, len(older_logs), 3)] # Keep every 3rd entry
|
|
||||||
metrics_log = sparse_older_logs + recent_logs
|
|
||||||
logging.info(f"Pruned metrics log from {len(metrics_log)} to {len(sparse_older_logs + recent_logs)} entries")
|
|
||||||
|
|
||||||
# Free memory more aggressively
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
def persist_critical_state(self, cached_metrics, scheduler_last_successful_run, last_metrics_update_time):
|
|
||||||
"""
|
|
||||||
Store critical state in Redis for recovery after worker restarts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cached_metrics (dict): Current metrics
|
|
||||||
scheduler_last_successful_run (float): Timestamp of last successful scheduler run
|
|
||||||
last_metrics_update_time (float): Timestamp of last metrics update
|
|
||||||
"""
|
|
||||||
if not self.redis_client:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Only persist if we have valid data
|
|
||||||
if cached_metrics and cached_metrics.get("server_timestamp"):
|
|
||||||
state = {
|
|
||||||
"cached_metrics_timestamp": cached_metrics.get("server_timestamp"),
|
|
||||||
"last_successful_run": scheduler_last_successful_run,
|
|
||||||
"last_update_time": last_metrics_update_time
|
|
||||||
}
|
|
||||||
self.redis_client.set("critical_state", json.dumps(state))
|
|
||||||
logging.info(f"Persisted critical state to Redis, timestamp: {cached_metrics.get('server_timestamp')}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error persisting critical state: {e}")
|
|
||||||
|
|
||||||
def load_critical_state(self):
|
|
||||||
"""
|
|
||||||
Recover critical state variables after a worker restart.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (last_successful_run, last_update_time)
|
|
||||||
"""
|
|
||||||
if not self.redis_client:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
state_json = self.redis_client.get("critical_state")
|
|
||||||
if state_json:
|
|
||||||
state = json.loads(state_json.decode('utf-8'))
|
|
||||||
last_successful_run = state.get("last_successful_run")
|
|
||||||
last_update_time = state.get("last_update_time")
|
|
||||||
|
|
||||||
logging.info(f"Loaded critical state from Redis, last run: {last_successful_run}")
|
|
||||||
|
|
||||||
# We don't restore cached_metrics itself, as we'll fetch fresh data
|
|
||||||
# Just note that we have state to recover from
|
|
||||||
logging.info(f"Last metrics timestamp from Redis: {state.get('cached_metrics_timestamp')}")
|
|
||||||
|
|
||||||
return last_successful_run, last_update_time
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error loading critical state: {e}")
|
|
||||||
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def update_metrics_history(self, metrics):
|
|
||||||
"""
|
|
||||||
Update history collections with new metrics data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metrics (dict): New metrics data
|
|
||||||
"""
|
|
||||||
global arrow_history, hashrate_history, metrics_log
|
|
||||||
|
|
||||||
# Skip if metrics is None
|
|
||||||
if not metrics:
|
|
||||||
return
|
|
||||||
|
|
||||||
arrow_keys = [
|
|
||||||
"pool_total_hashrate", "hashrate_24hr", "hashrate_3hr", "hashrate_10min",
|
|
||||||
"hashrate_60sec", "block_number", "btc_price", "network_hashrate",
|
|
||||||
"difficulty", "daily_revenue", "daily_power_cost", "daily_profit_usd",
|
|
||||||
"monthly_profit_usd", "daily_mined_sats", "monthly_mined_sats", "unpaid_earnings",
|
|
||||||
"estimated_earnings_per_day_sats", "estimated_earnings_next_block_sats", "estimated_rewards_in_window_sats",
|
|
||||||
"workers_hashing"
|
|
||||||
]
|
|
||||||
|
|
||||||
# --- Bucket by second (Los Angeles Time) with thread safety ---
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
current_second = datetime.now(ZoneInfo(get_timezone())).strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
with state_lock:
|
|
||||||
for key in arrow_keys:
|
|
||||||
if metrics.get(key) is not None:
|
|
||||||
current_val = metrics[key]
|
|
||||||
arrow = ""
|
|
||||||
|
|
||||||
# Get the corresponding unit key if available
|
|
||||||
unit_key = f"{key}_unit"
|
|
||||||
current_unit = metrics.get(unit_key, "")
|
|
||||||
|
|
||||||
if key in arrow_history and arrow_history[key]:
|
|
||||||
try:
|
|
||||||
previous_val = arrow_history[key][-1]["value"]
|
|
||||||
previous_unit = arrow_history[key][-1].get("unit", "")
|
|
||||||
previous_arrow = arrow_history[key][-1].get("arrow", "") # Get previous arrow
|
|
||||||
|
|
||||||
# Use the convert_to_ths function to normalize both values before comparison
|
|
||||||
if key.startswith("hashrate") and current_unit:
|
|
||||||
from models import convert_to_ths
|
|
||||||
norm_curr_val = convert_to_ths(float(current_val), current_unit)
|
|
||||||
norm_prev_val = convert_to_ths(float(previous_val), previous_unit if previous_unit else "th/s")
|
|
||||||
|
|
||||||
# Lower the threshold to 0.05% for more sensitivity
|
|
||||||
if norm_curr_val > norm_prev_val * 1.0001:
|
|
||||||
arrow = "↑"
|
|
||||||
elif norm_curr_val < norm_prev_val * 0.9999:
|
|
||||||
arrow = "↓"
|
|
||||||
else:
|
|
||||||
arrow = previous_arrow # Preserve previous arrow if change is insignificant
|
|
||||||
else:
|
|
||||||
# For non-hashrate values or when units are missing
|
|
||||||
# Try to convert to float for comparison
|
|
||||||
try:
|
|
||||||
curr_float = float(current_val)
|
|
||||||
prev_float = float(previous_val)
|
|
||||||
|
|
||||||
# Lower the threshold to 0.05% for more sensitivity
|
|
||||||
if curr_float > prev_float * 1.0001:
|
|
||||||
arrow = "↑"
|
|
||||||
elif curr_float < prev_float * 0.9999:
|
|
||||||
arrow = "↓"
|
|
||||||
else:
|
|
||||||
arrow = previous_arrow # Preserve previous arrow
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
# If values can't be converted to float, compare directly
|
|
||||||
if current_val != previous_val:
|
|
||||||
arrow = "↑" if current_val > previous_val else "↓"
|
|
||||||
else:
|
|
||||||
arrow = previous_arrow # Preserve previous arrow
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error calculating arrow for {key}: {e}")
|
|
||||||
# Keep previous arrow on error instead of empty string
|
|
||||||
if arrow_history[key] and arrow_history[key][-1].get("arrow"):
|
|
||||||
arrow = arrow_history[key][-1]["arrow"]
|
|
||||||
|
|
||||||
if key not in arrow_history:
|
|
||||||
arrow_history[key] = []
|
|
||||||
|
|
||||||
if not arrow_history[key] or arrow_history[key][-1]["time"] != current_second:
|
|
||||||
# Create new entry
|
|
||||||
entry = {
|
|
||||||
"time": current_second,
|
|
||||||
"value": current_val,
|
|
||||||
"arrow": arrow,
|
|
||||||
}
|
|
||||||
# Add unit information if available
|
|
||||||
if current_unit:
|
|
||||||
entry["unit"] = current_unit
|
|
||||||
|
|
||||||
arrow_history[key].append(entry)
|
|
||||||
else:
|
|
||||||
# Update existing entry
|
|
||||||
arrow_history[key][-1]["value"] = current_val
|
|
||||||
# Only update arrow if it's not empty - this preserves arrows between changes
|
|
||||||
if arrow:
|
|
||||||
arrow_history[key][-1]["arrow"] = arrow
|
|
||||||
# Update unit if available
|
|
||||||
if current_unit:
|
|
||||||
arrow_history[key][-1]["unit"] = current_unit
|
|
||||||
|
|
||||||
# Cap history to three hours worth (180 entries)
|
|
||||||
if len(arrow_history[key]) > MAX_HISTORY_ENTRIES:
|
|
||||||
arrow_history[key] = arrow_history[key][-MAX_HISTORY_ENTRIES:]
|
|
||||||
|
|
||||||
# --- Aggregate arrow_history by minute for the graph ---
|
|
||||||
aggregated_history = {}
|
|
||||||
for key, entries in arrow_history.items():
|
|
||||||
minute_groups = {}
|
|
||||||
for entry in entries:
|
|
||||||
minute = entry["time"][:5] # extract HH:MM
|
|
||||||
minute_groups[minute] = entry # take last entry for that minute
|
|
||||||
|
|
||||||
# Sort by time to ensure chronological order
|
|
||||||
aggregated_history[key] = sorted(list(minute_groups.values()),
|
|
||||||
key=lambda x: x["time"])
|
|
||||||
|
|
||||||
# Only keep the most recent 60 data points for the graph display
|
|
||||||
aggregated_history[key] = aggregated_history[key][-60:] if len(aggregated_history[key]) > 60 else aggregated_history[key]
|
|
||||||
|
|
||||||
metrics["arrow_history"] = aggregated_history
|
|
||||||
metrics["history"] = hashrate_history
|
|
||||||
|
|
||||||
entry = {"timestamp": datetime.now().isoformat(), "metrics": metrics}
|
|
||||||
metrics_log.append(entry)
|
|
||||||
# Cap the metrics log to three hours worth (180 entries)
|
|
||||||
if len(metrics_log) > MAX_HISTORY_ENTRIES:
|
|
||||||
metrics_log = metrics_log[-MAX_HISTORY_ENTRIES:]
|
|
||||||
|
|
||||||
def save_notifications(self, notifications):
|
|
||||||
"""Save notifications to persistent storage."""
|
|
||||||
try:
|
|
||||||
# If we have Redis, use it
|
|
||||||
if self.redis_client:
|
|
||||||
notifications_json = json.dumps(notifications)
|
|
||||||
self.redis_client.set("dashboard_notifications", notifications_json)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# Otherwise just keep in memory
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error saving notifications: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_notifications(self):
|
|
||||||
"""Retrieve notifications from persistent storage."""
|
|
||||||
try:
|
|
||||||
# If we have Redis, use it
|
|
||||||
if self.redis_client:
|
|
||||||
notifications_json = self.redis_client.get("dashboard_notifications")
|
|
||||||
if notifications_json:
|
|
||||||
return json.loads(notifications_json)
|
|
||||||
|
|
||||||
# Return empty list if not found or no Redis
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error retrieving notifications: {e}")
|
|
||||||
return []
|
|
@ -1,371 +0,0 @@
|
|||||||
/* Styles specific to the blocks page */
|
|
||||||
|
|
||||||
/* Block controls */
|
|
||||||
.block-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-control-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-input {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
border: 1px solid var(--primary-color) !important;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-input:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-button {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 5px 15px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-button:hover {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Latest block stats */
|
|
||||||
.latest-block-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item strong {
|
|
||||||
color: #f7931a; /* Use the Bitcoin orange color for labels */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blocks grid */
|
|
||||||
.blocks-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocks-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-card {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-card:hover {
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 1px, transparent 1px, transparent 2px );
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-height {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-time {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #00dfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 8px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value.yellow {
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value.green {
|
|
||||||
color: #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value.blue {
|
|
||||||
color: #00dfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value.white {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-info-value.red {
|
|
||||||
color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loader */
|
|
||||||
.loader {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-text {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal styles */
|
|
||||||
.block-modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-content {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
margin: 5% auto;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
box-shadow: 0 0 20px rgba(247, 147, 26, 0.5);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 800px;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-content::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 1px, transparent 1px, transparent 2px );
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-header {
|
|
||||||
background-color: #000;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-bottom: 1px solid var(--primary-color);
|
|
||||||
animation: flicker 4s infinite;
|
|
||||||
font-family: var(--header-font);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-close {
|
|
||||||
color: var(--primary-color);
|
|
||||||
float: right;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-close:hover,
|
|
||||||
.block-modal-close:focus {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-body {
|
|
||||||
padding: 1rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#block-details {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-detail-section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-detail-title {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-detail-item {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-detail-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-detail-value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-hash {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #00dfff;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transaction-data {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-bar-container {
|
|
||||||
height: 5px;
|
|
||||||
width: 100%;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
margin-top: 5px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-bar {
|
|
||||||
height: 100%;
|
|
||||||
width: 0;
|
|
||||||
background: linear-gradient(90deg, #32CD32, #ffd700);
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mining Animation Container */
|
|
||||||
.mining-animation-container {
|
|
||||||
padding: 0;
|
|
||||||
background-color: #0a0a0a;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mining-animation-container::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 1px, transparent 1px, transparent 2px );
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#svg-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center; /* Add this to center vertically if needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block; /* Ensures proper centering */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make sure the SVG itself takes more width */
|
|
||||||
#block-mining-animation {
|
|
||||||
width: 100%;
|
|
||||||
height: 300px;
|
|
||||||
/* Fixed height but full width */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.latest-block-stats {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocks-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-content {
|
|
||||||
width: 95%;
|
|
||||||
margin: 10% auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#block-details {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#svg-container {
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,664 +0,0 @@
|
|||||||
/* Config form styling - fixed width and hidden by default */
|
|
||||||
#config-form {
|
|
||||||
display: none;
|
|
||||||
width: 500px;
|
|
||||||
max-width: 90%;
|
|
||||||
margin: 30px auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #0d0d0d;
|
|
||||||
border: 1px solid #f7931a;
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Boot text color - updated with theme toggling */
|
|
||||||
body:not(.deepsea-theme) #terminal,
|
|
||||||
body:not(.deepsea-theme) #output,
|
|
||||||
body:not(.deepsea-theme) #prompt-container,
|
|
||||||
body:not(.deepsea-theme) #prompt-text,
|
|
||||||
body:not(.deepsea-theme) #user-input,
|
|
||||||
body:not(.deepsea-theme) #loading-message {
|
|
||||||
color: #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DeepSea theme text color */
|
|
||||||
body.deepsea-theme #terminal,
|
|
||||||
body.deepsea-theme #output,
|
|
||||||
body.deepsea-theme #prompt-container,
|
|
||||||
body.deepsea-theme #prompt-text,
|
|
||||||
body.deepsea-theme #user-input,
|
|
||||||
body.deepsea-theme #loading-message {
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DeepSea cursor color */
|
|
||||||
body.deepsea-theme .cursor,
|
|
||||||
body.deepsea-theme .prompt-cursor {
|
|
||||||
background-color: #0088cc;
|
|
||||||
box-shadow: 0 0 5px rgba(0, 136, 204, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Boot-specific DeepSea theme adjustments */
|
|
||||||
body.deepsea-theme #bitcoin-logo {
|
|
||||||
color: #0088cc;
|
|
||||||
border-color: #0088cc;
|
|
||||||
text-shadow: 0 0 10px rgba(0, 136, 204, 0.5);
|
|
||||||
box-shadow: 0 0 15px rgba(0, 136, 204, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme #config-form {
|
|
||||||
border: 1px solid #0088cc;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 136, 204, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .config-title {
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .form-group label {
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .form-group input,
|
|
||||||
body.deepsea-theme .form-group select {
|
|
||||||
border: 1px solid #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .form-group input:focus,
|
|
||||||
body.deepsea-theme .form-group select:focus {
|
|
||||||
box-shadow: 0 0 5px #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .btn {
|
|
||||||
background-color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .btn:hover {
|
|
||||||
background-color: #00b3ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .btn-secondary {
|
|
||||||
background-color: #333;
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .tooltip .tooltip-text {
|
|
||||||
border: 1px solid #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .form-group select {
|
|
||||||
background-image: linear-gradient(45deg, transparent 50%, #0088cc 50%), linear-gradient(135deg, #0088cc 50%, transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DeepSea skip button */
|
|
||||||
body.deepsea-theme #skip-button {
|
|
||||||
background-color: #0088cc;
|
|
||||||
box-shadow: 0 0 8px rgba(0, 136, 204, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme #skip-button:hover {
|
|
||||||
background-color: #00b3ff;
|
|
||||||
box-shadow: 0 0 12px rgba(0, 136, 204, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Original Bitcoin styling preserved by default */
|
|
||||||
.config-title {
|
|
||||||
font-size: 24px;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
background-color: #0d0d0d;
|
|
||||||
border: 1px solid #f7931a;
|
|
||||||
color: #fff;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 5px #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
color: #000;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background-color: #ffa32e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #333;
|
|
||||||
color: #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#form-message {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-success {
|
|
||||||
background-color: rgba(50, 205, 50, 0.2);
|
|
||||||
border: 1px solid #32CD32;
|
|
||||||
color: #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.2);
|
|
||||||
border: 1px solid #ff0000;
|
|
||||||
color: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 5px;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 14px;
|
|
||||||
font-size: 10px;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip .tooltip-text {
|
|
||||||
visibility: hidden;
|
|
||||||
width: 200px;
|
|
||||||
background-color: #000;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
bottom: 125%;
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -100px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid #f7931a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip:hover .tooltip-text {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the select dropdown with custom arrow */
|
|
||||||
.form-group select {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
background-image: linear-gradient(45deg, transparent 50%, #f7931a 50%), linear-gradient(135deg, #f7931a 50%, transparent 50%);
|
|
||||||
background-position: calc(100% - 15px) calc(1em + 0px), calc(100% - 10px) calc(1em + 0px);
|
|
||||||
background-size: 5px 5px, 5px 5px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
padding-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base styling for the Bitcoin logo */
|
|
||||||
#bitcoin-logo {
|
|
||||||
position: relative;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: monospace;
|
|
||||||
height: 130px; /* Set fixed height to match original logo */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update the DeepSea theme logo styling */
|
|
||||||
body.deepsea-theme #bitcoin-logo {
|
|
||||||
color: transparent; /* Hide original logo */
|
|
||||||
position: relative;
|
|
||||||
text-shadow: none;
|
|
||||||
min-height: 120px; /* Ensure enough height for the new logo */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add the new DeepSea ASCII art */
|
|
||||||
body.deepsea-theme #bitcoin-logo::after {
|
|
||||||
content: " ____ ____ \A| _ \\ ___ ___ _ __/ ___| ___ __ _ \A| | | |/ _ \\/ _ \\ '_ \\___ \\ / _ \\/ _` |\A| |_| | __/ __/ |_) |__) | __/ (_| |\A|____/ \\___|\\___|_.__/____/ \\___|\\__,_|\A|_| ";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%); /* Center perfectly */
|
|
||||||
font-size: 100%; /* Full size */
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: #0088cc;
|
|
||||||
white-space: pre;
|
|
||||||
display: block;
|
|
||||||
text-shadow: 0 0 10px rgba(0, 136, 204, 0.5);
|
|
||||||
font-family: monospace;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add "DeepSea" version info */
|
|
||||||
body.deepsea-theme #bitcoin-logo::before {
|
|
||||||
content: "v.21";
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 10px;
|
|
||||||
color: #0088cc;
|
|
||||||
font-size: 16px;
|
|
||||||
text-shadow: 0 0 5px rgba(0, 136, 204, 0.5);
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
z-index: 2; /* Ensure version displays on top */
|
|
||||||
}
|
|
||||||
/* Ocean Wave Ripple Effect for DeepSea Theme */
|
|
||||||
body.deepsea-theme::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: transparent;
|
|
||||||
opacity: 0.1;
|
|
||||||
z-index: 10;
|
|
||||||
animation: oceanRipple 8s infinite linear;
|
|
||||||
background-image: repeating-linear-gradient( 0deg, rgba(0, 136, 204, 0.1), rgba(0, 136, 204, 0.1) 1px, transparent 1px, transparent 6px );
|
|
||||||
background-size: 100% 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ocean waves moving animation */
|
|
||||||
@keyframes oceanRipple {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Retro glitch effect for DeepSea Theme */
|
|
||||||
body.deepsea-theme::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 3;
|
|
||||||
opacity: 0.15;
|
|
||||||
background-image: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 73, 109, 0.1) 50%), linear-gradient(90deg, rgba(0, 81, 122, 0.03), rgba(0, 136, 204, 0.08), rgba(0, 191, 255, 0.03));
|
|
||||||
background-size: 100% 2px, 3px 100%;
|
|
||||||
animation: glitchEffect 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glitch animation */
|
|
||||||
@keyframes glitchEffect {
|
|
||||||
0% {
|
|
||||||
opacity: 0.15;
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
20% {
|
|
||||||
opacity: 0.17;
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
opacity: 0.14;
|
|
||||||
background-position: -1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
opacity: 0.15;
|
|
||||||
background-position: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
80% {
|
|
||||||
opacity: 0.16;
|
|
||||||
background-position: -2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.15;
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Deep underwater light rays */
|
|
||||||
body.deepsea-theme {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme .underwater-rays {
|
|
||||||
position: fixed;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
right: -50%;
|
|
||||||
bottom: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: rgba(0, 0, 0, 0);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
background-image: radial-gradient(ellipse at top, rgba(0, 136, 204, 0.1) 0%, rgba(0, 136, 204, 0) 70%), radial-gradient(ellipse at bottom, rgba(0, 91, 138, 0.15) 0%, rgba(0, 0, 0, 0) 70%);
|
|
||||||
animation: lightRays 15s ease infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light ray animation */
|
|
||||||
@keyframes lightRays {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg) scale(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg) scale(1.1);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle digital noise texture */
|
|
||||||
body.deepsea-theme .digital-noise {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4woEFQwNDaabTQAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAACASURBVGje7dixDcIwFEbhb8QMKWn5dwEWY4fswAasRJkBkhfAIarsNDEF5x5LrV/dJ1cEAAAAAOzHuefF5byzZ7tS6xDj6qoQpdRxUvNM6lH3rPeM1+ZJ3ROtqe9feGcjY8z74M8UvJGxEVHxTcIbGSsR+SECAAAAsC9/8G82GwHDD80AAAAASUVORK5CYII=');
|
|
||||||
opacity: 0.05;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: noise 0.5s steps(5) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Noise animation */
|
|
||||||
@keyframes noise {
|
|
||||||
0% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
20% {
|
|
||||||
transform: translate(-1px, 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
transform: translate(1px, -1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
transform: translate(-2px, -1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
80% {
|
|
||||||
transform: translate(2px, 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base Styles with a subtle radial background for extra depth */
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #121212, #000000);
|
|
||||||
color: #f7931a;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
height: calc(100vh - 100px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CRT Screen Effect */
|
|
||||||
body::before {
|
|
||||||
content: " ";
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; bottom: 0; right: 0;
|
|
||||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
|
||||||
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
|
||||||
background-size: 100% 2px, 3px 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flicker Animation */
|
|
||||||
@keyframes flicker {
|
|
||||||
0% { opacity: 0.97; }
|
|
||||||
5% { opacity: 0.95; }
|
|
||||||
10% { opacity: 0.97; }
|
|
||||||
15% { opacity: 0.94; }
|
|
||||||
20% { opacity: 0.98; }
|
|
||||||
50% { opacity: 0.95; }
|
|
||||||
80% { opacity: 0.96; }
|
|
||||||
90% { opacity: 0.94; }
|
|
||||||
100% { opacity: 0.98; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terminal Window with scrolling enabled */
|
|
||||||
#terminal {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
animation: flicker 4s infinite;
|
|
||||||
height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#terminal-content {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neon-inspired color classes */
|
|
||||||
.green {
|
|
||||||
color: #39ff14 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blue {
|
|
||||||
color: #00dfff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yellow {
|
|
||||||
color: #ffd700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.white {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.red {
|
|
||||||
color: #ff2d2d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.magenta {
|
|
||||||
color: #ff2d95 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bitcoin Logo styling with extra neon border */
|
|
||||||
#bitcoin-logo {
|
|
||||||
display: block;
|
|
||||||
visibility: hidden;
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px auto;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1;
|
|
||||||
color: #f7931a;
|
|
||||||
text-shadow: 0 0 10px rgba(247, 147, 26, 0.8);
|
|
||||||
white-space: pre;
|
|
||||||
width: 260px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 2px solid #f7931a;
|
|
||||||
background-color: #0a0a0a;
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.5);
|
|
||||||
font-family: monospace;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skip Button */
|
|
||||||
#skip-button {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
color: #000;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
z-index: 50; /* Lower z-index value */
|
|
||||||
}
|
|
||||||
|
|
||||||
#skip-button:hover {
|
|
||||||
background-color: #ffa32e;
|
|
||||||
box-shadow: 0 0 12px rgba(247, 147, 26, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile-specific adjustments */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#skip-button {
|
|
||||||
bottom: 25px;
|
|
||||||
right: 10px;
|
|
||||||
padding: 10px 18px; /* Larger touch target for mobile */
|
|
||||||
font-size: 18px;
|
|
||||||
height: 40px;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add this to your CSS */
|
|
||||||
#config-form {
|
|
||||||
z-index: 100; /* Higher than the skip button */
|
|
||||||
position: relative; /* Needed for z-index to work properly */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prompt Styling */
|
|
||||||
#prompt-container {
|
|
||||||
display: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#prompt-text {
|
|
||||||
color: #f7931a;
|
|
||||||
margin-right: 5px;
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #f7931a;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 20px;
|
|
||||||
caret-color: transparent;
|
|
||||||
outline: none;
|
|
||||||
width: 35px;
|
|
||||||
height: 33px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
|
||||||
position: relative;
|
|
||||||
top: 1px;
|
|
||||||
margin-left: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsiveness */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
body { font-size: 14px; padding: 10px; }
|
|
||||||
#terminal { margin: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Debug Info */
|
|
||||||
#loading-message {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#debug-info {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
@ -1,501 +0,0 @@
|
|||||||
.footer {
|
|
||||||
margin-top: 30px;
|
|
||||||
padding: 10px 0;
|
|
||||||
color: grey;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
</style >
|
|
||||||
<!-- Preload theme to prevent flicker -->
|
|
||||||
<style id="theme-preload" >
|
|
||||||
/* Theme-aware loading state */
|
|
||||||
html.bitcoin-theme {
|
|
||||||
background-color: #111111;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.deepsea-theme {
|
|
||||||
background-color: #0c141a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-loader {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 9999;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.bitcoin-theme #theme-loader {
|
|
||||||
background-color: #111111;
|
|
||||||
color: #f2a900;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.deepsea-theme #theme-loader {
|
|
||||||
background-color: #0c141a;
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loader-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: spin 2s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loader-text {
|
|
||||||
font-size: 24px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide content during load */
|
|
||||||
body {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Common styling elements shared across all pages */
|
|
||||||
:root {
|
|
||||||
--bg-color: #0a0a0a;
|
|
||||||
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
|
|
||||||
--primary-color: #f7931a;
|
|
||||||
--accent-color: #00ffff;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-padding: 0.5rem;
|
|
||||||
--text-size-base: 16px;
|
|
||||||
--terminal-font: 'VT323', monospace;
|
|
||||||
--header-font: 'Orbitron', sans-serif;
|
|
||||||
--text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
:root {
|
|
||||||
--card-padding: 0.75rem;
|
|
||||||
--text-size-base: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CRT Screen Effect */
|
|
||||||
body::before {
|
|
||||||
content: " ";
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; bottom: 0; right: 0;
|
|
||||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
|
||||||
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
|
||||||
background-size: 100% 2px, 3px 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flicker Animation */
|
|
||||||
@keyframes flicker {
|
|
||||||
0% { opacity: 0.97; }
|
|
||||||
5% { opacity: 0.95; }
|
|
||||||
10% { opacity: 0.97; }
|
|
||||||
15% { opacity: 0.94; }
|
|
||||||
20% { opacity: 0.98; }
|
|
||||||
50% { opacity: 0.95; }
|
|
||||||
80% { opacity: 0.96; }
|
|
||||||
90% { opacity: 0.94; }
|
|
||||||
100% { opacity: 0.98; }
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg-gradient);
|
|
||||||
color: var(--text-color);
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
font-size: var(--text-size-base);
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-family: var(--header-font);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
animation: flicker 4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation links */
|
|
||||||
.navigation-links {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
padding: 5px 15px;
|
|
||||||
margin: 0 10px;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top right link */
|
|
||||||
#topRightLink {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
color: grey;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.7rem; /* Decreased font size */
|
|
||||||
padding: 5px 10px; /* Add padding for a larger clickable area */
|
|
||||||
transition: background-color 0.3s ease; /* Optional: Add hover effect */
|
|
||||||
}
|
|
||||||
|
|
||||||
#topRightLink:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1); /* Optional: Highlight on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Card styles */
|
|
||||||
.card,
|
|
||||||
.card-header,
|
|
||||||
.card-body,
|
|
||||||
.card-footer {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced card with scanlines */
|
|
||||||
.card {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding: var(--card-padding);
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scanline effect for cards */
|
|
||||||
.card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.05),
|
|
||||||
rgba(0, 0, 0, 0.05) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #000;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-bottom: 1px solid var(--primary-color);
|
|
||||||
animation: flicker 4s infinite;
|
|
||||||
font-family: var(--header-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body hr {
|
|
||||||
border-top: 1px solid var(--primary-color);
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connection status indicator */
|
|
||||||
#connectionStatus {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: rgba(255,0,0,0.7);
|
|
||||||
color: white;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
z-index: 9999;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Last Updated text with subtle animation */
|
|
||||||
#lastUpdated {
|
|
||||||
animation: flicker 5s infinite;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cursor blink for terminal feel */
|
|
||||||
#terminal-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
margin-left: 2px;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.container-fluid {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status indicators */
|
|
||||||
.online-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: #32CD32;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
animation: glow 3s infinite;
|
|
||||||
box-shadow: 0 0 10px #32CD32, 0 0 20px #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow {
|
|
||||||
0%, 100% { box-shadow: 0 0 10px #32CD32, 0 0 15px #32CD32; }
|
|
||||||
50% { box-shadow: 0 0 15px #32CD32, 0 0 25px #32CD32; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.offline-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: red;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
animation: glow 3s infinite;
|
|
||||||
box-shadow: 0 0 10px red, 0 0 20px red !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glowRed {
|
|
||||||
0%, 100% { box-shadow: 0 0 10px red, 0 0 15px red; }
|
|
||||||
50% { box-shadow: 0 0 15px red, 0 0 25px red; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color utility classes */
|
|
||||||
.green-glow, .status-green {
|
|
||||||
color: #39ff14 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.red-glow, .status-red {
|
|
||||||
color: #ff2d2d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yellow-glow {
|
|
||||||
color: #ffd700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blue-glow {
|
|
||||||
color: #00dfff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.white-glow {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Basic color classes for backward compatibility */
|
|
||||||
.green {
|
|
||||||
color: #39ff14 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blue {
|
|
||||||
color: #00dfff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yellow {
|
|
||||||
color: #ffd700 !important;
|
|
||||||
font-weight: normal !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.white {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.red {
|
|
||||||
color: #ff2d2d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.magenta {
|
|
||||||
color: #ff2d95 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bitcoin Progress Bar Styles */
|
|
||||||
.bitcoin-progress-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 300px;
|
|
||||||
height: 20px;
|
|
||||||
background-color: #111;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0.5rem auto;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitcoin-progress-inner {
|
|
||||||
height: 100%;
|
|
||||||
width: 0;
|
|
||||||
background: linear-gradient(90deg, #f7931a, #ffa500);
|
|
||||||
border-radius: 0;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitcoin-progress-inner::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
rgba(255, 255, 255, 0.1) 0%,
|
|
||||||
rgba(255, 255, 255, 0.2) 20%,
|
|
||||||
rgba(255, 255, 255, 0.1) 40%);
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitcoin-icons {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-effect {
|
|
||||||
box-shadow: 0 0 15px #f7931a, 0 0 25px #f7931a;
|
|
||||||
animation: pulse 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extra styling for when server update is late */
|
|
||||||
.waiting-for-update {
|
|
||||||
animation: waitingPulse 2s infinite !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes waitingPulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 10px #f7931a, 0 0 15px #f7931a; opacity: 0.8; }
|
|
||||||
50% { box-shadow: 0 0 20px #f7931a, 0 0 35px #f7931a; opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-text {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.container-fluid {
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#topRightLink {
|
|
||||||
position: static;
|
|
||||||
display: block;
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation badges for notifications */
|
|
||||||
.nav-badge {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 1px 5px;
|
|
||||||
min-width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
display: none;
|
|
||||||
margin-left: 5px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
@ -1,247 +0,0 @@
|
|||||||
/* Specific styles for the main dashboard */
|
|
||||||
|
|
||||||
#graphContainer {
|
|
||||||
background-color: #000;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
height: 230px;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.2);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add scanline effect to graph */
|
|
||||||
#graphContainer::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.1),
|
|
||||||
rgba(0, 0, 0, 0.1) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override for Payout & Misc card */
|
|
||||||
#payoutMiscCard {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Row equal height for card alignment */
|
|
||||||
.row.equal-height {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.equal-height > [class*="col-"] {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.equal-height > [class*="col-"] .card {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Arrow indicator styles */
|
|
||||||
.arrow {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bounce animations for indicators */
|
|
||||||
@keyframes bounceUp {
|
|
||||||
0% { transform: translateY(0); }
|
|
||||||
25% { transform: translateY(-2px); }
|
|
||||||
50% { transform: translateY(0); }
|
|
||||||
75% { transform: translateY(-2px); }
|
|
||||||
100% { transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounceDown {
|
|
||||||
0% { transform: translateY(0); }
|
|
||||||
25% { transform: translateY(2px); }
|
|
||||||
50% { transform: translateY(0); }
|
|
||||||
75% { transform: translateY(2px); }
|
|
||||||
100% { transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bounce-up {
|
|
||||||
animation: bounceUp 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bounce-down {
|
|
||||||
animation: bounceDown 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Refresh timer container */
|
|
||||||
#refreshUptime {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uptimeTimer strong {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uptimeTimer {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Metric styling by category */
|
|
||||||
.metric-value {
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Yellow color family (BTC price, sats metrics, time to payout) */
|
|
||||||
#btc_price,
|
|
||||||
#daily_mined_sats,
|
|
||||||
#monthly_mined_sats,
|
|
||||||
#estimated_earnings_per_day_sats,
|
|
||||||
#estimated_earnings_next_block_sats,
|
|
||||||
#estimated_rewards_in_window_sats,
|
|
||||||
#est_time_to_payout {
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Green color family (profits, earnings) */
|
|
||||||
#unpaid_earnings,
|
|
||||||
#daily_revenue,
|
|
||||||
#daily_profit_usd,
|
|
||||||
#monthly_profit_usd {
|
|
||||||
color: #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Red color family (costs) */
|
|
||||||
#daily_power_cost {
|
|
||||||
color: #ff5555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* White metrics (general stats) */
|
|
||||||
.metric-value.white,
|
|
||||||
#block_number,
|
|
||||||
#network_hashrate,
|
|
||||||
#difficulty,
|
|
||||||
#workers_hashing,
|
|
||||||
#last_share,
|
|
||||||
#blocks_found,
|
|
||||||
#last_block_height,
|
|
||||||
#pool_fees_percentage {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blue metrics (time data) */
|
|
||||||
#last_block_time {
|
|
||||||
color: #00dfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body strong {
|
|
||||||
color: var(--primary-color);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body p {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hidden Congrats Message */
|
|
||||||
#congratsMessage {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 1000;
|
|
||||||
background: #f7931a;
|
|
||||||
color: #000;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
/* Add bottom padding to accommodate minimized system monitor */
|
|
||||||
.container-fluid {
|
|
||||||
padding-bottom: 60px !important; /* Enough space for minimized monitor */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add these styles to dashboard.css */
|
|
||||||
@keyframes pulse-block-marker {
|
|
||||||
0% {
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg) scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.3);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg) scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container-relative {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for optimal fee indicator */
|
|
||||||
.fee-star {
|
|
||||||
color: gold;
|
|
||||||
margin-left: 4px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datum-label {
|
|
||||||
color: #ffffff; /* White color */
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-left: 4px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pool luck indicators */
|
|
||||||
.very-lucky {
|
|
||||||
color: #32CD32 !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lucky {
|
|
||||||
color: #90EE90 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.normal-luck {
|
|
||||||
color: #ffd700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unlucky {
|
|
||||||
color: #ff5555 !important;
|
|
||||||
}
|
|
@ -1,134 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-color: #0a0a0a;
|
|
||||||
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
|
|
||||||
--primary-color: #f7931a;
|
|
||||||
--text-color: white;
|
|
||||||
--terminal-font: 'VT323', monospace;
|
|
||||||
--header-font: 'Orbitron', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CRT Screen Effect */
|
|
||||||
body::before {
|
|
||||||
content: " ";
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; bottom: 0; right: 0;
|
|
||||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
|
||||||
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
|
||||||
background-size: 100% 2px, 3px 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flicker Animation */
|
|
||||||
@keyframes flicker {
|
|
||||||
0% { opacity: 0.97; }
|
|
||||||
5% { opacity: 0.95; }
|
|
||||||
10% { opacity: 0.97; }
|
|
||||||
15% { opacity: 0.94; }
|
|
||||||
20% { opacity: 0.98; }
|
|
||||||
50% { opacity: 0.95; }
|
|
||||||
80% { opacity: 0.96; }
|
|
||||||
90% { opacity: 0.94; }
|
|
||||||
100% { opacity: 0.98; }
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg-gradient);
|
|
||||||
color: var(--text-color);
|
|
||||||
padding-top: 50px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
a.btn-primary {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
color: black;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-family: var(--header-font);
|
|
||||||
text-shadow: none;
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.btn-primary:hover {
|
|
||||||
background-color: #ffa64d;
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced error container with scanlines */
|
|
||||||
.error-container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: flicker 4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scanline effect for error container */
|
|
||||||
.error-container::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.1),
|
|
||||||
rgba(0, 0, 0, 0.1) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--primary-color);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-family: var(--header-font);
|
|
||||||
font-weight: bold;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cursor blink for terminal feel */
|
|
||||||
.terminal-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 20px;
|
|
||||||
background-color: #f7931a;
|
|
||||||
margin-left: 2px;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error code styling */
|
|
||||||
.error-code {
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #00dfff;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
@ -1,327 +0,0 @@
|
|||||||
/* notifications.css */
|
|
||||||
/* Notification Controls */
|
|
||||||
.notification-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-timestamp {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button:hover {
|
|
||||||
background-color: rgba(247, 147, 26, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button.active {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
min-width: 80px; /* Set a minimum width to prevent text cutoff */
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.9rem; /* Slightly smaller font */
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button:hover {
|
|
||||||
background-color: rgba(247, 147, 26, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button.danger {
|
|
||||||
border-color: #ff5555;
|
|
||||||
color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button.danger:hover {
|
|
||||||
background-color: rgba(255, 85, 85, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card header with unread badge */
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unread-badge {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
min-width: 25px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unread-badge:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notifications Container */
|
|
||||||
#notifications-container {
|
|
||||||
min-height: 200px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-message {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Item */
|
|
||||||
.notification-item {
|
|
||||||
display: flex;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid rgba(247, 147, 26, 0.2);
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item:hover {
|
|
||||||
background-color: rgba(247, 147, 26, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-read="true"] {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="success"] {
|
|
||||||
border-left: 3px solid #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="info"] {
|
|
||||||
border-left: 3px solid #00dfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="warning"] {
|
|
||||||
border-left: 3px solid #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="error"] {
|
|
||||||
border-left: 3px solid #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
|
||||||
flex: 0 0 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="success"] .notification-icon i {
|
|
||||||
color: #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="info"] .notification-icon i {
|
|
||||||
color: #00dfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="warning"] .notification-icon i {
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item[data-level="error"] .notification-icon i {
|
|
||||||
color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
word-break: break-word;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-meta {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #888;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-category {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-actions {
|
|
||||||
flex: 0 0 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-actions button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #888;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark-read-button:hover {
|
|
||||||
color: #32CD32;
|
|
||||||
background-color: rgba(50, 205, 50, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-button:hover {
|
|
||||||
color: #ff5555;
|
|
||||||
background-color: rgba(255, 85, 85, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination-controls {
|
|
||||||
margin-top: 15px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-button {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 5px 15px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-button:hover {
|
|
||||||
background-color: rgba(247, 147, 26, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Animation */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
animation: fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.notification-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button {
|
|
||||||
width: 100%; /* Full width on small screens */
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-controls {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons {
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
|
||||||
flex: 0 0 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-actions {
|
|
||||||
flex: 0 0 60px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,209 +0,0 @@
|
|||||||
/* Theme Toggle Button with positioning logic similar to topRightLink */
|
|
||||||
#themeToggle,
|
|
||||||
.theme-toggle-btn {
|
|
||||||
position: absolute; /* Change from fixed to absolute like topRightLink */
|
|
||||||
z-index: 1000;
|
|
||||||
background: transparent;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-transform: uppercase;
|
|
||||||
outline: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
top: 30px; /* Match the top positioning of topRightLink */
|
|
||||||
left: 15px; /* Keep on left side */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop specific styling */
|
|
||||||
@media screen and (min-width: 768px) {
|
|
||||||
#themeToggle,
|
|
||||||
.theme-toggle-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 3px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add theme icon for desktop view */
|
|
||||||
#themeToggle:before,
|
|
||||||
.theme-toggle-btn:before {
|
|
||||||
content: " ₿|🌊";
|
|
||||||
margin-right: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effects for desktop */
|
|
||||||
#themeToggle:hover,
|
|
||||||
.theme-toggle-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile-specific styling */
|
|
||||||
@media screen and (max-width: 767px) {
|
|
||||||
#themeToggle,
|
|
||||||
.theme-toggle-btn {
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
width: 40px;
|
|
||||||
height: 35px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Use just icon for mobile to save space */
|
|
||||||
#themeToggle:before,
|
|
||||||
.theme-toggle-btn:before {
|
|
||||||
content: " ₿|🌊";
|
|
||||||
margin-right: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide text on mobile */
|
|
||||||
#themeToggle span,
|
|
||||||
.theme-toggle-btn span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust position when in portrait mode on very small screens */
|
|
||||||
@media screen and (max-height: 500px) {
|
|
||||||
#themeToggle,
|
|
||||||
.theme-toggle-btn {
|
|
||||||
top: 5px;
|
|
||||||
left: 5px; /* Keep on left side */
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The rest of the CSS remains unchanged */
|
|
||||||
/* Active state for the button */
|
|
||||||
#themeToggle:active,
|
|
||||||
.theme-toggle-btn:active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bitcoin theme specific styling (orange) */
|
|
||||||
body:not(.deepsea-theme) #themeToggle,
|
|
||||||
body:not(.deepsea-theme) .theme-toggle-btn {
|
|
||||||
color: #f2a900;
|
|
||||||
border-color: #f2a900;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.deepsea-theme) #themeToggle:hover,
|
|
||||||
body:not(.deepsea-theme) .theme-toggle-btn:hover {
|
|
||||||
background-color: rgba(242, 169, 0, 0.1);
|
|
||||||
box-shadow: 0 4px 8px rgba(242, 169, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DeepSea theme specific styling (blue) */
|
|
||||||
body.deepsea-theme #themeToggle,
|
|
||||||
body.deepsea-theme .theme-toggle-btn {
|
|
||||||
color: #0088cc;
|
|
||||||
border-color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme #themeToggle:hover,
|
|
||||||
body.deepsea-theme .theme-toggle-btn:hover {
|
|
||||||
background-color: rgba(0, 136, 204, 0.1);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 136, 204, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition effect for smoother theme switching */
|
|
||||||
#themeToggle,
|
|
||||||
.theme-toggle-btn,
|
|
||||||
#themeToggle:before,
|
|
||||||
.theme-toggle-btn:before {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility improvements */
|
|
||||||
#themeToggle:focus,
|
|
||||||
.theme-toggle-btn:focus {
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.3);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.deepsea-theme) #themeToggle:focus,
|
|
||||||
body:not(.deepsea-theme) .theme-toggle-btn:focus {
|
|
||||||
box-shadow: 0 0 0 3px rgba(242, 169, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.deepsea-theme #themeToggle:focus,
|
|
||||||
body.deepsea-theme .theme-toggle-btn:focus {
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 136, 204, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add to your common.css or theme-toggle.css */
|
|
||||||
html.deepsea-theme {
|
|
||||||
--primary-color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.bitcoin-theme {
|
|
||||||
--primary-color: #f2a900;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add these theme-specific loading styles */
|
|
||||||
#theme-loader {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 9999;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.bitcoin-theme #theme-loader {
|
|
||||||
background-color: #111111;
|
|
||||||
color: #f2a900;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.deepsea-theme #theme-loader {
|
|
||||||
background-color: #0c141a;
|
|
||||||
color: #0088cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loader-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: spin 2s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loader-text {
|
|
||||||
font-size: 24px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,341 +0,0 @@
|
|||||||
/* Styles specific to the workers page */
|
|
||||||
|
|
||||||
/* Search and filter controls */
|
|
||||||
.controls-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-family: var(--terminal-font);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button.active {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Worker grid for worker cards */
|
|
||||||
.worker-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Worker card styles */
|
|
||||||
.worker-card {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 10px;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.05),
|
|
||||||
rgba(0, 0, 0, 0.05) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-card-online {
|
|
||||||
border-color: #32CD32;
|
|
||||||
box-shadow: 0 0 8px rgba(50, 205, 50, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-card-offline {
|
|
||||||
border-color: #ff5555;
|
|
||||||
box-shadow: 0 0 8px rgba(255, 85, 85, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-name {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-stats {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-stats-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-stats-label {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashrate-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: linear-gradient(90deg, #1137F5, #39ff14);
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Worker badge */
|
|
||||||
.worker-type {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
padding: 1px 5px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badges */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge-online {
|
|
||||||
background-color: rgba(50, 205, 50, 0.2);
|
|
||||||
border: 1px solid #32CD32;
|
|
||||||
color: #32CD32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge-offline {
|
|
||||||
background-color: rgba(255, 85, 85, 0.2);
|
|
||||||
border: 1px solid #ff5555;
|
|
||||||
color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats bars */
|
|
||||||
.stats-bar-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
margin-top: 2px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #ff2d2d, #39ff14);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Summary stats in the header */
|
|
||||||
.summary-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-around;
|
|
||||||
gap: 15px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-stat {
|
|
||||||
text-align: center;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-stat-value {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
/* font-weight: bold; */
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-stat-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Worker count ring */
|
|
||||||
.worker-ring {
|
|
||||||
width: 90px;
|
|
||||||
height: 90px;
|
|
||||||
border-radius: 50%;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: conic-gradient(
|
|
||||||
#32CD32 0% calc(var(--online-percent) * 100%),
|
|
||||||
#ff5555 calc(var(--online-percent) * 100%) 100%
|
|
||||||
);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-ring-inner {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mini hashrate chart */
|
|
||||||
.mini-chart {
|
|
||||||
height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 5px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-fade {
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.controls-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-stats {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-stat {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* Fix for "Made by" link collision with title */
|
|
||||||
#topRightLink {
|
|
||||||
position: static !important;
|
|
||||||
display: block !important;
|
|
||||||
text-align: right !important;
|
|
||||||
margin-bottom: 0.5rem !important;
|
|
||||||
margin-top: 0 !important;
|
|
||||||
font-size: 0.8rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust heading for better mobile display */
|
|
||||||
h1 {
|
|
||||||
font-size: 20px !important;
|
|
||||||
line-height: 1.2 !important;
|
|
||||||
margin-top: 0.5rem !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve container padding for mobile */
|
|
||||||
.container-fluid {
|
|
||||||
padding-left: 0.5rem !important;
|
|
||||||
padding-right: 0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure top section has appropriate spacing */
|
|
||||||
.row.mb-3 {
|
|
||||||
margin-top: 0.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add a more aggressive breakpoint for very small screens */
|
|
||||||
@media (max-width: 380px) {
|
|
||||||
#topRightLink {
|
|
||||||
margin-bottom: 0.75rem !important;
|
|
||||||
font-size: 0.7rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
margin-bottom: 0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Further reduce container padding */
|
|
||||||
.container-fluid {
|
|
||||||
padding-left: 0.3rem !important;
|
|
||||||
padding-right: 0.3rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Add extra padding at bottom of worker grid to avoid overlap */
|
|
||||||
.worker-grid {
|
|
||||||
margin-bottom: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure summary stats have proper spacing on mobile */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.summary-stats {
|
|
||||||
margin-bottom: 60px;
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
1040
static/js/blocks.js
1040
static/js/blocks.js
File diff suppressed because it is too large
Load Diff
2431
static/js/main.js
2431
static/js/main.js
File diff suppressed because it is too large
Load Diff
@ -1,503 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Global variables
|
|
||||||
let currentFilter = "all";
|
|
||||||
let currentOffset = 0;
|
|
||||||
const pageSize = 20;
|
|
||||||
let hasMoreNotifications = true;
|
|
||||||
let isLoading = false;
|
|
||||||
|
|
||||||
// Timezone configuration
|
|
||||||
let dashboardTimezone = 'America/Los_Angeles'; // Default
|
|
||||||
window.dashboardTimezone = dashboardTimezone; // Make it globally accessible
|
|
||||||
|
|
||||||
// Initialize when document is ready
|
|
||||||
$(document).ready(() => {
|
|
||||||
console.log("Notification page initializing...");
|
|
||||||
|
|
||||||
// Fetch timezone configuration
|
|
||||||
fetchTimezoneConfig();
|
|
||||||
|
|
||||||
// Set up filter buttons
|
|
||||||
$('.filter-button').click(function () {
|
|
||||||
$('.filter-button').removeClass('active');
|
|
||||||
$(this).addClass('active');
|
|
||||||
currentFilter = $(this).data('filter');
|
|
||||||
resetAndLoadNotifications();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up action buttons
|
|
||||||
$('#mark-all-read').click(markAllAsRead);
|
|
||||||
$('#clear-read').click(clearReadNotifications);
|
|
||||||
$('#clear-all').click(clearAllNotifications);
|
|
||||||
$('#load-more').click(loadMoreNotifications);
|
|
||||||
|
|
||||||
// Initial load of notifications
|
|
||||||
loadNotifications();
|
|
||||||
|
|
||||||
// Start polling for unread count
|
|
||||||
startUnreadCountPolling();
|
|
||||||
|
|
||||||
// Initialize BitcoinMinuteRefresh if available
|
|
||||||
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.initialize) {
|
|
||||||
BitcoinMinuteRefresh.initialize(refreshNotifications);
|
|
||||||
console.log("BitcoinMinuteRefresh initialized with refresh function");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start periodic update of notification timestamps every 30 seconds
|
|
||||||
setInterval(updateNotificationTimestamps, 30000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch timezone configuration from server
|
|
||||||
function fetchTimezoneConfig() {
|
|
||||||
return fetch('/api/timezone')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.timezone) {
|
|
||||||
dashboardTimezone = data.timezone;
|
|
||||||
window.dashboardTimezone = dashboardTimezone; // Make it globally accessible
|
|
||||||
console.log(`Notifications page using timezone: ${dashboardTimezone}`);
|
|
||||||
|
|
||||||
// Store in localStorage for future use
|
|
||||||
try {
|
|
||||||
localStorage.setItem('dashboardTimezone', dashboardTimezone);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error storing timezone in localStorage:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all timestamps with the new timezone
|
|
||||||
updateNotificationTimestamps();
|
|
||||||
return dashboardTimezone;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching timezone config:', error);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load notifications with current filter
|
|
||||||
function loadNotifications() {
|
|
||||||
if (isLoading) return;
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
showLoading();
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
limit: pageSize,
|
|
||||||
offset: currentOffset
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentFilter !== "all") {
|
|
||||||
params.category = currentFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: `/api/notifications?${$.param(params)}`,
|
|
||||||
method: "GET",
|
|
||||||
dataType: "json",
|
|
||||||
success: (data) => {
|
|
||||||
renderNotifications(data.notifications, currentOffset === 0);
|
|
||||||
updateUnreadBadge(data.unread_count);
|
|
||||||
|
|
||||||
// Update load more button state
|
|
||||||
hasMoreNotifications = data.notifications.length === pageSize;
|
|
||||||
$('#load-more').prop('disabled', !hasMoreNotifications);
|
|
||||||
|
|
||||||
isLoading = false;
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error loading notifications:", error);
|
|
||||||
showError("Failed to load notifications. Please try again.");
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset offset and load notifications
|
|
||||||
function resetAndLoadNotifications() {
|
|
||||||
currentOffset = 0;
|
|
||||||
loadNotifications();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load more notifications
|
|
||||||
function loadMoreNotifications() {
|
|
||||||
if (!hasMoreNotifications || isLoading) return;
|
|
||||||
|
|
||||||
currentOffset += pageSize;
|
|
||||||
loadNotifications();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh notifications (for periodic updates)
|
|
||||||
function refreshNotifications() {
|
|
||||||
// Only refresh if we're on the first page
|
|
||||||
if (currentOffset === 0) {
|
|
||||||
resetAndLoadNotifications();
|
|
||||||
} else {
|
|
||||||
// Just update the unread count
|
|
||||||
updateUnreadCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This refreshes all timestamps on the page periodically
|
|
||||||
function updateNotificationTimestamps() {
|
|
||||||
$('.notification-item').each(function () {
|
|
||||||
const timestampStr = $(this).attr('data-timestamp');
|
|
||||||
if (timestampStr) {
|
|
||||||
try {
|
|
||||||
const timestamp = new Date(timestampStr);
|
|
||||||
|
|
||||||
// Update relative time
|
|
||||||
$(this).find('.notification-time').text(formatTimestamp(timestamp));
|
|
||||||
|
|
||||||
// Update full timestamp with configured timezone
|
|
||||||
if ($(this).find('.full-timestamp').length) {
|
|
||||||
const options = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
timeZone: window.dashboardTimezone || 'America/Los_Angeles'
|
|
||||||
};
|
|
||||||
|
|
||||||
const fullTimestamp = timestamp.toLocaleString('en-US', options);
|
|
||||||
$(this).find('.full-timestamp').text(fullTimestamp);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error updating timestamp:", e, timestampStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading indicator
|
|
||||||
function showLoading() {
|
|
||||||
if (currentOffset === 0) {
|
|
||||||
// First page load, show loading message
|
|
||||||
$('#notifications-container').html('<div class="loading-message">Loading notifications<span class="terminal-cursor"></span></div>');
|
|
||||||
} else {
|
|
||||||
// Pagination load, show loading below
|
|
||||||
$('#load-more').prop('disabled', true).text('Loading...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error message
|
|
||||||
function showError(message) {
|
|
||||||
$('#notifications-container').html(`<div class="error-message">${message}</div>`);
|
|
||||||
$('#load-more').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render notifications in the container
|
|
||||||
function renderNotifications(notifications, isFirstPage) {
|
|
||||||
const container = $('#notifications-container');
|
|
||||||
|
|
||||||
// If first page and no notifications
|
|
||||||
if (isFirstPage && (!notifications || notifications.length === 0)) {
|
|
||||||
container.html($('#empty-template').html());
|
|
||||||
$('#load-more').hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If first page, clear container
|
|
||||||
if (isFirstPage) {
|
|
||||||
container.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render each notification
|
|
||||||
notifications.forEach(notification => {
|
|
||||||
const notificationElement = createNotificationElement(notification);
|
|
||||||
container.append(notificationElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show/hide load more button
|
|
||||||
$('#load-more').show().prop('disabled', !hasMoreNotifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification element from template
|
|
||||||
function createNotificationElement(notification) {
|
|
||||||
const template = $('#notification-template').html();
|
|
||||||
const element = $(template);
|
|
||||||
|
|
||||||
// Set data attributes
|
|
||||||
element.attr('data-id', notification.id)
|
|
||||||
.attr('data-level', notification.level)
|
|
||||||
.attr('data-category', notification.category)
|
|
||||||
.attr('data-read', notification.read)
|
|
||||||
.attr('data-timestamp', notification.timestamp);
|
|
||||||
|
|
||||||
// Set icon based on level
|
|
||||||
const iconElement = element.find('.notification-icon i');
|
|
||||||
switch (notification.level) {
|
|
||||||
case 'success':
|
|
||||||
iconElement.addClass('fa-check-circle');
|
|
||||||
break;
|
|
||||||
case 'info':
|
|
||||||
iconElement.addClass('fa-info-circle');
|
|
||||||
break;
|
|
||||||
case 'warning':
|
|
||||||
iconElement.addClass('fa-exclamation-triangle');
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
iconElement.addClass('fa-times-circle');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
iconElement.addClass('fa-bell');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important: Do not append "Z" here, as that can cause timezone issues
|
|
||||||
// Create a date object from the notification timestamp
|
|
||||||
let notificationDate;
|
|
||||||
try {
|
|
||||||
// Parse the timestamp directly without modifications
|
|
||||||
notificationDate = new Date(notification.timestamp);
|
|
||||||
|
|
||||||
// Validate the date object - if invalid, try alternative approach
|
|
||||||
if (isNaN(notificationDate.getTime())) {
|
|
||||||
console.warn("Invalid date from notification timestamp, trying alternative format");
|
|
||||||
|
|
||||||
// Try adding Z to make it explicit UTC if not already ISO format
|
|
||||||
if (!notification.timestamp.endsWith('Z') && !notification.timestamp.includes('+')) {
|
|
||||||
notificationDate = new Date(notification.timestamp + 'Z');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error parsing notification date:", e);
|
|
||||||
notificationDate = new Date(); // Fallback to current date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format the timestamp using the configured timezone
|
|
||||||
const options = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
timeZone: window.dashboardTimezone || 'America/Los_Angeles'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format full timestamp with configured timezone
|
|
||||||
let fullTimestamp;
|
|
||||||
try {
|
|
||||||
fullTimestamp = notificationDate.toLocaleString('en-US', options);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error formatting timestamp with timezone:", e);
|
|
||||||
fullTimestamp = notificationDate.toLocaleString('en-US'); // Fallback without timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append the message and formatted timestamp
|
|
||||||
const messageWithTimestamp = `${notification.message}<br><span class="full-timestamp">${fullTimestamp}</span>`;
|
|
||||||
element.find('.notification-message').html(messageWithTimestamp);
|
|
||||||
|
|
||||||
// Set metadata for relative time display
|
|
||||||
element.find('.notification-time').text(formatTimestamp(notificationDate));
|
|
||||||
element.find('.notification-category').text(notification.category);
|
|
||||||
|
|
||||||
// Set up action buttons
|
|
||||||
element.find('.mark-read-button').on('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
markAsRead(notification.id);
|
|
||||||
});
|
|
||||||
element.find('.delete-button').on('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteNotification(notification.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide mark as read button if already read
|
|
||||||
if (notification.read) {
|
|
||||||
element.find('.mark-read-button').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(timestamp) {
|
|
||||||
// Ensure we have a valid date object
|
|
||||||
let dateObj = timestamp;
|
|
||||||
if (!(timestamp instanceof Date) || isNaN(timestamp.getTime())) {
|
|
||||||
try {
|
|
||||||
dateObj = new Date(timestamp);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Invalid timestamp in formatTimestamp:", e);
|
|
||||||
return "unknown time";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time difference in local timezone context
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now - dateObj;
|
|
||||||
const diffSec = Math.floor(diffMs / 1000);
|
|
||||||
const diffMin = Math.floor(diffSec / 60);
|
|
||||||
const diffHour = Math.floor(diffMin / 60);
|
|
||||||
const diffDay = Math.floor(diffHour / 24);
|
|
||||||
|
|
||||||
if (diffSec < 60) {
|
|
||||||
return "just now";
|
|
||||||
} else if (diffMin < 60) {
|
|
||||||
return `${diffMin}m ago`;
|
|
||||||
} else if (diffHour < 24) {
|
|
||||||
return `${diffHour}h ago`;
|
|
||||||
} else if (diffDay < 30) {
|
|
||||||
return `${diffDay}d ago`;
|
|
||||||
} else {
|
|
||||||
// Format as date for older notifications using configured timezone
|
|
||||||
const options = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: window.dashboardTimezone || 'America/Los_Angeles'
|
|
||||||
};
|
|
||||||
return dateObj.toLocaleDateString('en-US', options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark a notification as read
|
|
||||||
function markAsRead(notificationId) {
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/mark_read",
|
|
||||||
method: "POST",
|
|
||||||
data: JSON.stringify({ notification_id: notificationId }),
|
|
||||||
contentType: "application/json",
|
|
||||||
success: (data) => {
|
|
||||||
// Update UI
|
|
||||||
$(`[data-id="${notificationId}"]`).attr('data-read', 'true');
|
|
||||||
$(`[data-id="${notificationId}"]`).find('.mark-read-button').hide();
|
|
||||||
|
|
||||||
// Update unread badge
|
|
||||||
updateUnreadBadge(data.unread_count);
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error marking notification as read:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark all notifications as read
|
|
||||||
function markAllAsRead() {
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/mark_read",
|
|
||||||
method: "POST",
|
|
||||||
data: JSON.stringify({}),
|
|
||||||
contentType: "application/json",
|
|
||||||
success: (data) => {
|
|
||||||
// Update UI
|
|
||||||
$('.notification-item').attr('data-read', 'true');
|
|
||||||
$('.mark-read-button').hide();
|
|
||||||
|
|
||||||
// Update unread badge
|
|
||||||
updateUnreadBadge(0);
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error marking all notifications as read:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete a notification
|
|
||||||
function deleteNotification(notificationId) {
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/delete",
|
|
||||||
method: "POST",
|
|
||||||
data: JSON.stringify({ notification_id: notificationId }),
|
|
||||||
contentType: "application/json",
|
|
||||||
success: (data) => {
|
|
||||||
// Remove from UI with animation
|
|
||||||
$(`[data-id="${notificationId}"]`).fadeOut(300, function () {
|
|
||||||
$(this).remove();
|
|
||||||
|
|
||||||
// Check if container is empty now
|
|
||||||
if ($('#notifications-container').children().length === 0) {
|
|
||||||
$('#notifications-container').html($('#empty-template').html());
|
|
||||||
$('#load-more').hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update unread badge
|
|
||||||
updateUnreadBadge(data.unread_count);
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error deleting notification:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear read notifications
|
|
||||||
function clearReadNotifications() {
|
|
||||||
if (!confirm("Are you sure you want to clear all read notifications?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/clear",
|
|
||||||
method: "POST",
|
|
||||||
data: JSON.stringify({
|
|
||||||
// Special parameter to clear only read notifications
|
|
||||||
read_only: true
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
success: () => {
|
|
||||||
// Reload notifications
|
|
||||||
resetAndLoadNotifications();
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error clearing read notifications:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all notifications
|
|
||||||
function clearAllNotifications() {
|
|
||||||
if (!confirm("Are you sure you want to clear ALL notifications? This cannot be undone.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/clear",
|
|
||||||
method: "POST",
|
|
||||||
data: JSON.stringify({}),
|
|
||||||
contentType: "application/json",
|
|
||||||
success: () => {
|
|
||||||
// Reload notifications
|
|
||||||
resetAndLoadNotifications();
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error clearing all notifications:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update unread badge
|
|
||||||
function updateUnreadBadge(count) {
|
|
||||||
$('#unread-badge').text(count);
|
|
||||||
|
|
||||||
// Add special styling if unread
|
|
||||||
if (count > 0) {
|
|
||||||
$('#unread-badge').addClass('has-unread');
|
|
||||||
} else {
|
|
||||||
$('#unread-badge').removeClass('has-unread');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update unread count from API
|
|
||||||
function updateUnreadCount() {
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/unread_count",
|
|
||||||
method: "GET",
|
|
||||||
success: (data) => {
|
|
||||||
updateUnreadBadge(data.unread_count);
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => {
|
|
||||||
console.error("Error updating unread count:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling for unread count
|
|
||||||
function startUnreadCountPolling() {
|
|
||||||
// Update every 30 seconds
|
|
||||||
setInterval(updateUnreadCount, 30000);
|
|
||||||
}
|
|
@ -1,440 +0,0 @@
|
|||||||
// Add this flag at the top of your file, outside the function
|
|
||||||
let isApplyingTheme = false;
|
|
||||||
|
|
||||||
// Bitcoin Orange theme (default)
|
|
||||||
const BITCOIN_THEME = {
|
|
||||||
PRIMARY: '#f2a900',
|
|
||||||
PRIMARY_RGB: '242, 169, 0',
|
|
||||||
SHARED: {
|
|
||||||
GREEN: '#32CD32',
|
|
||||||
RED: '#ff5555',
|
|
||||||
YELLOW: '#ffd700'
|
|
||||||
},
|
|
||||||
CHART: {
|
|
||||||
GRADIENT_START: '#f2a900',
|
|
||||||
GRADIENT_END: 'rgba(242, 169, 0, 0.2)',
|
|
||||||
ANNOTATION: '#ffd700'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// DeepSea theme (blue alternative)
|
|
||||||
const DEEPSEA_THEME = {
|
|
||||||
PRIMARY: '#0088cc',
|
|
||||||
PRIMARY_RGB: '0, 136, 204',
|
|
||||||
SHARED: {
|
|
||||||
GREEN: '#32CD32',
|
|
||||||
RED: '#ff5555',
|
|
||||||
YELLOW: '#ffd700'
|
|
||||||
},
|
|
||||||
CHART: {
|
|
||||||
GRADIENT_START: '#0088cc',
|
|
||||||
GRADIENT_END: 'rgba(0, 136, 204, 0.2)',
|
|
||||||
ANNOTATION: '#00b3ff'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global theme constants
|
|
||||||
const THEME = {
|
|
||||||
BITCOIN: BITCOIN_THEME,
|
|
||||||
DEEPSEA: DEEPSEA_THEME,
|
|
||||||
SHARED: BITCOIN_THEME.SHARED
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get the current theme based on localStorage setting
|
|
||||||
function getCurrentTheme() {
|
|
||||||
const useDeepSea = localStorage.getItem('useDeepSeaTheme') === 'true';
|
|
||||||
return useDeepSea ? DEEPSEA_THEME : BITCOIN_THEME;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make globals available
|
|
||||||
window.THEME = THEME;
|
|
||||||
window.getCurrentTheme = getCurrentTheme;
|
|
||||||
|
|
||||||
// Use window-scoped variable to prevent conflicts
|
|
||||||
window.themeProcessing = false;
|
|
||||||
|
|
||||||
// Fixed applyDeepSeaTheme function with recursion protection
|
|
||||||
function applyDeepSeaTheme() {
|
|
||||||
// Check if we're already applying the theme to prevent recursion
|
|
||||||
if (window.themeProcessing) {
|
|
||||||
console.log("Theme application already in progress, avoiding recursion");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the guard flag
|
|
||||||
isApplyingTheme = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Applying DeepSea theme...");
|
|
||||||
|
|
||||||
// Create or update CSS variables for the DeepSea theme
|
|
||||||
const styleElement = document.createElement('style');
|
|
||||||
styleElement.id = 'deepSeaThemeStyles'; // Give it an ID so we can check if it exists
|
|
||||||
|
|
||||||
// Enhanced CSS with clean, organized structure
|
|
||||||
styleElement.textContent = `
|
|
||||||
/* Base theme variables */
|
|
||||||
:root {
|
|
||||||
--primary-color: #0088cc;
|
|
||||||
--primary-color-rgb: 0, 136, 204;
|
|
||||||
--accent-color: #00b3ff;
|
|
||||||
--bg-gradient: linear-gradient(135deg, #0a0a0a, #131b20);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card styling */
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header, .card > .card-header {
|
|
||||||
background: linear-gradient(to right, var(--primary-color), #006699) !important;
|
|
||||||
border-bottom: 1px solid var(--primary-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation */
|
|
||||||
.nav-link {
|
|
||||||
border: 1px solid var(--primary-color) !important;
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Interface elements */
|
|
||||||
#terminal-cursor {
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lastUpdated {
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, .text-center h1 {
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-badge {
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bitcoin progress elements */
|
|
||||||
.bitcoin-progress-inner {
|
|
||||||
background: linear-gradient(90deg, var(--primary-color), var(--accent-color)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitcoin-progress-container {
|
|
||||||
border: 1px solid var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Theme toggle button styling */
|
|
||||||
#themeToggle, button.theme-toggle, .toggle-theme-btn {
|
|
||||||
background: transparent !important;
|
|
||||||
border: 1px solid var(--primary-color) !important;
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#themeToggle:hover, button.theme-toggle:hover, .toggle-theme-btn:hover {
|
|
||||||
background-color: rgba(var(--primary-color-rgb), 0.1) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== SPECIAL CASE FIXES ===== */
|
|
||||||
|
|
||||||
/* Pool hashrate - always white */
|
|
||||||
[id^="pool_"] {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Block page elements */
|
|
||||||
.stat-item strong,
|
|
||||||
.block-height,
|
|
||||||
.block-detail-title {
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Block inputs and button styles */
|
|
||||||
.block-input:focus {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-button:hover {
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
color: #000 !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification page elements */
|
|
||||||
.filter-button.active {
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
color: #000 !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button:hover,
|
|
||||||
.action-button:hover:not(.danger),
|
|
||||||
.load-more-button:hover {
|
|
||||||
background-color: rgba(var(--primary-color-rgb), 0.2) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Block cards and modals */
|
|
||||||
.block-card:hover {
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-content {
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-modal-close:hover,
|
|
||||||
.block-modal-close:focus {
|
|
||||||
color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== COLOR CATEGORIES ===== */
|
|
||||||
|
|
||||||
/* YELLOW - SATOSHI EARNINGS & BTC PRICE */
|
|
||||||
[id$="_sats"],
|
|
||||||
#btc_price,
|
|
||||||
.metric-value[id$="_sats"],
|
|
||||||
.est_time_to_payout:not(.green):not(.red) {
|
|
||||||
color: #ffd700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* GREEN - POSITIVE USD VALUES */
|
|
||||||
.metric-value.green,
|
|
||||||
span.green,
|
|
||||||
#daily_revenue:not([style*="color: #ff"]),
|
|
||||||
#monthly_profit_usd:not([style*="color: #ff"]),
|
|
||||||
#daily_profit_usd:not([style*="color: #ff"]),
|
|
||||||
.status-green,
|
|
||||||
#pool_luck.very-lucky,
|
|
||||||
#pool_luck.lucky {
|
|
||||||
color: #32CD32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-dot {
|
|
||||||
background: #32CD32 !important;
|
|
||||||
box-shadow: 0 0 10px #32CD32, 0 0 10px #32CD32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light green for "lucky" status */
|
|
||||||
#pool_luck.lucky {
|
|
||||||
color: #90EE90 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NORMAL LUCK - KHAKI */
|
|
||||||
#pool_luck.normal-luck {
|
|
||||||
color: #F0E68C !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RED - NEGATIVE VALUES & WARNINGS */
|
|
||||||
.metric-value.red,
|
|
||||||
span.red,
|
|
||||||
.status-red,
|
|
||||||
#daily_power_cost,
|
|
||||||
#pool_luck.unlucky {
|
|
||||||
color: #ff5555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offline-dot {
|
|
||||||
background: #ff5555 !important;
|
|
||||||
box-shadow: 0 0 10px #ff5555, 0 0 10px #ff5555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WHITE - NETWORK STATS & WORKER DATA */
|
|
||||||
#block_number,
|
|
||||||
#difficulty,
|
|
||||||
#network_hashrate,
|
|
||||||
#pool_fees_percentage,
|
|
||||||
#workers_hashing,
|
|
||||||
#last_share,
|
|
||||||
#blocks_found,
|
|
||||||
#last_block_height,
|
|
||||||
#hashrate_24hr,
|
|
||||||
#hashrate_3hr,
|
|
||||||
#hashrate_10min,
|
|
||||||
#hashrate_60sec {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CYAN - TIME AGO IN LAST BLOCK */
|
|
||||||
#last_block_time {
|
|
||||||
color: #00ffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CONGRATULATIONS MESSAGE */
|
|
||||||
#congratsMessage {
|
|
||||||
background: var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.7) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ANIMATIONS */
|
|
||||||
@keyframes waitingPulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 10px var(--primary-color), 0 0 10px var(--primary-color) !important; opacity: 0.8; }
|
|
||||||
50% { box-shadow: 0 0 10px var(--primary-color), 0 0 10px var(--primary-color) !important; opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow {
|
|
||||||
0%, 100% { box-shadow: 0 0 10px var(--primary-color), 0 0 10px var(--primary-color) !important; }
|
|
||||||
50% { box-shadow: 0 0 10px var(--primary-color), 0 0 10px var(--primary-color) !important; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Check if our style element already exists
|
|
||||||
const existingStyle = document.getElementById('deepSeaThemeStyles');
|
|
||||||
if (existingStyle) {
|
|
||||||
existingStyle.parentNode.removeChild(existingStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add our new style element to the head
|
|
||||||
document.head.appendChild(styleElement);
|
|
||||||
|
|
||||||
// Update page title
|
|
||||||
document.title = document.title.replace("BTC-OS", "DeepSea");
|
|
||||||
document.title = document.title.replace("Bitcoin", "DeepSea");
|
|
||||||
|
|
||||||
// Update header text
|
|
||||||
const headerElement = document.querySelector('h1');
|
|
||||||
if (headerElement) {
|
|
||||||
headerElement.innerHTML = headerElement.innerHTML.replace("BTC-OS", "DeepSea");
|
|
||||||
headerElement.innerHTML = headerElement.innerHTML.replace("BITCOIN", "DEEPSEA");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update theme toggle button
|
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
|
||||||
if (themeToggle) {
|
|
||||||
themeToggle.style.borderColor = '#0088cc';
|
|
||||||
themeToggle.style.color = '#0088cc';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("DeepSea theme applied with color adjustments");
|
|
||||||
} finally {
|
|
||||||
// Reset the guard flag when done, even if there's an error
|
|
||||||
setTimeout(() => { isApplyingTheme = false; }, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the function accessible globally
|
|
||||||
window.applyDeepSeaTheme = applyDeepSeaTheme;
|
|
||||||
|
|
||||||
// Toggle theme with hard page refresh
|
|
||||||
function toggleTheme() {
|
|
||||||
const useDeepSea = localStorage.getItem('useDeepSeaTheme') !== 'true';
|
|
||||||
|
|
||||||
// Save the new theme preference
|
|
||||||
saveThemePreference(useDeepSea);
|
|
||||||
|
|
||||||
// Show a themed loading message
|
|
||||||
const loadingMessage = document.createElement('div');
|
|
||||||
loadingMessage.id = 'theme-loader';
|
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.id = 'loader-icon';
|
|
||||||
icon.innerHTML = useDeepSea ? '🌊' : '₿';
|
|
||||||
|
|
||||||
const text = document.createElement('div');
|
|
||||||
text.id = 'loader-text';
|
|
||||||
text.textContent = 'Applying ' + (useDeepSea ? 'DeepSea' : 'Bitcoin') + ' Theme';
|
|
||||||
|
|
||||||
loadingMessage.appendChild(icon);
|
|
||||||
loadingMessage.appendChild(text);
|
|
||||||
|
|
||||||
// Apply immediate styling
|
|
||||||
loadingMessage.style.position = 'fixed';
|
|
||||||
loadingMessage.style.top = '0';
|
|
||||||
loadingMessage.style.left = '0';
|
|
||||||
loadingMessage.style.width = '100%';
|
|
||||||
loadingMessage.style.height = '100%';
|
|
||||||
loadingMessage.style.backgroundColor = useDeepSea ? '#0c141a' : '#111111';
|
|
||||||
loadingMessage.style.color = useDeepSea ? '#0088cc' : '#f2a900';
|
|
||||||
loadingMessage.style.display = 'flex';
|
|
||||||
loadingMessage.style.flexDirection = 'column';
|
|
||||||
loadingMessage.style.justifyContent = 'center';
|
|
||||||
loadingMessage.style.alignItems = 'center';
|
|
||||||
loadingMessage.style.zIndex = '9999';
|
|
||||||
loadingMessage.style.fontFamily = "'VT323', monospace";
|
|
||||||
|
|
||||||
document.body.appendChild(loadingMessage);
|
|
||||||
|
|
||||||
// Short delay before refreshing
|
|
||||||
setTimeout(() => {
|
|
||||||
// Hard reload the page
|
|
||||||
window.location.reload();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set theme preference to localStorage
|
|
||||||
function saveThemePreference(useDeepSea) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('useDeepSeaTheme', useDeepSea);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error saving theme preference:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is the first startup by checking for the "firstStartup" flag
|
|
||||||
function isFirstStartup() {
|
|
||||||
return localStorage.getItem('hasStartedBefore') !== 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark that the app has started before
|
|
||||||
function markAppStarted() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('hasStartedBefore', 'true');
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error marking app as started:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize DeepSea as default on first startup
|
|
||||||
function initializeDefaultTheme() {
|
|
||||||
if (isFirstStartup()) {
|
|
||||||
console.log("First startup detected, setting DeepSea as default theme");
|
|
||||||
saveThemePreference(true); // Set DeepSea theme as default (true)
|
|
||||||
markAppStarted();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for theme preference in localStorage
|
|
||||||
function loadThemePreference() {
|
|
||||||
try {
|
|
||||||
// Check if it's first startup - if so, set DeepSea as default
|
|
||||||
const isFirstTime = initializeDefaultTheme();
|
|
||||||
|
|
||||||
// Get theme preference from localStorage
|
|
||||||
const themePreference = localStorage.getItem('useDeepSeaTheme');
|
|
||||||
|
|
||||||
// Apply theme based on preference
|
|
||||||
if (themePreference === 'true' || isFirstTime) {
|
|
||||||
applyDeepSeaTheme();
|
|
||||||
} else {
|
|
||||||
// Make sure the toggle button is styled correctly for Bitcoin theme
|
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
|
||||||
if (themeToggle) {
|
|
||||||
themeToggle.style.borderColor = '#f2a900';
|
|
||||||
themeToggle.style.color = '#f2a900';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error loading theme preference:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply theme on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', loadThemePreference);
|
|
||||||
|
|
||||||
// For pages that load content dynamically, also check when the window loads
|
|
||||||
window.addEventListener('load', loadThemePreference);
|
|
@ -1,565 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Global variables for workers dashboard
|
|
||||||
let workerData = null;
|
|
||||||
let refreshTimer;
|
|
||||||
const pageLoadTime = Date.now();
|
|
||||||
let lastManualRefreshTime = 0;
|
|
||||||
const filterState = {
|
|
||||||
currentFilter: 'all',
|
|
||||||
searchTerm: ''
|
|
||||||
};
|
|
||||||
let miniChart = null;
|
|
||||||
let connectionRetryCount = 0;
|
|
||||||
|
|
||||||
// Server time variables for uptime calculation - synced with main dashboard
|
|
||||||
let serverTimeOffset = 0;
|
|
||||||
let serverStartTime = null;
|
|
||||||
|
|
||||||
// New variable to track custom refresh timing
|
|
||||||
const MIN_REFRESH_INTERVAL = 10000; // Minimum 10 seconds between refreshes
|
|
||||||
|
|
||||||
// Hashrate Normalization Utilities
|
|
||||||
// Helper function to normalize hashrate to TH/s for consistent graphing
|
|
||||||
function normalizeHashrate(value, unit = 'th/s') {
|
|
||||||
if (!value || isNaN(value)) return 0;
|
|
||||||
|
|
||||||
unit = unit.toLowerCase();
|
|
||||||
const unitConversion = {
|
|
||||||
'ph/s': 1000,
|
|
||||||
'eh/s': 1000000,
|
|
||||||
'gh/s': 1 / 1000,
|
|
||||||
'mh/s': 1 / 1000000,
|
|
||||||
'kh/s': 1 / 1000000000,
|
|
||||||
'h/s': 1 / 1000000000000
|
|
||||||
};
|
|
||||||
|
|
||||||
return unitConversion[unit] !== undefined ? value * unitConversion[unit] : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format hashrate values for display
|
|
||||||
function formatHashrateForDisplay(value, unit) {
|
|
||||||
if (isNaN(value) || value === null || value === undefined) return "N/A";
|
|
||||||
|
|
||||||
const normalizedValue = unit ? normalizeHashrate(value, unit) : value;
|
|
||||||
const unitRanges = [
|
|
||||||
{ threshold: 1000000, unit: 'EH/s', divisor: 1000000 },
|
|
||||||
{ threshold: 1000, unit: 'PH/s', divisor: 1000 },
|
|
||||||
{ threshold: 1, unit: 'TH/s', divisor: 1 },
|
|
||||||
{ threshold: 0.001, unit: 'GH/s', divisor: 1 / 1000 },
|
|
||||||
{ threshold: 0, unit: 'MH/s', divisor: 1 / 1000000 }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const range of unitRanges) {
|
|
||||||
if (normalizedValue >= range.threshold) {
|
|
||||||
return (normalizedValue / range.divisor).toFixed(2) + ' ' + range.unit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (normalizedValue * 1000000).toFixed(2) + ' MH/s';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the page
|
|
||||||
$(document).ready(function () {
|
|
||||||
console.log("Worker page initializing...");
|
|
||||||
|
|
||||||
initNotificationBadge();
|
|
||||||
initializePage();
|
|
||||||
updateServerTime();
|
|
||||||
|
|
||||||
window.manualRefresh = fetchWorkerData;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.initialize) {
|
|
||||||
BitcoinMinuteRefresh.initialize(window.manualRefresh);
|
|
||||||
console.log("BitcoinMinuteRefresh initialized with refresh function");
|
|
||||||
} else {
|
|
||||||
console.warn("BitcoinMinuteRefresh not available");
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
fetchWorkerData();
|
|
||||||
|
|
||||||
$('.filter-button').click(function () {
|
|
||||||
$('.filter-button').removeClass('active');
|
|
||||||
$(this).addClass('active');
|
|
||||||
filterState.currentFilter = $(this).data('filter');
|
|
||||||
filterWorkers();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#worker-search').on('input', function () {
|
|
||||||
filterState.searchTerm = $(this).val().toLowerCase();
|
|
||||||
filterWorkers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load timezone setting early
|
|
||||||
(function loadTimezoneEarly() {
|
|
||||||
// First try to get from localStorage for instant access
|
|
||||||
try {
|
|
||||||
const storedTimezone = localStorage.getItem('dashboardTimezone');
|
|
||||||
if (storedTimezone) {
|
|
||||||
window.dashboardTimezone = storedTimezone;
|
|
||||||
console.log(`Using cached timezone: ${storedTimezone}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error reading timezone from localStorage:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then fetch from server to ensure we have the latest setting
|
|
||||||
fetch('/api/timezone')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.timezone) {
|
|
||||||
window.dashboardTimezone = data.timezone;
|
|
||||||
console.log(`Set timezone from server: ${data.timezone}`);
|
|
||||||
|
|
||||||
// Cache for future use
|
|
||||||
try {
|
|
||||||
localStorage.setItem('dashboardTimezone', data.timezone);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error storing timezone in localStorage:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error fetching timezone:", error);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Initialize page elements
|
|
||||||
function initializePage() {
|
|
||||||
console.log("Initializing page elements...");
|
|
||||||
|
|
||||||
if (document.getElementById('total-hashrate-chart')) {
|
|
||||||
initializeMiniChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#worker-grid').html('<div class="text-center p-5"><i class="fas fa-spinner fa-spin"></i> Loading worker data...</div>');
|
|
||||||
|
|
||||||
if (!$('#retry-button').length) {
|
|
||||||
$('body').append('<button id="retry-button" style="position: fixed; bottom: 20px; left: 20px; z-index: 1000; background: #f7931a; color: black; border: none; padding: 8px 16px; display: none; border-radius: 4px; cursor: pointer;">Retry Loading Data</button>');
|
|
||||||
|
|
||||||
$('#retry-button').on('click', function () {
|
|
||||||
$(this).text('Retrying...').prop('disabled', true);
|
|
||||||
fetchWorkerData(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
$('#retry-button').text('Retry Loading Data').prop('disabled', false);
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update unread notifications badge in navigation
|
|
||||||
function updateNotificationBadge() {
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/notifications/unread_count",
|
|
||||||
method: "GET",
|
|
||||||
success: function (data) {
|
|
||||||
const unreadCount = data.unread_count;
|
|
||||||
const badge = $("#nav-unread-badge");
|
|
||||||
|
|
||||||
if (unreadCount > 0) {
|
|
||||||
badge.text(unreadCount).show();
|
|
||||||
} else {
|
|
||||||
badge.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize notification badge checking
|
|
||||||
function initNotificationBadge() {
|
|
||||||
updateNotificationBadge();
|
|
||||||
setInterval(updateNotificationBadge, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server time update via polling - enhanced to use shared storage
|
|
||||||
function updateServerTime() {
|
|
||||||
console.log("Updating server time...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storedOffset = localStorage.getItem('serverTimeOffset');
|
|
||||||
const storedStartTime = localStorage.getItem('serverStartTime');
|
|
||||||
|
|
||||||
if (storedOffset && storedStartTime) {
|
|
||||||
serverTimeOffset = parseFloat(storedOffset);
|
|
||||||
serverStartTime = parseFloat(storedStartTime);
|
|
||||||
console.log("Using stored server time offset:", serverTimeOffset, "ms");
|
|
||||||
|
|
||||||
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.updateServerTime) {
|
|
||||||
BitcoinMinuteRefresh.updateServerTime(serverTimeOffset, serverStartTime);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error reading stored server time:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: "/api/time",
|
|
||||||
method: "GET",
|
|
||||||
timeout: 5000,
|
|
||||||
success: function (data) {
|
|
||||||
serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now();
|
|
||||||
serverStartTime = new Date(data.server_start_time).getTime();
|
|
||||||
|
|
||||||
localStorage.setItem('serverTimeOffset', serverTimeOffset.toString());
|
|
||||||
localStorage.setItem('serverStartTime', serverStartTime.toString());
|
|
||||||
|
|
||||||
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.updateServerTime) {
|
|
||||||
BitcoinMinuteRefresh.updateServerTime(serverTimeOffset, serverStartTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Server time synchronized. Offset:", serverTimeOffset, "ms");
|
|
||||||
},
|
|
||||||
error: function (jqXHR, textStatus, errorThrown) {
|
|
||||||
console.error("Error fetching server time:", textStatus, errorThrown);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions to show/hide loader
|
|
||||||
function showLoader() {
|
|
||||||
$("#loader").show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideLoader() {
|
|
||||||
$("#loader").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch worker data from API with pagination, limiting to 10 pages
|
|
||||||
function fetchWorkerData(forceRefresh = false) {
|
|
||||||
console.log("Fetching worker data...");
|
|
||||||
lastManualRefreshTime = Date.now();
|
|
||||||
$('#worker-grid').addClass('loading-fade');
|
|
||||||
showLoader();
|
|
||||||
|
|
||||||
const maxPages = 10;
|
|
||||||
const requests = [];
|
|
||||||
|
|
||||||
// Create requests for pages 1 through maxPages concurrently
|
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
|
||||||
const apiUrl = `/api/workers?page=${page}${forceRefresh ? '&force=true' : ''}`;
|
|
||||||
requests.push($.ajax({
|
|
||||||
url: apiUrl,
|
|
||||||
method: 'GET',
|
|
||||||
dataType: 'json',
|
|
||||||
timeout: 15000
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process all requests concurrently
|
|
||||||
Promise.all(requests)
|
|
||||||
.then(pages => {
|
|
||||||
let allWorkers = [];
|
|
||||||
let aggregatedData = null;
|
|
||||||
|
|
||||||
pages.forEach((data, i) => {
|
|
||||||
if (data && data.workers && data.workers.length > 0) {
|
|
||||||
allWorkers = allWorkers.concat(data.workers);
|
|
||||||
if (i === 0) {
|
|
||||||
aggregatedData = data; // preserve stats from first page
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`No workers found on page ${i + 1}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deduplicate workers if necessary (using worker.name as unique key)
|
|
||||||
const uniqueWorkers = allWorkers.filter((worker, index, self) =>
|
|
||||||
index === self.findIndex((w) => w.name === worker.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
workerData = aggregatedData || {};
|
|
||||||
workerData.workers = uniqueWorkers;
|
|
||||||
|
|
||||||
if (typeof BitcoinMinuteRefresh !== 'undefined' && BitcoinMinuteRefresh.notifyRefresh) {
|
|
||||||
BitcoinMinuteRefresh.notifyRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWorkerGrid();
|
|
||||||
updateSummaryStats();
|
|
||||||
updateMiniChart();
|
|
||||||
updateLastUpdated();
|
|
||||||
|
|
||||||
$('#retry-button').hide();
|
|
||||||
connectionRetryCount = 0;
|
|
||||||
console.log("Worker data updated successfully");
|
|
||||||
$('#worker-grid').removeClass('loading-fade');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error fetching worker data:", error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
hideLoader();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh worker data every 60 seconds
|
|
||||||
setInterval(function () {
|
|
||||||
console.log("Refreshing worker data at " + new Date().toLocaleTimeString());
|
|
||||||
fetchWorkerData();
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
// Update the worker grid with data
|
|
||||||
function updateWorkerGrid() {
|
|
||||||
console.log("Updating worker grid...");
|
|
||||||
|
|
||||||
if (!workerData || !workerData.workers) {
|
|
||||||
console.error("No worker data available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerGrid = $('#worker-grid');
|
|
||||||
workerGrid.empty();
|
|
||||||
|
|
||||||
const filteredWorkers = filterWorkersData(workerData.workers);
|
|
||||||
|
|
||||||
if (filteredWorkers.length === 0) {
|
|
||||||
workerGrid.html(`
|
|
||||||
<div class="text-center p-5">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<p>No workers match your filter criteria</p>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredWorkers.forEach(worker => {
|
|
||||||
const card = createWorkerCard(worker);
|
|
||||||
workerGrid.append(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create worker card element
|
|
||||||
function createWorkerCard(worker) {
|
|
||||||
const card = $('<div class="worker-card"></div>');
|
|
||||||
|
|
||||||
card.addClass(worker.status === 'online' ? 'worker-card-online' : 'worker-card-offline');
|
|
||||||
card.append(`<div class="worker-type">${worker.type}</div>`);
|
|
||||||
card.append(`<div class="worker-name">${worker.name}</div>`);
|
|
||||||
card.append(`<div class="status-badge ${worker.status === 'online' ? 'status-badge-online' : 'status-badge-offline'}">${worker.status.toUpperCase()}</div>`);
|
|
||||||
|
|
||||||
const maxHashrate = 125; // TH/s - adjust based on your fleet
|
|
||||||
const normalizedHashrate = normalizeHashrate(worker.hashrate_3hr, worker.hashrate_3hr_unit || 'th/s');
|
|
||||||
const hashratePercent = Math.min(100, (normalizedHashrate / maxHashrate) * 100);
|
|
||||||
const formattedHashrate = formatHashrateForDisplay(worker.hashrate_3hr, worker.hashrate_3hr_unit || 'th/s');
|
|
||||||
|
|
||||||
card.append(`
|
|
||||||
<div class="worker-stats-row">
|
|
||||||
<div class="worker-stats-label">Hashrate (3hr):</div>
|
|
||||||
<div class="white-glow">${formattedHashrate}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-bar-container">
|
|
||||||
<div class="stats-bar" style="width: ${hashratePercent}%"></div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
formattedLastShare = dateWithoutTZ.toLocaleString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
card.append(`
|
|
||||||
<div class="worker-stats">
|
|
||||||
<div class="worker-stats-row">
|
|
||||||
<div class="worker-stats-label">Last Share:</div>
|
|
||||||
<div class="blue-glow">${formattedLastShare}</div>
|
|
||||||
</div>
|
|
||||||
<div class="worker-stats-row">
|
|
||||||
<div class="worker-stats-label">Earnings:</div>
|
|
||||||
<div class="green-glow">${worker.earnings.toFixed(8)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter worker data based on current filter state
|
|
||||||
function filterWorkersData(workers) {
|
|
||||||
if (!workers) return [];
|
|
||||||
|
|
||||||
return workers.filter(worker => {
|
|
||||||
const workerName = (worker.name || '').toLowerCase();
|
|
||||||
const isOnline = worker.status === 'online';
|
|
||||||
const workerType = (worker.type || '').toLowerCase();
|
|
||||||
|
|
||||||
const matchesFilter = filterState.currentFilter === 'all' ||
|
|
||||||
(filterState.currentFilter === 'online' && isOnline) ||
|
|
||||||
(filterState.currentFilter === 'offline' && !isOnline) ||
|
|
||||||
(filterState.currentFilter === 'asic' && workerType === 'asic') ||
|
|
||||||
(filterState.currentFilter === 'bitaxe' && workerType === 'bitaxe');
|
|
||||||
|
|
||||||
const matchesSearch = filterState.searchTerm === '' || workerName.includes(filterState.searchTerm);
|
|
||||||
|
|
||||||
return matchesFilter && matchesSearch;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply filter to rendered worker cards
|
|
||||||
function filterWorkers() {
|
|
||||||
if (!workerData || !workerData.workers) return;
|
|
||||||
updateWorkerGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update summary stats with normalized hashrate display
|
|
||||||
function updateSummaryStats() {
|
|
||||||
if (!workerData) return;
|
|
||||||
|
|
||||||
$('#workers-count').text(workerData.workers_total || 0);
|
|
||||||
$('#workers-online').text(workerData.workers_online || 0);
|
|
||||||
$('#workers-offline').text(workerData.workers_offline || 0);
|
|
||||||
|
|
||||||
const onlinePercent = workerData.workers_total > 0 ? workerData.workers_online / workerData.workers_total : 0;
|
|
||||||
$('.worker-ring').css('--online-percent', onlinePercent);
|
|
||||||
|
|
||||||
const formattedHashrate = workerData.total_hashrate !== undefined ?
|
|
||||||
formatHashrateForDisplay(workerData.total_hashrate, workerData.hashrate_unit || 'TH/s') :
|
|
||||||
'0.0 TH/s';
|
|
||||||
$('#total-hashrate').text(formattedHashrate);
|
|
||||||
|
|
||||||
$('#total-earnings').text(`${(workerData.total_earnings || 0).toFixed(8)} BTC`);
|
|
||||||
$('#daily-sats').text(`${numberWithCommas(workerData.daily_sats || 0)} SATS`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize mini chart
|
|
||||||
function initializeMiniChart() {
|
|
||||||
console.log("Initializing mini chart...");
|
|
||||||
|
|
||||||
const ctx = document.getElementById('total-hashrate-chart');
|
|
||||||
if (!ctx) {
|
|
||||||
console.error("Mini chart canvas not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = Array(24).fill('').map((_, i) => i);
|
|
||||||
const data = Array(24).fill(0).map(() => Math.random() * 100 + 700);
|
|
||||||
|
|
||||||
miniChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: [{
|
|
||||||
data: data,
|
|
||||||
borderColor: '#1137F5',
|
|
||||||
backgroundColor: 'rgba(57, 255, 20, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
pointRadius: 0
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
x: { display: false },
|
|
||||||
y: {
|
|
||||||
display: false,
|
|
||||||
min: Math.min(...data) * 0.9,
|
|
||||||
max: Math.max(...data) * 1.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: { enabled: false }
|
|
||||||
},
|
|
||||||
animation: false,
|
|
||||||
elements: {
|
|
||||||
line: {
|
|
||||||
tension: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update mini chart with real data and normalization
|
|
||||||
function updateMiniChart() {
|
|
||||||
if (!miniChart || !workerData || !workerData.hashrate_history) {
|
|
||||||
console.log("Skipping mini chart update - missing data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyData = workerData.hashrate_history;
|
|
||||||
if (!historyData || historyData.length === 0) {
|
|
||||||
console.log("No hashrate history data available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = historyData.map(item => normalizeHashrate(parseFloat(item.value) || 0, item.unit || workerData.hashrate_unit || 'th/s'));
|
|
||||||
const labels = historyData.map(item => item.time);
|
|
||||||
|
|
||||||
miniChart.data.labels = labels;
|
|
||||||
miniChart.data.datasets[0].data = values;
|
|
||||||
|
|
||||||
const min = Math.min(...values.filter(v => v > 0)) || 0;
|
|
||||||
const max = Math.max(...values) || 1;
|
|
||||||
miniChart.options.scales.y.min = min * 0.9;
|
|
||||||
miniChart.options.scales.y.max = max * 1.1;
|
|
||||||
|
|
||||||
miniChart.update('none');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the last updated timestamp
|
|
||||||
function updateLastUpdated() {
|
|
||||||
if (!workerData || !workerData.timestamp) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timestamp = new Date(workerData.timestamp);
|
|
||||||
|
|
||||||
// Get the configured timezone with a fallback
|
|
||||||
const configuredTimezone = window.dashboardTimezone || 'America/Los_Angeles';
|
|
||||||
|
|
||||||
// Format with the configured timezone
|
|
||||||
const options = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
timeZone: configuredTimezone // Explicitly use the configured timezone
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format the timestamp and update the DOM
|
|
||||||
const formattedTime = timestamp.toLocaleString('en-US', options);
|
|
||||||
|
|
||||||
$("#lastUpdated").html("<strong>Last Updated:</strong> " +
|
|
||||||
formattedTime + "<span id='terminal-cursor'></span>");
|
|
||||||
|
|
||||||
console.log(`Last updated timestamp using timezone: ${configuredTimezone}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error formatting timestamp:", e);
|
|
||||||
// Fallback to basic timestamp if there's an error
|
|
||||||
$("#lastUpdated").html("<strong>Last Updated:</strong> " +
|
|
||||||
new Date().toLocaleString() + "<span id='terminal-cursor'></span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format numbers with commas
|
|
||||||
function numberWithCommas(x) {
|
|
||||||
if (x == null) return "N/A";
|
|
||||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
}
|
|
@ -1,178 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}BTC-OS MINING DASHBOARD {% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Common fonts -->
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
||||||
|
|
||||||
<!-- Bootstrap -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Common CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/css/common.css">
|
|
||||||
|
|
||||||
<!-- Custom CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
|
|
||||||
<!-- Theme JS (added to ensure consistent application of theme) -->
|
|
||||||
<script src="/static/js/theme.js"></script>
|
|
||||||
|
|
||||||
<!-- Page-specific CSS -->
|
|
||||||
{% block css %}{% endblock %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Execute this immediately to preload theme
|
|
||||||
(function () {
|
|
||||||
const useDeepSea = localStorage.getItem('useDeepSeaTheme') === 'true';
|
|
||||||
const themeClass = useDeepSea ? 'deepsea-theme' : 'bitcoin-theme';
|
|
||||||
|
|
||||||
// Apply theme class to html element
|
|
||||||
document.documentElement.classList.add(themeClass);
|
|
||||||
|
|
||||||
// Create and add loader
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Create loader element
|
|
||||||
const loader = document.createElement('div');
|
|
||||||
loader.id = 'theme-loader';
|
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.id = 'loader-icon';
|
|
||||||
icon.innerHTML = useDeepSea ? '🌊' : '₿';
|
|
||||||
|
|
||||||
const text = document.createElement('div');
|
|
||||||
text.id = 'loader-text';
|
|
||||||
text.textContent = 'Loading ' + (useDeepSea ? 'DeepSea' : 'Bitcoin') + ' Theme';
|
|
||||||
|
|
||||||
loader.appendChild(icon);
|
|
||||||
loader.appendChild(text);
|
|
||||||
document.body.appendChild(loader);
|
|
||||||
|
|
||||||
// Add fade-in effect for content once theme is loaded
|
|
||||||
setTimeout(function () {
|
|
||||||
document.body.style.visibility = 'visible';
|
|
||||||
|
|
||||||
// Fade out loader
|
|
||||||
loader.style.transition = 'opacity 0.5s ease';
|
|
||||||
loader.style.opacity = '0';
|
|
||||||
|
|
||||||
// Remove loader after fade
|
|
||||||
setTimeout(function () {
|
|
||||||
if (loader && loader.parentNode) {
|
|
||||||
loader.parentNode.removeChild(loader);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
// Add underwater effects for DeepSea theme
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Check if DeepSea theme is active
|
|
||||||
if (localStorage.getItem('useDeepSeaTheme') === 'true') {
|
|
||||||
// Create underwater light rays
|
|
||||||
const rays = document.createElement('div');
|
|
||||||
rays.className = 'underwater-rays';
|
|
||||||
document.body.appendChild(rays);
|
|
||||||
|
|
||||||
// Create digital noise
|
|
||||||
const noise = document.createElement('div');
|
|
||||||
noise.className = 'digital-noise';
|
|
||||||
document.body.appendChild(noise);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="container-fluid">
|
|
||||||
<!-- Connection status indicator -->
|
|
||||||
<div id="connectionStatus"></div>
|
|
||||||
|
|
||||||
<h1 class="text-center">
|
|
||||||
<a href="/" style="text-decoration:none; color:inherit;">
|
|
||||||
{% block header %}BTC-OS MINING DASHBOARD{% endblock %}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- Top right link -->
|
|
||||||
<a href="https://x.com/DJObleezy" id="topRightLink" target="_blank" rel="noopener noreferrer">MADE BY @DJO₿LEEZY</a>
|
|
||||||
|
|
||||||
<!-- Theme toggle button (new) -->
|
|
||||||
<button id="themeToggle" class="theme-toggle-btn">
|
|
||||||
<span>Toggle Theme</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{% block last_updated %}
|
|
||||||
<p class="text-center" id="lastUpdated" style="color: #f7931a; text-transform: uppercase;"><strong>LAST UPDATED:</strong> {{ current_time }}<span id="terminal-cursor"></span></p>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block navigation %}
|
|
||||||
<div class="navigation-links">
|
|
||||||
<a href="/dashboard" class="nav-link {% block dashboard_active %}{% endblock %}">DASHBOARD</a>
|
|
||||||
<a href="/workers" class="nav-link {% block workers_active %}{% endblock %}">WORKERS</a>
|
|
||||||
<a href="/blocks" class="nav-link {% block blocks_active %}{% endblock %}">BLOCKS</a>
|
|
||||||
<a href="/notifications" class="nav-link {% block notifications_active %}{% endblock %}">
|
|
||||||
NOTIFICATIONS
|
|
||||||
<span id="nav-unread-badge" class="nav-badge"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Main content area -->
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<!-- Hidden Congrats Message -->
|
|
||||||
{% block congrats_message %}
|
|
||||||
<div id="congratsMessage" style="display:none; position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; background: #f7931a; color: #000; padding: 10px; border-radius: 5px; box-shadow: 0 0 15px rgba(247, 147, 26, 0.7);"></div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="footer text-center">
|
|
||||||
<p>Not affiliated with <a href="https://www.Ocean.xyz">Ocean.xyz</a></p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- External JavaScript libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@1.1.0"></script>
|
|
||||||
|
|
||||||
<!-- Theme toggle initialization -->
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Initialize theme toggle button based on current theme
|
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
|
||||||
if (themeToggle) {
|
|
||||||
// Check current theme
|
|
||||||
const isDeepSea = localStorage.getItem('useDeepSeaTheme') === 'true';
|
|
||||||
|
|
||||||
// Update button style based on theme
|
|
||||||
if (isDeepSea) {
|
|
||||||
themeToggle.style.borderColor = '#0088cc';
|
|
||||||
themeToggle.style.color = '#0088cc';
|
|
||||||
} else {
|
|
||||||
themeToggle.style.borderColor = '#f2a900';
|
|
||||||
themeToggle.style.color = '#f2a900';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add click event listener
|
|
||||||
themeToggle.addEventListener('click', function () {
|
|
||||||
toggleTheme(); // This will now trigger a page refresh
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Page-specific JavaScript -->
|
|
||||||
{% block javascript %}{% endblock %}
|
|
||||||
|
|
||||||
<!-- Bitcoin Progress Bar -->
|
|
||||||
<script src="/static/js/BitcoinProgressBar.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,94 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}BLOCKS - BTC-OS MINING DASHBOARD {% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
<link rel="stylesheet" href="/static/css/blocks.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header %}BLOCKCHAIN MONITOR v 0.1{% endblock %}
|
|
||||||
|
|
||||||
{% block blocks_active %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<!-- Latest block stats -->
|
|
||||||
<div class="row mb-2 equal-height">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">LATEST BLOCK STATS</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="latest-block-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>BLOCK HEIGHT:</strong>
|
|
||||||
<span id="latest-height" class="metric-value white">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>TIME:</strong>
|
|
||||||
<span id="latest-time" class="metric-value blue">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>TRANSACTIONS:</strong>
|
|
||||||
<span id="latest-tx-count" class="metric-value white">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>SIZE:</strong>
|
|
||||||
<span id="latest-size" class="metric-value white">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>DIFFICULTY:</strong>
|
|
||||||
<span id="latest-difficulty" class="metric-value yellow">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>POOL:</strong>
|
|
||||||
<span id="latest-pool" class="metric-value green">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<strong>AVG FEE RATE:</strong>
|
|
||||||
<span id="latest-fee-rate" class="metric-value yellow" style="animation: pulse 1s infinite;">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Blocks grid -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">RECENT BLOCKS</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="blocks-container">
|
|
||||||
<div id="blocks-grid" class="blocks-grid">
|
|
||||||
<!-- Blocks will be generated here via JavaScript -->
|
|
||||||
<div class="loader">
|
|
||||||
<span class="loader-text">Connecting to mempool.guide API<span class="terminal-cursor"></span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Block details modal -->
|
|
||||||
<div id="block-modal" class="block-modal">
|
|
||||||
<div class="block-modal-content">
|
|
||||||
<div class="block-modal-header">
|
|
||||||
<span class="block-modal-title">BLOCK DETAILS</span>
|
|
||||||
<span class="block-modal-close">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="block-modal-body">
|
|
||||||
<div id="block-details">
|
|
||||||
<!-- Block details will be displayed here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<script src="/static/js/blocks.js"></script>
|
|
||||||
{% endblock %}
|
|
@ -1,722 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Ocean.xyz Pool Miner - Initializing...</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/boot.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
<!-- Add Theme JS -->
|
|
||||||
<script src="/static/js/theme.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
// Add underwater effects for DeepSea theme
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Check if DeepSea theme is active
|
|
||||||
if (localStorage.getItem('useDeepSeaTheme') === 'true') {
|
|
||||||
// Create underwater light rays
|
|
||||||
const rays = document.createElement('div');
|
|
||||||
rays.className = 'underwater-rays';
|
|
||||||
document.body.appendChild(rays);
|
|
||||||
|
|
||||||
// Create digital noise
|
|
||||||
const noise = document.createElement('div');
|
|
||||||
noise.className = 'digital-noise';
|
|
||||||
document.body.appendChild(noise);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<!-- Theme toggle button (new) -->
|
|
||||||
<button id="themeToggle" class="theme-toggle-btn">
|
|
||||||
<span>Toggle Theme</span>
|
|
||||||
</button>
|
|
||||||
<button id="skip-button">SKIP</button>
|
|
||||||
<div id="debug-info"></div>
|
|
||||||
<div id="loading-message">Loading mining data...</div>
|
|
||||||
<div id="bitcoin-logo">
|
|
||||||
██████╗ ████████╗ ██████╗ ██████╗ ███████╗
|
|
||||||
██╔══██╗╚══██╔══╝██╔════╝ ██╔═══██╗██╔════╝
|
|
||||||
██████╔╝ ██║ ██║ ██║ ██║███████╗
|
|
||||||
██╔══██╗ ██║ ██║ ██║ ██║╚════██║
|
|
||||||
██████╔╝ ██║ ╚██████╗ ╚██████╔╝███████║
|
|
||||||
╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
|
|
||||||
v.21
|
|
||||||
</div>
|
|
||||||
<div id="terminal">
|
|
||||||
<div id="terminal-content">
|
|
||||||
<span id="output"></span><span class="cursor"></span>
|
|
||||||
<span id="prompt-container">
|
|
||||||
<span id="prompt-text">
|
|
||||||
Initialize mining dashboard? [Y/N]:
|
|
||||||
<span class="prompt-cursor"></span>
|
|
||||||
<input type="text" id="user-input" maxlength="1" autocomplete="off" spellcheck="false" autofocus style="font-size: 16px; font-weight: bold;">
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration Form -->
|
|
||||||
<div id="config-form">
|
|
||||||
<div class="config-title">MINING CONFIGURATION</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="wallet-address">
|
|
||||||
Bitcoin Wallet Address
|
|
||||||
<span class="tooltip">
|
|
||||||
?
|
|
||||||
<span class="tooltip-text">Your Ocean.xyz pool mining address</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="wallet-address" placeholder="bc1..." value="">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="power-cost">
|
|
||||||
Power Cost ($/kWh)
|
|
||||||
<span class="tooltip">
|
|
||||||
?
|
|
||||||
<span class="tooltip-text">Your electricity cost per kilowatt-hour</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="power-cost" step="0.01" min="0" placeholder="0.12" value="">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="power-usage">
|
|
||||||
Power Usage (Watts)
|
|
||||||
<span class="tooltip">
|
|
||||||
?
|
|
||||||
<span class="tooltip-text">Total power consumption of your mining equipment</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="power-usage" step="50" min="0" placeholder="13450" value="">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="network-fee">
|
|
||||||
Network Fee (%)
|
|
||||||
<span class="tooltip">
|
|
||||||
?
|
|
||||||
<span class="tooltip-text">Additional fees beyond pool fee, like Firmware fees</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="network-fee" step="0.1" min="0" max="10" placeholder="0.0" value="">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timezone">
|
|
||||||
Timezone
|
|
||||||
<span class="tooltip">
|
|
||||||
?
|
|
||||||
<span class="tooltip-text">Your local timezone for displaying time information</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select id="timezone" class="form-control">
|
|
||||||
<optgroup label="Common Timezones">
|
|
||||||
<option value="America/Los_Angeles">Los Angeles (Pacific Time)</option>
|
|
||||||
<option value="America/Denver">Denver (Mountain Time)</option>
|
|
||||||
<option value="America/Chicago">Chicago (Central Time)</option>
|
|
||||||
<option value="America/New_York">New York (Eastern Time)</option>
|
|
||||||
<option value="Europe/London">London (GMT/BST)</option>
|
|
||||||
<option value="Europe/Paris">Paris (Central European Time)</option>
|
|
||||||
<option value="Asia/Tokyo">Tokyo (Japan Standard Time)</option>
|
|
||||||
<option value="Australia/Sydney">Sydney (Australian Eastern Time)</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Other Timezones" id="other-timezones">
|
|
||||||
<!-- Will be populated by JavaScript -->
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="form-message"></div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn-secondary" id="use-defaults">Use Defaults</button>
|
|
||||||
<button class="btn" id="save-config">Save & Continue</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Theme toggle initialization
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Initialize theme toggle button based on current theme
|
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
|
||||||
if (themeToggle) {
|
|
||||||
// Check current theme
|
|
||||||
const isDeepSea = localStorage.getItem('useDeepSeaTheme') === 'true';
|
|
||||||
|
|
||||||
// Update button style based on theme
|
|
||||||
if (isDeepSea) {
|
|
||||||
document.body.classList.add('deepsea-theme');
|
|
||||||
themeToggle.style.borderColor = '#0088cc';
|
|
||||||
themeToggle.style.color = '#0088cc';
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('deepsea-theme');
|
|
||||||
themeToggle.style.borderColor = '#f2a900';
|
|
||||||
themeToggle.style.color = '#f2a900';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add click event listener
|
|
||||||
themeToggle.addEventListener('click', function () {
|
|
||||||
toggleTheme(); // This will now trigger a page refresh
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update terminal colors based on theme (boot.html specific)
|
|
||||||
function updateTerminalColors() {
|
|
||||||
const isDeepSeaTheme = localStorage.getItem('useDeepSeaTheme') === 'true';
|
|
||||||
if (isDeepSeaTheme) {
|
|
||||||
document.body.classList.add('deepsea-theme');
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('deepsea-theme');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize terminal colors
|
|
||||||
updateTerminalColors();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a function to populate all available timezones
|
|
||||||
function populateTimezones() {
|
|
||||||
const otherTimezones = document.getElementById('other-timezones');
|
|
||||||
|
|
||||||
// Common timezone areas to include
|
|
||||||
const commonAreas = [
|
|
||||||
'Africa', 'America', 'Antarctica', 'Asia', 'Atlantic',
|
|
||||||
'Australia', 'Europe', 'Indian', 'Pacific'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fetch the list of available timezones
|
|
||||||
fetch('/api/available_timezones')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.timezones || !Array.isArray(data.timezones)) {
|
|
||||||
console.error('Invalid timezone data received');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort timezones and filter to include only common areas
|
|
||||||
const sortedTimezones = data.timezones
|
|
||||||
.filter(tz => commonAreas.some(area => tz.startsWith(area + '/')))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
// Add options for each timezone (excluding those already in common list)
|
|
||||||
const commonOptions = Array.from(document.querySelectorAll('#timezone optgroup:first-child option'))
|
|
||||||
.map(opt => opt.value);
|
|
||||||
|
|
||||||
sortedTimezones.forEach(tz => {
|
|
||||||
if (!commonOptions.includes(tz)) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = tz;
|
|
||||||
option.textContent = tz.replace('_', ' ');
|
|
||||||
otherTimezones.appendChild(option);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Error fetching timezones:', error));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call this when the page loads
|
|
||||||
document.addEventListener('DOMContentLoaded', populateTimezones);
|
|
||||||
|
|
||||||
// Load the current timezone from configuration
|
|
||||||
function loadTimezoneFromConfig() {
|
|
||||||
if (currentConfig && currentConfig.timezone) {
|
|
||||||
const timezoneSelect = document.getElementById('timezone');
|
|
||||||
|
|
||||||
// First, check if the option exists
|
|
||||||
let optionExists = false;
|
|
||||||
for (let i = 0; i < timezoneSelect.options.length; i++) {
|
|
||||||
if (timezoneSelect.options[i].value === currentConfig.timezone) {
|
|
||||||
timezoneSelect.selectedIndex = i;
|
|
||||||
optionExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the option doesn't exist yet (might be in the 'other' group being loaded)
|
|
||||||
// set a data attribute to select it when options are loaded
|
|
||||||
if (!optionExists) {
|
|
||||||
timezoneSelect.setAttribute('data-select-value', currentConfig.timezone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call this after loading config
|
|
||||||
loadConfig().then(() => {
|
|
||||||
loadTimezoneFromConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update saveConfig to include network fee
|
|
||||||
function saveConfig() {
|
|
||||||
const wallet = document.getElementById('wallet-address').value.trim();
|
|
||||||
const powerCost = parseFloat(document.getElementById('power-cost').value) || 0;
|
|
||||||
const powerUsage = parseFloat(document.getElementById('power-usage').value) || 0;
|
|
||||||
const timezone = document.getElementById('timezone').value;
|
|
||||||
const networkFee = parseFloat(document.getElementById('network-fee').value) || 0;
|
|
||||||
|
|
||||||
const updatedConfig = {
|
|
||||||
wallet: wallet || (currentConfig ? currentConfig.wallet : ""),
|
|
||||||
power_cost: powerCost,
|
|
||||||
power_usage: powerUsage,
|
|
||||||
timezone: timezone,
|
|
||||||
network_fee: networkFee
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetch('/api/config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedConfig)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to save configuration');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
function updateDebug(message) {
|
|
||||||
document.getElementById('debug-info').textContent = message;
|
|
||||||
console.log(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format numbers with commas
|
|
||||||
function numberWithCommas(x) {
|
|
||||||
if (x == null) return "N/A";
|
|
||||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global variables
|
|
||||||
let bootMessages = [];
|
|
||||||
let dashboardData = null;
|
|
||||||
let outputElement = document.getElementById('output');
|
|
||||||
const bitcoinLogo = document.getElementById('bitcoin-logo');
|
|
||||||
const skipButton = document.getElementById('skip-button');
|
|
||||||
const loadingMessage = document.getElementById('loading-message');
|
|
||||||
const promptContainer = document.getElementById('prompt-container');
|
|
||||||
const userInput = document.getElementById('user-input');
|
|
||||||
const configForm = document.getElementById('config-form');
|
|
||||||
let messageIndex = 0;
|
|
||||||
let timeoutId = null;
|
|
||||||
let waitingForUserInput = false;
|
|
||||||
let bootComplete = false;
|
|
||||||
let configLoaded = false;
|
|
||||||
let currentConfig = {
|
|
||||||
wallet: "yourwallethere",
|
|
||||||
power_cost: 0.0,
|
|
||||||
power_usage: 0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update loadConfig function to include network fee
|
|
||||||
function loadConfig() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
fetch('/api/config?nocache=' + new Date().getTime())
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load configuration: ' + response.statusText);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
console.log("Loaded configuration:", data);
|
|
||||||
currentConfig = data;
|
|
||||||
|
|
||||||
// Update form fields with latest values
|
|
||||||
document.getElementById('wallet-address').value = currentConfig.wallet || "";
|
|
||||||
document.getElementById('power-cost').value = currentConfig.power_cost || "";
|
|
||||||
document.getElementById('power-usage').value = currentConfig.power_usage || "";
|
|
||||||
document.getElementById('network-fee').value = currentConfig.network_fee || "";
|
|
||||||
configLoaded = true;
|
|
||||||
resolve(currentConfig);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error loading config:", err);
|
|
||||||
// Use default values if loading fails
|
|
||||||
currentConfig = {
|
|
||||||
wallet: "yourwallethere",
|
|
||||||
power_cost: 0.0,
|
|
||||||
power_usage: 0.0,
|
|
||||||
network_fee: 0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('wallet-address').value = currentConfig.wallet || "";
|
|
||||||
document.getElementById('power-cost').value = currentConfig.power_cost || "";
|
|
||||||
document.getElementById('power-usage').value = currentConfig.power_usage || "";
|
|
||||||
document.getElementById('network-fee').value = currentConfig.network_fee || "";
|
|
||||||
resolve(currentConfig);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update the save button event handler to reload the config after saving
|
|
||||||
document.getElementById('save-config').addEventListener('click', function () {
|
|
||||||
const messageElement = document.getElementById('form-message');
|
|
||||||
messageElement.style.display = 'block';
|
|
||||||
|
|
||||||
saveConfig()
|
|
||||||
.then(data => {
|
|
||||||
console.log("Configuration saved:", data);
|
|
||||||
messageElement.textContent = "Configuration saved successfully!";
|
|
||||||
messageElement.className = "message-success";
|
|
||||||
|
|
||||||
// Update currentConfig with the saved values
|
|
||||||
currentConfig = data.config || data;
|
|
||||||
|
|
||||||
setTimeout(redirectToDashboard, 1000);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error saving configuration:", error);
|
|
||||||
messageElement.textContent = "Error saving configuration. Please try again.";
|
|
||||||
messageElement.className = "message-error";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safety timeout: redirect after 120 seconds if boot not complete
|
|
||||||
window.addEventListener('load', function () {
|
|
||||||
setTimeout(function () {
|
|
||||||
if (!bootComplete && !waitingForUserInput) {
|
|
||||||
console.warn("Safety timeout reached - redirecting to dashboard");
|
|
||||||
redirectToDashboard();
|
|
||||||
}
|
|
||||||
}, 120000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configuration form event listeners
|
|
||||||
document.getElementById('save-config').addEventListener('click', function () {
|
|
||||||
const messageElement = document.getElementById('form-message');
|
|
||||||
messageElement.style.display = 'block';
|
|
||||||
|
|
||||||
saveConfig()
|
|
||||||
.then(data => {
|
|
||||||
console.log("Configuration saved:", data);
|
|
||||||
messageElement.textContent = "Configuration saved successfully!";
|
|
||||||
messageElement.className = "message-success";
|
|
||||||
setTimeout(redirectToDashboard, 1000);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error saving configuration:", error);
|
|
||||||
messageElement.textContent = "Error saving configuration. Please try again.";
|
|
||||||
messageElement.className = "message-error";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Use Defaults button handler
|
|
||||||
document.getElementById('use-defaults').addEventListener('click', function () {
|
|
||||||
// Set default values including network fee
|
|
||||||
document.getElementById('wallet-address').value = "35eS5Lsqw8NCjFJ8zhp9JaEmyvLDwg6XtS";
|
|
||||||
document.getElementById('power-cost').value = 0.0;
|
|
||||||
document.getElementById('power-usage').value = 0.0;
|
|
||||||
document.getElementById('network-fee').value = 0.0;
|
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
const btn = document.getElementById('use-defaults');
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = "Defaults Applied";
|
|
||||||
btn.style.backgroundColor = "#32CD32";
|
|
||||||
setTimeout(function () {
|
|
||||||
btn.textContent = originalText;
|
|
||||||
btn.style.backgroundColor = "";
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
function redirectToDashboard() {
|
|
||||||
updateDebug("Boot sequence complete, redirecting...");
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
window.location.href = baseUrl + "/dashboard";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade in Bitcoin logo
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
setTimeout(function () {
|
|
||||||
bitcoinLogo.style.visibility = 'visible';
|
|
||||||
setTimeout(function () {
|
|
||||||
bitcoinLogo.style.opacity = '1';
|
|
||||||
}, 100);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
loadConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Post-confirmation messages with retro typing effect
|
|
||||||
function showPostConfirmationMessages(response) {
|
|
||||||
try {
|
|
||||||
outputElement = document.getElementById('output');
|
|
||||||
if (!outputElement) {
|
|
||||||
setTimeout(redirectToDashboard, 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration form will be shown after boot sequence
|
|
||||||
if (response.toUpperCase() === 'Y') {
|
|
||||||
const yesMessages = [
|
|
||||||
{ text: "INITIALIZING DASHBOARD...\n", html: true, delay: 400 },
|
|
||||||
{ text: "Connecting to real-time data feeds...", speed: 20, delay: 300 },
|
|
||||||
{ text: "<span class='green'>CONNECTED</span>\n", html: true, delay: 400 },
|
|
||||||
{ text: "Loading blockchain validators...", speed: 15, delay: 300 },
|
|
||||||
{ text: "<span class='green'>COMPLETE</span>\n", html: true, delay: 400 },
|
|
||||||
{ text: "Starting TX fee calculation module...", speed: 15, delay: 400 },
|
|
||||||
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 400 },
|
|
||||||
{ text: "Verifying BTC-USD exchange rates...", speed: 15, delay: 200 },
|
|
||||||
{ text: "<span class='green'>CURRENT RATE CONFIRMED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "Calibrating hashrate telemetry...", speed: 15, delay: 200 },
|
|
||||||
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 200 },
|
|
||||||
{ text: "Loading mining configuration...", speed: 15, delay: 200 },
|
|
||||||
{ text: "<span class='green'>LOADED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "Preparing configuration interface...", speed: 15, delay: 800 },
|
|
||||||
{ text: "<span class='green'>READY</span>\n", html: true, delay: 500 },
|
|
||||||
{ text: "\nPlease configure your mining setup or use the default values:\n", html: true, delay: 800, showConfigForm: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
let msgIndex = 0;
|
|
||||||
function processNextMessage() {
|
|
||||||
if (msgIndex >= yesMessages.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentMessage = yesMessages[msgIndex];
|
|
||||||
|
|
||||||
if (currentMessage.showConfigForm) {
|
|
||||||
msgIndex++;
|
|
||||||
// Show configuration form
|
|
||||||
document.getElementById('config-form').style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentMessage.html) {
|
|
||||||
outputElement.innerHTML += currentMessage.text;
|
|
||||||
msgIndex++;
|
|
||||||
setTimeout(processNextMessage, currentMessage.delay || 300);
|
|
||||||
} else {
|
|
||||||
let charIndex = 0;
|
|
||||||
function typeCharacter() {
|
|
||||||
if (charIndex < currentMessage.text.length) {
|
|
||||||
outputElement.innerHTML += currentMessage.text.charAt(charIndex);
|
|
||||||
charIndex++;
|
|
||||||
setTimeout(typeCharacter, currentMessage.speed || 20);
|
|
||||||
} else {
|
|
||||||
msgIndex++;
|
|
||||||
setTimeout(processNextMessage, currentMessage.delay || 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
typeCharacter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(processNextMessage, 500);
|
|
||||||
} else {
|
|
||||||
// If user selects 'N', show configuration form directly without boot messages
|
|
||||||
outputElement.innerHTML += "N\n\nDASHBOARD INITIALIZATION ABORTED.\n";
|
|
||||||
outputElement.innerHTML += "\nPlease configure your mining setup:\n";
|
|
||||||
|
|
||||||
// Short pause and then show the configuration form
|
|
||||||
setTimeout(function () {
|
|
||||||
document.getElementById('config-form').style.display = 'block';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setTimeout(redirectToDashboard, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Y/N prompt input
|
|
||||||
userInput.addEventListener('keydown', function (e) {
|
|
||||||
if (waitingForUserInput && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const response = userInput.value.toUpperCase();
|
|
||||||
|
|
||||||
if (response === 'Y' || response === 'N') {
|
|
||||||
promptContainer.style.display = 'none';
|
|
||||||
waitingForUserInput = false;
|
|
||||||
outputElement.innerHTML += response + "\n";
|
|
||||||
userInput.value = '';
|
|
||||||
showPostConfirmationMessages(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show the prompt
|
|
||||||
function showUserPrompt() {
|
|
||||||
promptContainer.style.display = 'inline';
|
|
||||||
waitingForUserInput = true;
|
|
||||||
document.querySelector('.cursor').style.display = 'none';
|
|
||||||
userInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable truncation so all text is visible
|
|
||||||
function manageTerminalContent() { }
|
|
||||||
|
|
||||||
// Retro typing effect for boot messages
|
|
||||||
function typeBootMessages() {
|
|
||||||
try {
|
|
||||||
if (!outputElement) {
|
|
||||||
outputElement = document.getElementById('output');
|
|
||||||
if (!outputElement) {
|
|
||||||
skipButton.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageIndex >= bootMessages.length) { return; }
|
|
||||||
const currentMessage = bootMessages[messageIndex];
|
|
||||||
|
|
||||||
if (currentMessage.showPrompt) {
|
|
||||||
messageIndex++;
|
|
||||||
showUserPrompt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentMessage.html) {
|
|
||||||
outputElement.innerHTML += currentMessage.text;
|
|
||||||
messageIndex++;
|
|
||||||
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentMessage.typingIndex) { currentMessage.typingIndex = 0; }
|
|
||||||
if (currentMessage.typingIndex < currentMessage.text.length) {
|
|
||||||
outputElement.innerHTML += currentMessage.text.charAt(currentMessage.typingIndex);
|
|
||||||
currentMessage.typingIndex++;
|
|
||||||
timeoutId = setTimeout(typeBootMessages, currentMessage.speed || 15);
|
|
||||||
} else {
|
|
||||||
messageIndex++;
|
|
||||||
timeoutId = setTimeout(typeBootMessages, currentMessage.delay || 300);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
messageIndex++;
|
|
||||||
timeoutId = setTimeout(typeBootMessages, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip button: reveal configuration form only
|
|
||||||
skipButton.addEventListener('click', function () {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
// Optionally, clear boot messages or hide elements related to boot sequence
|
|
||||||
outputElement.innerHTML = "";
|
|
||||||
// Hide any loading or prompt messages
|
|
||||||
loadingMessage.style.display = 'none';
|
|
||||||
promptContainer.style.display = 'none';
|
|
||||||
// Show the configuration form
|
|
||||||
configForm.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the typing animation (hides loading message)
|
|
||||||
function startTyping() {
|
|
||||||
loadingMessage.style.display = 'none';
|
|
||||||
setTimeout(typeBootMessages, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback messages (used immediately)
|
|
||||||
function setupFallbackMessages() {
|
|
||||||
bootMessages = [
|
|
||||||
{ text: "BITCOIN OS - MINING CONTROL SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
|
|
||||||
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto\n", speed: 20, delay: 250 },
|
|
||||||
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
|
|
||||||
{ text: "INITIALIZING SYSTEM...\n", speed: 25, delay: 300 },
|
|
||||||
{ text: "HARDWARE: ", speed: 25, delay: 100 },
|
|
||||||
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "NETWORK: ", speed: 25, delay: 100 },
|
|
||||||
{ text: "<span class='green'>OK</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "BLOCKCHAIN: ", speed: 25, delay: 100 },
|
|
||||||
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "MINING RIG: ", speed: 25, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nSystem ready. ", speed: 25, delay: 400 },
|
|
||||||
{ showPrompt: true, delay: 0 }
|
|
||||||
];
|
|
||||||
startTyping();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize with fallback, then try live data
|
|
||||||
setupFallbackMessages();
|
|
||||||
updateDebug("Fetching dashboard data...");
|
|
||||||
fetch('/api/metrics')
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); }
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
dashboardData = data;
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
messageIndex = 0;
|
|
||||||
outputElement = document.getElementById('output');
|
|
||||||
outputElement.innerHTML = "";
|
|
||||||
bootMessages = [
|
|
||||||
{ text: "BITCOIN OS - MINING CONTROL SYSTEM - v21.000.000\n", speed: 25, delay: 300 },
|
|
||||||
{ text: "Copyright (c) 2009-2025 Satoshi Nakamoto & The Bitcoin Core Developers\n", speed: 20, delay: 250 },
|
|
||||||
{ text: "All rights reserved.\n\n", speed: 25, delay: 300 },
|
|
||||||
{ text: "INITIALIZING SHA-256 MINING SUBSYSTEMS...\n", speed: 25, delay: 400 },
|
|
||||||
{ text: "ASIC CLUSTER STATUS: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ONLINE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "CHIP TEMPERATURE: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>62°C - WITHIN OPTIMAL RANGE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "COOLING SYSTEMS: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>OPERATIONAL</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "POWER SUPPLY HEALTH: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>98.7% - NOMINAL</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nCONNECTING TO BITCOIN NETWORK...\n", speed: 20, delay: 400 },
|
|
||||||
{ text: "BLOCKCHAIN SYNC STATUS: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>SYNCHRONIZED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "DIFFICULTY ADJUSTMENT: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>CALCULATED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "MEMPOOL MONITORING: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nESTABLISHING POOL CONNECTION...\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "CONNECTING TO OCEAN.XYZ...\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "STRATUM PROTOCOL v2: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>INITIALIZED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "POOL HASHRATE: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>VERIFIED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "WORKER AUTHENTICATION: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>SUCCESSFUL</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nINITIALIZING METRICS COLLECTORS...\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "HASHRATE MONITOR: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "EARNINGS CALCULATOR: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>CALIBRATED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "POWER USAGE TRACKING: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ENABLED</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "PAYOUT THRESHOLD MONITOR: ", speed: 15, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ACTIVE</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nCURRENT NETWORK METRICS DETECTED\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "BTC PRICE: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>$" + numberWithCommas((data.btc_price || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "NETWORK DIFFICULTY: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='white'>" + numberWithCommas(Math.round(data.difficulty || 0)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "NETWORK HASHRATE: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='white'>" + (data.network_hashrate ? numberWithCommas(Math.round(data.network_hashrate)) : "N/A") + " EH/s</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "BLOCK HEIGHT: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='white'>" + numberWithCommas(data.block_number || "N/A") + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nMINER PERFORMANCE DATA\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "CURRENT HASHRATE: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>" + (data.hashrate_60sec || "N/A") + " " + (data.hashrate_60sec_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "24HR AVG HASHRATE: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>" + (data.hashrate_24hr || "N/A") + " " + (data.hashrate_24hr_unit || "TH/s") + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "ACTIVE WORKERS: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>" + (data.workers_hashing || "0") + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\nFINANCIAL CALCULATIONS\n", speed: 20, delay: 300 },
|
|
||||||
{ text: "DAILY MINING REVENUE: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='green'>$" + numberWithCommas((data.daily_revenue || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "DAILY POWER COST: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='red'>$" + numberWithCommas((data.daily_power_cost || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "DAILY PROFIT: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='green'>$" + numberWithCommas((data.daily_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "PROJECTED MONTHLY PROFIT: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='green'>$" + numberWithCommas((data.monthly_profit_usd || 0).toFixed(2)) + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "DAILY SATOSHI YIELD: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>" + numberWithCommas(data.daily_mined_sats || 0) + " sats</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "UNPAID EARNINGS: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='green'>" + (data.unpaid_earnings || "0") + " BTC</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "ESTIMATED TIME TO PAYOUT: ", speed: 20, delay: 100 },
|
|
||||||
{ text: "<span class='yellow'>" + (data.est_time_to_payout || "Unknown") + "</span>\n", html: true, delay: 300 },
|
|
||||||
{ text: "\n", speed: 25, delay: 100 },
|
|
||||||
{ text: "<span class='green'>ALL MINING PROCESSES OPERATIONAL</span>\n", html: true, delay: 400 },
|
|
||||||
{ text: "\nInitialize mining dashboard? ", speed: 25, delay: 400 },
|
|
||||||
{ showPrompt: true, delay: 0 }
|
|
||||||
];
|
|
||||||
startTyping();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
updateDebug(`Error fetching dashboard data: ${error.message}`);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,375 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}BTC-OS Mining Dashboard {% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block dashboard_active %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Graph Container -->
|
|
||||||
<div id="graphContainer" class="mb-2">
|
|
||||||
<canvas id="trendGraph" style="width: 100%; height: 100%; position: relative; z-index: 2;"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Miner Status and Payout Info -->
|
|
||||||
<div class="row mb-2 equal-height">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Miner Status</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>Status:</strong>
|
|
||||||
<span id="miner_status" class="metric-value">
|
|
||||||
{% if metrics and metrics.workers_hashing and metrics.workers_hashing > 0 %}
|
|
||||||
<span class="status-green">ONLINE</span> <span class="online-dot"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="status-red">OFFLINE</span> <span class="offline-dot"></span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Workers Hashing:</strong>
|
|
||||||
<span id="workers_hashing" class="metric-value">{{ metrics.workers_hashing or 0 }}</span>
|
|
||||||
<span id="indicator_workers_hashing"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Last Share:</strong>
|
|
||||||
<span id="last_share" class="metric-value">{{ metrics.total_last_share or "N/A" }}</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Blocks Found:</strong>
|
|
||||||
<span id="blocks_found" class="metric-value white">
|
|
||||||
{{ metrics.blocks_found if metrics and metrics.blocks_found else "0" }}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_blocks_found"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card" id="payoutMiscCard">
|
|
||||||
<div class="card-header">Payout Info</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>Unpaid Earnings:</strong>
|
|
||||||
<span id="unpaid_earnings" class="metric-value green">
|
|
||||||
{% if metrics and metrics.unpaid_earnings %}
|
|
||||||
{{ metrics.unpaid_earnings }} BTC
|
|
||||||
{% else %}
|
|
||||||
0 BTC
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_unpaid_earnings"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Last Block:</strong>
|
|
||||||
<span id="last_block_height" class="metric-value white">
|
|
||||||
{{ metrics.last_block_height|commafy if metrics and metrics.last_block_height else "N/A" }}
|
|
||||||
</span>
|
|
||||||
—
|
|
||||||
<span id="last_block_time" class="metric-value blue">
|
|
||||||
{{ metrics.last_block_time if metrics and metrics.last_block_time else "N/A" }}
|
|
||||||
</span>
|
|
||||||
—
|
|
||||||
<span class="green">
|
|
||||||
{% if metrics and metrics.last_block_earnings %}
|
|
||||||
+{{ metrics.last_block_earnings|int|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
+0 SATS
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_last_block"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Est. Time to Payout:</strong>
|
|
||||||
<span id="est_time_to_payout" class="metric-value yellow">
|
|
||||||
{{ metrics.est_time_to_payout if metrics and metrics.est_time_to_payout else "N/A" }}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_est_time_to_payout"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Pool Fees:</strong>
|
|
||||||
<span id="pool_fees_percentage" class="metric-value">
|
|
||||||
{% if metrics and metrics.pool_fees_percentage is defined and metrics.pool_fees_percentage is not none %}
|
|
||||||
{{ metrics.pool_fees_percentage }}%
|
|
||||||
{% if metrics.pool_fees_percentage is not none and metrics.pool_fees_percentage >= 0.9 and metrics.pool_fees_percentage <= 1.3 %}
|
|
||||||
<span class="fee-star">★</span> <span class="datum-label">DATUM</span> <span class="fee-star">★</span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_pool_fees_percentage"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pool Hashrates and Bitcoin Network Stats -->
|
|
||||||
<div class="row equal-height">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Pool Hashrates</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>Pool Hashrate:</strong>
|
|
||||||
<span id="pool_total_hashrate" class="metric-value white">
|
|
||||||
{% if metrics and metrics.pool_total_hashrate and metrics.pool_total_hashrate_unit %}
|
|
||||||
{{ metrics.pool_total_hashrate }} {{ metrics.pool_total_hashrate_unit[:-2]|upper ~ metrics.pool_total_hashrate_unit[-2:] }}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_pool_total_hashrate"></span>
|
|
||||||
</p>
|
|
||||||
<hr>
|
|
||||||
<p>
|
|
||||||
<strong>24hr Avg Hashrate:</strong>
|
|
||||||
<span id="hashrate_24hr" class="metric-value white">
|
|
||||||
{% if metrics and metrics.hashrate_24hr %}
|
|
||||||
{{ metrics.hashrate_24hr }}
|
|
||||||
{% if metrics.hashrate_24hr_unit %}
|
|
||||||
{{ metrics.hashrate_24hr_unit[:-2]|upper ~ metrics.hashrate_24hr_unit[-2:] }}
|
|
||||||
{% else %}
|
|
||||||
TH/s
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_hashrate_24hr"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>3hr Avg Hashrate:</strong>
|
|
||||||
<span id="hashrate_3hr" class="metric-value white">
|
|
||||||
{% if metrics and metrics.hashrate_3hr %}
|
|
||||||
{{ metrics.hashrate_3hr }}
|
|
||||||
{% if metrics.hashrate_3hr_unit %}
|
|
||||||
{{ metrics.hashrate_3hr_unit[:-2]|upper ~ metrics.hashrate_3hr_unit[-2:] }}
|
|
||||||
{% else %}
|
|
||||||
TH/s
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_hashrate_3hr"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>10min Avg Hashrate:</strong>
|
|
||||||
<span id="hashrate_10min" class="metric-value white">
|
|
||||||
{% if metrics and metrics.hashrate_10min %}
|
|
||||||
{{ metrics.hashrate_10min }}
|
|
||||||
{% if metrics.hashrate_10min_unit %}
|
|
||||||
{{ metrics.hashrate_10min_unit[:-2]|upper ~ metrics.hashrate_10min_unit[-2:] }}
|
|
||||||
{% else %}
|
|
||||||
TH/s
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_hashrate_10min"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>60sec Avg Hashrate:</strong>
|
|
||||||
<span id="hashrate_60sec" class="metric-value white">
|
|
||||||
{% if metrics and metrics.hashrate_60sec %}
|
|
||||||
{{ metrics.hashrate_60sec }}
|
|
||||||
{% if metrics.hashrate_60sec_unit %}
|
|
||||||
{{ metrics.hashrate_60sec_unit[:-2]|upper ~ metrics.hashrate_60sec_unit[-2:] }}
|
|
||||||
{% else %}
|
|
||||||
TH/s
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_hashrate_60sec"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Network Stats</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>BTC Price:</strong>
|
|
||||||
<span id="btc_price" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.btc_price %}
|
|
||||||
${{ "%.2f"|format(metrics.btc_price) }}
|
|
||||||
{% else %}
|
|
||||||
$0.00
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_btc_price"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Block Number:</strong>
|
|
||||||
<span id="block_number" class="metric-value white">
|
|
||||||
{% if metrics and metrics.block_number %}
|
|
||||||
{{ metrics.block_number|commafy }}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_block_number"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Network Hashrate:</strong>
|
|
||||||
<span id="network_hashrate" class="metric-value white">
|
|
||||||
{% if metrics and metrics.network_hashrate %}
|
|
||||||
{{ metrics.network_hashrate|round|commafy }} EH/s
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_network_hashrate"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Difficulty:</strong>
|
|
||||||
<span id="difficulty" class="metric-value white">
|
|
||||||
{% if metrics and metrics.difficulty %}
|
|
||||||
{{ metrics.difficulty|round|commafy }}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_difficulty"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Satoshi and USD Metrics -->
|
|
||||||
<div class="row equal-height">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">SATOSHI EARNINGS</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>Projected Daily (Net):</strong>
|
|
||||||
<span id="daily_mined_sats" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.daily_mined_sats %}
|
|
||||||
{{ metrics.daily_mined_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
0 sats
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_daily_mined_sats"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Projected Monthly (Net):</strong>
|
|
||||||
<span id="monthly_mined_sats" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.monthly_mined_sats %}
|
|
||||||
{{ metrics.monthly_mined_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
0 sats
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_monthly_mined_sats"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Est. Earnings/Day:</strong>
|
|
||||||
<span id="estimated_earnings_per_day_sats" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.estimated_earnings_per_day_sats %}
|
|
||||||
{{ metrics.estimated_earnings_per_day_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
0 sats
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_estimated_earnings_per_day_sats"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Est. Earnings/Block:</strong>
|
|
||||||
<span id="estimated_earnings_next_block_sats" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.estimated_earnings_next_block_sats %}
|
|
||||||
{{ metrics.estimated_earnings_next_block_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
0 sats
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_estimated_earnings_next_block_sats"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Est. Rewards in Window:</strong>
|
|
||||||
<span id="estimated_rewards_in_window_sats" class="metric-value yellow">
|
|
||||||
{% if metrics and metrics.estimated_rewards_in_window_sats %}
|
|
||||||
{{ metrics.estimated_rewards_in_window_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
0 sats
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_estimated_rewards_in_window_sats"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">USD EARNINGS</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
<strong>Daily Revenue:</strong>
|
|
||||||
<span id="daily_revenue" class="metric-value green">
|
|
||||||
{% if metrics and metrics.daily_revenue is defined and metrics.daily_revenue is not none %}
|
|
||||||
${{ "%.2f"|format(metrics.daily_revenue) }}
|
|
||||||
{% else %}
|
|
||||||
$0.00
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_daily_revenue"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Daily Power Cost:</strong>
|
|
||||||
<span id="daily_power_cost" class="metric-value red">
|
|
||||||
{% if metrics and metrics.daily_power_cost is defined and metrics.daily_power_cost is not none %}
|
|
||||||
${{ "%.2f"|format(metrics.daily_power_cost) }}
|
|
||||||
{% else %}
|
|
||||||
$0.00
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_daily_power_cost"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Daily Profit (USD):</strong>
|
|
||||||
<span id="daily_profit_usd" class="metric-value green">
|
|
||||||
{% if metrics and metrics.daily_profit_usd is defined and metrics.daily_profit_usd is not none %}
|
|
||||||
${{ "%.2f"|format(metrics.daily_profit_usd) }}
|
|
||||||
{% else %}
|
|
||||||
$0.00
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_daily_profit_usd"></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Monthly Profit (USD):</strong>
|
|
||||||
<span id="monthly_profit_usd" class="metric-value green">
|
|
||||||
{% if metrics and metrics.monthly_profit_usd is defined and metrics.monthly_profit_usd is not none %}
|
|
||||||
${{ "%.2f"|format(metrics.monthly_profit_usd) }}
|
|
||||||
{% else %}
|
|
||||||
$0.00
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span id="indicator_monthly_profit_usd"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<!-- External JavaScript file with our application logic -->
|
|
||||||
<script src="/static/js/main.js"></script>
|
|
||||||
{% endblock %}
|
|
@ -1,22 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Error - Mining Dashboard</title>
|
|
||||||
<!-- Include both Orbitron and VT323 fonts -->
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/error.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="error-container">
|
|
||||||
<h1>ERROR!</h1>
|
|
||||||
<div class="error-code">CODE: SYS_EXCEPTION_0x69420</div>
|
|
||||||
<p>{{ message }}<span class="terminal-cursor"></span></p>
|
|
||||||
<a href="/dashboard" class="btn btn-primary">RETURN TO DASHBOARD</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,95 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}NOTIFICATIONS - BTC-OS MINING DASHBOARD {% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
<link rel="stylesheet" href="/static/css/notifications.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header %}NOTIFICATION CENTER v 0.1{% endblock %}
|
|
||||||
|
|
||||||
{% block notifications_active %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Notification Controls -->
|
|
||||||
<div class="row mb-2">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">NOTIFICATION CONTROLS</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="notification-controls">
|
|
||||||
<div class="filter-buttons">
|
|
||||||
<button class="filter-button active" data-filter="all">All</button>
|
|
||||||
<button class="filter-button" data-filter="hashrate">Hashrate</button>
|
|
||||||
<button class="filter-button" data-filter="block">Blocks</button>
|
|
||||||
<button class="filter-button" data-filter="worker">Workers</button>
|
|
||||||
<button class="filter-button" data-filter="earnings">Earnings</button>
|
|
||||||
<button class="filter-button" data-filter="system">System</button>
|
|
||||||
</div>
|
|
||||||
<div class="notification-actions">
|
|
||||||
<button id="mark-all-read" class="action-button">Mark All as Read</button>
|
|
||||||
<button id="clear-read" class="action-button">Clear Read Notifications</button>
|
|
||||||
<button id="clear-all" class="action-button danger">Clear All</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications List -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span>NOTIFICATIONS</span>
|
|
||||||
<span id="unread-badge" class="unread-badge">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="notifications-container">
|
|
||||||
<!-- Notifications will be populated here by JavaScript -->
|
|
||||||
<div class="loading-message">Loading notifications<span class="terminal-cursor"></span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="pagination-controls">
|
|
||||||
<button id="load-more" class="load-more-button">LOAD MORE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State Template (hidden) -->
|
|
||||||
<div id="empty-template" style="display:none;">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-bell-slash"></i>
|
|
||||||
<p>No notifications to display</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notification Template (hidden) -->
|
|
||||||
<div id="notification-template" style="display:none;">
|
|
||||||
<div class="notification-item" data-id="">
|
|
||||||
<div class="notification-icon">
|
|
||||||
<i class="fas"></i>
|
|
||||||
</div>
|
|
||||||
<div class="notification-content">
|
|
||||||
<div class="notification-message"></div>
|
|
||||||
<div class="notification-meta">
|
|
||||||
<span class="notification-time"></span>
|
|
||||||
<span class="notification-category"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notification-actions">
|
|
||||||
<button class="mark-read-button"><i class="fas fa-check"></i></button>
|
|
||||||
<button class="delete-button"><i class="fas fa-trash"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<script src="/static/js/notifications.js"></script>
|
|
||||||
{% endblock %}
|
|
@ -1,99 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}WORKERS - BTC-OS MINING DASHBOARD {% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
<link rel="stylesheet" href="/static/css/workers.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/theme-toggle.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header %}WORKERS OVERVIEW{% endblock %}
|
|
||||||
|
|
||||||
{% block workers_active %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Summary statistics -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">MINER SUMMARY</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="summary-stats">
|
|
||||||
<div class="summary-stat">
|
|
||||||
<div class="worker-ring" style="--online-percent: {{ workers_online / workers_total if workers_total > 0 else 0 }}">
|
|
||||||
<div class="worker-ring-inner">
|
|
||||||
<span id="workers-count">{{ workers_total }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-stat-label">WORKERS</div>
|
|
||||||
<div>
|
|
||||||
<span class="green-glow" id="workers-online">{{ workers_online }}</span> /
|
|
||||||
<span class="red-glow" id="workers-offline">{{ workers_offline }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-stat">
|
|
||||||
<div class="summary-stat-value white-glow" id="total-hashrate">
|
|
||||||
{% if total_hashrate is defined %}
|
|
||||||
{{ "%.1f"|format(total_hashrate) }} {{ hashrate_unit }}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="summary-stat-label">TOTAL HASHRATE</div>
|
|
||||||
<div class="mini-chart">
|
|
||||||
<canvas id="total-hashrate-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-stat">
|
|
||||||
<div class="summary-stat-value green-glow" id="total-earnings">
|
|
||||||
{% if total_earnings is defined %}
|
|
||||||
{{ "%.8f"|format(total_earnings) }} BTC
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="summary-stat-label">UNPAID EARNINGS</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-stat">
|
|
||||||
<div class="summary-stat-value yellow-glow" id="daily-sats">
|
|
||||||
{% if daily_sats is defined %}
|
|
||||||
{{ daily_sats|commafy }} SATS
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="summary-stat-label">DAILY SATS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls bar -->
|
|
||||||
<div class="controls-bar">
|
|
||||||
<input type="text" class="search-box" id="worker-search" placeholder="Search workers...">
|
|
||||||
<div class="filter-buttons">
|
|
||||||
<button class="filter-button active" data-filter="all">ALL WORKERS</button>
|
|
||||||
<button class="filter-button" data-filter="online">ONLINE</button>
|
|
||||||
<button class="filter-button" data-filter="offline">OFFLINE</button>
|
|
||||||
<button class="filter-button" data-filter="asic">ASIC</button>
|
|
||||||
<button class="filter-button" data-filter="bitaxe">BITAXE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Workers grid -->
|
|
||||||
<div class="worker-grid" id="worker-grid">
|
|
||||||
<!-- Worker cards will be generated here via JavaScript -->
|
|
||||||
<div id="loader" class="text-center p-5" style="display:none;">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading worker data...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<script src="/static/js/workers.js"></script>
|
|
||||||
{% endblock %}
|
|
@ -1,792 +0,0 @@
|
|||||||
"""
|
|
||||||
Worker service module for managing workers data.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from config import get_timezone
|
|
||||||
|
|
||||||
class WorkerService:
|
|
||||||
"""Service for retrieving and managing worker data."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the worker service."""
|
|
||||||
self.worker_data_cache = None
|
|
||||||
self.last_worker_data_update = None
|
|
||||||
self.WORKER_DATA_CACHE_TIMEOUT = 60 # Cache worker data for 60 seconds
|
|
||||||
self.dashboard_service = None # Will be set by App.py during initialization
|
|
||||||
self.sats_per_btc = 100_000_000 # Constant for conversion
|
|
||||||
|
|
||||||
def set_dashboard_service(self, dashboard_service):
|
|
||||||
"""
|
|
||||||
Set the dashboard service instance - to be called from App.py
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dashboard_service (MiningDashboardService): The initialized dashboard service
|
|
||||||
"""
|
|
||||||
self.dashboard_service = dashboard_service
|
|
||||||
# Immediately access the wallet from dashboard_service when it's set
|
|
||||||
if hasattr(dashboard_service, 'wallet'):
|
|
||||||
self.wallet = dashboard_service.wallet
|
|
||||||
logging.info(f"Worker service updated with new wallet: {self.wallet}")
|
|
||||||
logging.info("Dashboard service connected to worker service")
|
|
||||||
|
|
||||||
def generate_default_workers_data(self):
|
|
||||||
"""
|
|
||||||
Generate default worker data when no metrics are available.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Default worker data structure
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"workers": [],
|
|
||||||
"workers_total": 0,
|
|
||||||
"workers_online": 0,
|
|
||||||
"workers_offline": 0,
|
|
||||||
"total_hashrate": 0.0,
|
|
||||||
"hashrate_unit": "TH/s",
|
|
||||||
"total_earnings": 0.0,
|
|
||||||
"daily_sats": 0,
|
|
||||||
"hashrate_history": [],
|
|
||||||
"timestamp": datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_workers_data(self, cached_metrics, force_refresh=False):
|
|
||||||
"""
|
|
||||||
Get worker data with caching for better performance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cached_metrics (dict): Cached metrics from the dashboard
|
|
||||||
force_refresh (bool): Whether to force a refresh of cached data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Worker data
|
|
||||||
"""
|
|
||||||
current_time = datetime.now().timestamp()
|
|
||||||
|
|
||||||
# Return cached data if it's still fresh and not forced to refresh
|
|
||||||
if not force_refresh and self.worker_data_cache and self.last_worker_data_update and \
|
|
||||||
(current_time - self.last_worker_data_update) < self.WORKER_DATA_CACHE_TIMEOUT:
|
|
||||||
# Even when using cached data, sync worker count with main dashboard
|
|
||||||
if cached_metrics and cached_metrics.get("workers_hashing") is not None:
|
|
||||||
self.sync_worker_counts_with_dashboard(self.worker_data_cache, cached_metrics)
|
|
||||||
|
|
||||||
logging.info("Using cached worker data")
|
|
||||||
return self.worker_data_cache
|
|
||||||
|
|
||||||
try:
|
|
||||||
# First try to get actual worker data from the dashboard service
|
|
||||||
if self.dashboard_service:
|
|
||||||
logging.info("Attempting to fetch real worker data from Ocean.xyz")
|
|
||||||
real_worker_data = self.dashboard_service.get_worker_data()
|
|
||||||
|
|
||||||
if real_worker_data and real_worker_data.get('workers') and len(real_worker_data['workers']) > 0:
|
|
||||||
# Validate that worker names are not just "Online" or "Offline"
|
|
||||||
valid_names = False
|
|
||||||
for worker in real_worker_data['workers']:
|
|
||||||
name = worker.get('name', '').lower()
|
|
||||||
if name and name not in ['online', 'offline', 'total', 'worker', 'status']:
|
|
||||||
valid_names = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if valid_names:
|
|
||||||
logging.info(f"Successfully retrieved {len(real_worker_data['workers'])} real workers from Ocean.xyz")
|
|
||||||
|
|
||||||
# Add hashrate history if available in cached metrics
|
|
||||||
if cached_metrics and cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
|
|
||||||
real_worker_data["hashrate_history"] = cached_metrics["arrow_history"]["hashrate_3hr"]
|
|
||||||
|
|
||||||
# Sync with dashboard metrics to ensure consistency
|
|
||||||
if cached_metrics:
|
|
||||||
self.sync_worker_counts_with_dashboard(real_worker_data, cached_metrics)
|
|
||||||
|
|
||||||
# Update cache
|
|
||||||
self.worker_data_cache = real_worker_data
|
|
||||||
self.last_worker_data_update = current_time
|
|
||||||
|
|
||||||
return real_worker_data
|
|
||||||
else:
|
|
||||||
logging.warning("Real worker data had invalid names (like 'online'/'offline'), falling back to simulated data")
|
|
||||||
else:
|
|
||||||
logging.warning("Real worker data fetch returned no workers, falling back to simulated data")
|
|
||||||
else:
|
|
||||||
logging.warning("Dashboard service not available, cannot fetch real worker data")
|
|
||||||
|
|
||||||
# Fallback to simulated data if real data fetch fails or returns no workers
|
|
||||||
logging.info("Generating fallback simulated worker data")
|
|
||||||
worker_data = self.generate_fallback_data(cached_metrics)
|
|
||||||
|
|
||||||
# Add hashrate history if available in cached metrics
|
|
||||||
if cached_metrics and cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
|
|
||||||
worker_data["hashrate_history"] = cached_metrics["arrow_history"]["hashrate_3hr"]
|
|
||||||
|
|
||||||
# Ensure worker counts match dashboard metrics
|
|
||||||
if cached_metrics:
|
|
||||||
self.sync_worker_counts_with_dashboard(worker_data, cached_metrics)
|
|
||||||
|
|
||||||
# Update cache
|
|
||||||
self.worker_data_cache = worker_data
|
|
||||||
self.last_worker_data_update = current_time
|
|
||||||
|
|
||||||
logging.info(f"Successfully generated fallback worker data: {worker_data['workers_total']} workers")
|
|
||||||
return worker_data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error getting worker data: {e}")
|
|
||||||
fallback_data = self.generate_fallback_data(cached_metrics)
|
|
||||||
|
|
||||||
# Even on error, try to sync with dashboard metrics
|
|
||||||
if cached_metrics:
|
|
||||||
self.sync_worker_counts_with_dashboard(fallback_data, cached_metrics)
|
|
||||||
|
|
||||||
return fallback_data
|
|
||||||
|
|
||||||
def sync_worker_counts_with_dashboard(self, worker_data, dashboard_metrics):
|
|
||||||
"""
|
|
||||||
Synchronize worker counts and other metrics between worker data and dashboard metrics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
worker_data (dict): Worker data to be updated
|
|
||||||
dashboard_metrics (dict): Dashboard metrics with worker count and other data
|
|
||||||
"""
|
|
||||||
if not worker_data or not dashboard_metrics:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sync worker count
|
|
||||||
dashboard_worker_count = dashboard_metrics.get("workers_hashing")
|
|
||||||
|
|
||||||
# Only proceed if dashboard has valid worker count
|
|
||||||
if dashboard_worker_count is not None:
|
|
||||||
current_worker_count = worker_data.get("workers_total", 0)
|
|
||||||
|
|
||||||
# If counts already match, no need to sync workers count
|
|
||||||
if current_worker_count != dashboard_worker_count:
|
|
||||||
logging.info(f"Syncing worker count: worker page({current_worker_count}) → dashboard({dashboard_worker_count})")
|
|
||||||
|
|
||||||
# Update the total count
|
|
||||||
worker_data["workers_total"] = dashboard_worker_count
|
|
||||||
|
|
||||||
# Adjust online/offline counts proportionally
|
|
||||||
current_online = worker_data.get("workers_online", 0)
|
|
||||||
current_total = max(1, current_worker_count) # Avoid division by zero
|
|
||||||
|
|
||||||
# Calculate ratio of online workers
|
|
||||||
online_ratio = current_online / current_total
|
|
||||||
|
|
||||||
# Recalculate online and offline counts
|
|
||||||
new_online_count = round(dashboard_worker_count * online_ratio)
|
|
||||||
new_offline_count = dashboard_worker_count - new_online_count
|
|
||||||
|
|
||||||
# Update the counts
|
|
||||||
worker_data["workers_online"] = new_online_count
|
|
||||||
worker_data["workers_offline"] = new_offline_count
|
|
||||||
|
|
||||||
logging.info(f"Updated worker counts - Total: {dashboard_worker_count}, Online: {new_online_count}, Offline: {new_offline_count}")
|
|
||||||
|
|
||||||
# If we have worker instances, try to adjust them as well
|
|
||||||
if "workers" in worker_data and isinstance(worker_data["workers"], list):
|
|
||||||
self.adjust_worker_instances(worker_data, dashboard_worker_count)
|
|
||||||
|
|
||||||
# Sync daily sats - critical for fixing the daily sats discrepancy
|
|
||||||
if dashboard_metrics.get("daily_mined_sats") is not None:
|
|
||||||
daily_sats_value = dashboard_metrics.get("daily_mined_sats")
|
|
||||||
if daily_sats_value != worker_data.get("daily_sats"):
|
|
||||||
worker_data["daily_sats"] = daily_sats_value
|
|
||||||
logging.info(f"Synced daily sats: {worker_data['daily_sats']}")
|
|
||||||
|
|
||||||
# Sync other important metrics
|
|
||||||
if dashboard_metrics.get("total_hashrate") is not None:
|
|
||||||
worker_data["total_hashrate"] = dashboard_metrics.get("total_hashrate")
|
|
||||||
|
|
||||||
if dashboard_metrics.get("unpaid_earnings") is not None:
|
|
||||||
# Attempt to convert string to float if needed
|
|
||||||
unpaid_value = dashboard_metrics.get("unpaid_earnings")
|
|
||||||
if isinstance(unpaid_value, str):
|
|
||||||
try:
|
|
||||||
unpaid_value = float(unpaid_value.split()[0].replace(',', ''))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
worker_data["total_earnings"] = unpaid_value
|
|
||||||
|
|
||||||
def adjust_worker_instances(self, worker_data, target_count):
|
|
||||||
"""
|
|
||||||
Adjust the number of worker instances to match the target count.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
worker_data (dict): Worker data containing worker instances
|
|
||||||
target_count (int): Target number of worker instances
|
|
||||||
"""
|
|
||||||
current_workers = worker_data.get("workers", [])
|
|
||||||
current_count = len(current_workers)
|
|
||||||
|
|
||||||
if current_count == target_count:
|
|
||||||
return
|
|
||||||
|
|
||||||
if current_count < target_count:
|
|
||||||
# Need to add more workers
|
|
||||||
workers_to_add = target_count - current_count
|
|
||||||
|
|
||||||
# Get existing online/offline worker counts
|
|
||||||
online_workers = [w for w in current_workers if w["status"] == "online"]
|
|
||||||
offline_workers = [w for w in current_workers if w["status"] == "offline"]
|
|
||||||
|
|
||||||
# Use the same online/offline ratio for new workers
|
|
||||||
online_ratio = len(online_workers) / max(1, current_count)
|
|
||||||
new_online = round(workers_to_add * online_ratio)
|
|
||||||
new_offline = workers_to_add - new_online
|
|
||||||
|
|
||||||
# Copy and adjust existing workers to create new ones
|
|
||||||
if online_workers and new_online > 0:
|
|
||||||
for i in range(new_online):
|
|
||||||
# Pick a random online worker as template
|
|
||||||
template = random.choice(online_workers).copy()
|
|
||||||
# Give it a new name to avoid duplicates
|
|
||||||
template["name"] = f"{template['name']}_{current_count + i + 1}"
|
|
||||||
current_workers.append(template)
|
|
||||||
|
|
||||||
if offline_workers and new_offline > 0:
|
|
||||||
for i in range(new_offline):
|
|
||||||
# Pick a random offline worker as template
|
|
||||||
template = random.choice(offline_workers).copy()
|
|
||||||
# Give it a new name to avoid duplicates
|
|
||||||
template["name"] = f"{template['name']}_{current_count + new_online + i + 1}"
|
|
||||||
current_workers.append(template)
|
|
||||||
|
|
||||||
# If no existing workers of either type, create new ones from scratch
|
|
||||||
if not online_workers and new_online > 0:
|
|
||||||
for i in range(new_online):
|
|
||||||
worker = self.create_default_worker(f"Miner_{current_count + i + 1}", "online")
|
|
||||||
current_workers.append(worker)
|
|
||||||
|
|
||||||
if not offline_workers and new_offline > 0:
|
|
||||||
for i in range(new_offline):
|
|
||||||
worker = self.create_default_worker(f"Miner_{current_count + new_online + i + 1}", "offline")
|
|
||||||
current_workers.append(worker)
|
|
||||||
|
|
||||||
elif current_count > target_count:
|
|
||||||
# Need to remove some workers
|
|
||||||
workers_to_remove = current_count - target_count
|
|
||||||
|
|
||||||
# Remove workers from the end of the list to preserve earlier ones
|
|
||||||
worker_data["workers"] = current_workers[:target_count]
|
|
||||||
|
|
||||||
# Update the worker data
|
|
||||||
worker_data["workers"] = current_workers
|
|
||||||
|
|
||||||
def create_default_worker(self, name, status):
|
|
||||||
"""
|
|
||||||
Create a default worker with given name and status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Worker name
|
|
||||||
status (str): Worker status ('online' or 'offline')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Default worker data
|
|
||||||
"""
|
|
||||||
is_online = status == "online"
|
|
||||||
current_time = datetime.now(ZoneInfo(get_timezone()))
|
|
||||||
|
|
||||||
# Generate some reasonable hashrate and other values
|
|
||||||
hashrate = round(random.uniform(50, 100), 2) if is_online else 0
|
|
||||||
last_share = current_time.strftime("%Y-%m-%d %H:%M") if is_online else (
|
|
||||||
(current_time - timedelta(hours=random.uniform(1, 24))).strftime("%Y-%m-%d %H:%M")
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"status": status,
|
|
||||||
"type": "ASIC",
|
|
||||||
"model": "Default Miner",
|
|
||||||
"hashrate_60sec": hashrate if is_online else 0,
|
|
||||||
"hashrate_60sec_unit": "TH/s",
|
|
||||||
"hashrate_3hr": hashrate if is_online else round(random.uniform(30, 80), 2),
|
|
||||||
"hashrate_3hr_unit": "TH/s",
|
|
||||||
"efficiency": round(random.uniform(80, 95), 1) if is_online else 0,
|
|
||||||
"last_share": last_share,
|
|
||||||
"earnings": round(random.uniform(0.0001, 0.001), 8),
|
|
||||||
"power_consumption": round(random.uniform(2000, 3500)) if is_online else 0,
|
|
||||||
"temperature": round(random.uniform(55, 75)) if is_online else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_fallback_data(self, cached_metrics):
|
|
||||||
"""
|
|
||||||
Generate fallback worker data from cached metrics when real data can't be fetched.
|
|
||||||
Try to preserve real worker names if available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cached_metrics (dict): Cached metrics from the dashboard
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Generated worker data
|
|
||||||
"""
|
|
||||||
# If metrics aren't available yet, return default data
|
|
||||||
if not cached_metrics:
|
|
||||||
logging.warning("No cached metrics available for worker fallback data")
|
|
||||||
return self.generate_default_workers_data()
|
|
||||||
|
|
||||||
# Check if we have workers_hashing information
|
|
||||||
workers_count = cached_metrics.get("workers_hashing")
|
|
||||||
|
|
||||||
# Handle None value for workers_count
|
|
||||||
if workers_count is None:
|
|
||||||
logging.warning("No workers_hashing value in cached metrics, defaulting to 1 worker")
|
|
||||||
workers_count = 1
|
|
||||||
# Force at least 1 worker if the count is 0
|
|
||||||
elif workers_count <= 0:
|
|
||||||
logging.warning("No workers reported in metrics, forcing 1 worker")
|
|
||||||
workers_count = 1
|
|
||||||
|
|
||||||
# Get hashrate from cached metrics
|
|
||||||
original_hashrate_3hr = float(cached_metrics.get("hashrate_3hr", 0) or 0)
|
|
||||||
hashrate_unit = cached_metrics.get("hashrate_3hr_unit", "TH/s")
|
|
||||||
|
|
||||||
# If hashrate is 0, set a minimum value to avoid empty display
|
|
||||||
if original_hashrate_3hr <= 0:
|
|
||||||
original_hashrate_3hr = 50.0
|
|
||||||
logging.warning(f"Hashrate was 0, setting minimum value of {original_hashrate_3hr} {hashrate_unit}")
|
|
||||||
|
|
||||||
# Check if we have any previously cached real worker names
|
|
||||||
real_worker_names = []
|
|
||||||
if self.worker_data_cache and self.worker_data_cache.get('workers'):
|
|
||||||
for worker in self.worker_data_cache['workers']:
|
|
||||||
name = worker.get('name', '')
|
|
||||||
# Only use names that don't look like status indicators
|
|
||||||
if name and name.lower() not in ['online', 'offline', 'total']:
|
|
||||||
real_worker_names.append(name)
|
|
||||||
|
|
||||||
# Generate worker data
|
|
||||||
workers_data = []
|
|
||||||
|
|
||||||
# If we have real worker names, use them
|
|
||||||
if real_worker_names:
|
|
||||||
logging.info(f"Using {len(real_worker_names)} real worker names from cache")
|
|
||||||
workers_data = self.generate_simulated_workers(
|
|
||||||
workers_count,
|
|
||||||
original_hashrate_3hr,
|
|
||||||
hashrate_unit,
|
|
||||||
real_worker_names=real_worker_names
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Otherwise use sequential names
|
|
||||||
logging.info("No real worker names available, using sequential names")
|
|
||||||
workers_data = self.generate_sequential_workers(
|
|
||||||
workers_count,
|
|
||||||
original_hashrate_3hr,
|
|
||||||
hashrate_unit
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate basic statistics
|
|
||||||
workers_online = len([w for w in workers_data if w['status'] == 'online'])
|
|
||||||
workers_offline = len(workers_data) - workers_online
|
|
||||||
|
|
||||||
# Use unpaid_earnings from main dashboard
|
|
||||||
unpaid_earnings = cached_metrics.get("unpaid_earnings", 0)
|
|
||||||
# Handle case where unpaid_earnings might be a string
|
|
||||||
if isinstance(unpaid_earnings, str):
|
|
||||||
try:
|
|
||||||
# Handle case where it might include "BTC" or other text
|
|
||||||
unpaid_earnings = float(unpaid_earnings.split()[0].replace(',', ''))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
unpaid_earnings = 0.001
|
|
||||||
|
|
||||||
# Ensure we have a minimum value for unpaid earnings
|
|
||||||
if unpaid_earnings <= 0:
|
|
||||||
unpaid_earnings = 0.001
|
|
||||||
|
|
||||||
# Use unpaid_earnings as total_earnings
|
|
||||||
total_earnings = unpaid_earnings
|
|
||||||
|
|
||||||
# ---- IMPORTANT FIX: Daily sats calculation ----
|
|
||||||
# Get daily_mined_sats directly from cached metrics
|
|
||||||
daily_sats = cached_metrics.get("daily_mined_sats", 0)
|
|
||||||
|
|
||||||
# If daily_sats is missing or zero, try to calculate it from other available metrics
|
|
||||||
if daily_sats is None or daily_sats == 0:
|
|
||||||
logging.warning("daily_mined_sats is missing or zero, attempting alternative calculations")
|
|
||||||
|
|
||||||
# Try to calculate from daily_btc_net
|
|
||||||
if cached_metrics.get("daily_btc_net") is not None:
|
|
||||||
daily_btc_net = cached_metrics.get("daily_btc_net")
|
|
||||||
daily_sats = int(round(daily_btc_net * self.sats_per_btc))
|
|
||||||
logging.info(f"Calculated daily_sats from daily_btc_net: {daily_sats}")
|
|
||||||
|
|
||||||
# Alternative calculation from estimated_earnings_per_day
|
|
||||||
elif cached_metrics.get("estimated_earnings_per_day") is not None:
|
|
||||||
daily_btc = cached_metrics.get("estimated_earnings_per_day")
|
|
||||||
daily_sats = int(round(daily_btc * self.sats_per_btc))
|
|
||||||
logging.info(f"Calculated daily_sats from estimated_earnings_per_day: {daily_sats}")
|
|
||||||
|
|
||||||
# If still zero, try to use estimated_earnings_per_day_sats directly
|
|
||||||
elif cached_metrics.get("estimated_earnings_per_day_sats") is not None:
|
|
||||||
daily_sats = cached_metrics.get("estimated_earnings_per_day_sats")
|
|
||||||
logging.info(f"Using estimated_earnings_per_day_sats as fallback: {daily_sats}")
|
|
||||||
|
|
||||||
logging.info(f"Final daily_sats value: {daily_sats}")
|
|
||||||
|
|
||||||
# Create hashrate history based on arrow_history if available
|
|
||||||
hashrate_history = []
|
|
||||||
if cached_metrics.get("arrow_history") and cached_metrics["arrow_history"].get("hashrate_3hr"):
|
|
||||||
hashrate_history = cached_metrics["arrow_history"]["hashrate_3hr"]
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"workers": workers_data,
|
|
||||||
"workers_total": len(workers_data),
|
|
||||||
"workers_online": workers_online,
|
|
||||||
"workers_offline": workers_offline,
|
|
||||||
"total_hashrate": original_hashrate_3hr,
|
|
||||||
"hashrate_unit": hashrate_unit,
|
|
||||||
"total_earnings": total_earnings,
|
|
||||||
"daily_sats": daily_sats, # Fixed daily_sats value
|
|
||||||
"hashrate_history": hashrate_history,
|
|
||||||
"timestamp": datetime.now(ZoneInfo(get_timezone())).isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update cache
|
|
||||||
self.worker_data_cache = result
|
|
||||||
self.last_worker_data_update = datetime.now().timestamp()
|
|
||||||
|
|
||||||
logging.info(f"Generated fallback data with {len(workers_data)} workers")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def generate_sequential_workers(self, num_workers, total_hashrate, hashrate_unit, total_unpaid_earnings=None):
|
|
||||||
"""
|
|
||||||
Generate workers with sequential names when other methods fail.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
num_workers (int): Number of workers
|
|
||||||
total_hashrate (float): Total hashrate
|
|
||||||
hashrate_unit (str): Hashrate unit
|
|
||||||
total_unpaid_earnings (float, optional): Total unpaid earnings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of worker data dictionaries
|
|
||||||
"""
|
|
||||||
logging.info(f"Generating {num_workers} workers with sequential names")
|
|
||||||
|
|
||||||
# Ensure we have at least 1 worker
|
|
||||||
num_workers = max(1, num_workers)
|
|
||||||
|
|
||||||
# Worker model types for simulation
|
|
||||||
models = [
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer S19 Pro", "max_hashrate": 110, "power": 3250},
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer T21", "max_hashrate": 130, "power": 3276},
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer S19j Pro", "max_hashrate": 104, "power": 3150},
|
|
||||||
{"type": "Bitaxe", "model": "Bitaxe Gamma 601", "max_hashrate": 3.2, "power": 35}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Calculate hashrate distribution - majority of hashrate to online workers
|
|
||||||
online_count = max(1, int(num_workers * 0.8)) # At least 1 online worker
|
|
||||||
offline_count = num_workers - online_count
|
|
||||||
|
|
||||||
# Average hashrate per online worker (ensure it's at least 0.5 TH/s)
|
|
||||||
avg_hashrate = max(0.5, total_hashrate / online_count if online_count > 0 else 0)
|
|
||||||
|
|
||||||
workers = []
|
|
||||||
current_time = datetime.now(ZoneInfo(get_timezone()))
|
|
||||||
|
|
||||||
# Default total unpaid earnings if not provided
|
|
||||||
if total_unpaid_earnings is None or total_unpaid_earnings <= 0:
|
|
||||||
total_unpaid_earnings = 0.001 # Default small amount
|
|
||||||
|
|
||||||
# Generate online workers with sequential names
|
|
||||||
for i in range(online_count):
|
|
||||||
# Select a model based on hashrate
|
|
||||||
model_info = models[0] if avg_hashrate > 50 else models[-1] if avg_hashrate < 5 else random.choice(models)
|
|
||||||
|
|
||||||
# For Antminers and regular ASICs, use ASIC model
|
|
||||||
if i < online_count - 1 or avg_hashrate > 5:
|
|
||||||
model_idx = random.randint(0, len(models) - 2) # Exclude Bitaxe for most workers
|
|
||||||
else:
|
|
||||||
model_idx = len(models) - 1 # Bitaxe for last worker if small hashrate
|
|
||||||
|
|
||||||
model_info = models[model_idx]
|
|
||||||
|
|
||||||
# Generate hashrate with some random variation
|
|
||||||
base_hashrate = min(model_info["max_hashrate"], avg_hashrate * random.uniform(0.5, 1.5))
|
|
||||||
hashrate_60sec = round(base_hashrate * random.uniform(0.9, 1.1), 2)
|
|
||||||
hashrate_3hr = round(base_hashrate * random.uniform(0.85, 1.0), 2)
|
|
||||||
|
|
||||||
# Generate last share time (within last 5 minutes)
|
|
||||||
minutes_ago = random.randint(0, 5)
|
|
||||||
last_share = (current_time - timedelta(minutes=minutes_ago)).strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
# Generate temperature (normal operating range)
|
|
||||||
temperature = random.randint(55, 70) if model_info["type"] == "ASIC" else random.randint(45, 55)
|
|
||||||
|
|
||||||
# Create a sequential name
|
|
||||||
name = f"Miner_{i+1}"
|
|
||||||
|
|
||||||
workers.append({
|
|
||||||
"name": name,
|
|
||||||
"status": "online",
|
|
||||||
"type": model_info["type"],
|
|
||||||
"model": model_info["model"],
|
|
||||||
"hashrate_60sec": hashrate_60sec,
|
|
||||||
"hashrate_60sec_unit": hashrate_unit,
|
|
||||||
"hashrate_3hr": hashrate_3hr,
|
|
||||||
"hashrate_3hr_unit": hashrate_unit,
|
|
||||||
"efficiency": round(random.uniform(65, 95), 1),
|
|
||||||
"last_share": last_share,
|
|
||||||
"earnings": 0, # Will be set after all workers are generated
|
|
||||||
"power_consumption": model_info["power"],
|
|
||||||
"temperature": temperature
|
|
||||||
})
|
|
||||||
|
|
||||||
# Generate offline workers
|
|
||||||
for i in range(offline_count):
|
|
||||||
# Select a model - more likely to be Bitaxe for offline
|
|
||||||
if random.random() > 0.6:
|
|
||||||
model_info = models[-1] # Bitaxe
|
|
||||||
else:
|
|
||||||
model_info = random.choice(models[:-1]) # ASIC
|
|
||||||
|
|
||||||
# Generate last share time (0.5 to 8 hours ago)
|
|
||||||
hours_ago = random.uniform(0.5, 8)
|
|
||||||
last_share = (current_time - timedelta(hours=hours_ago)).strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
# Generate hashrate (historical before going offline)
|
|
||||||
if model_info["type"] == "Bitaxe":
|
|
||||||
hashrate_3hr = round(random.uniform(1, 3), 2)
|
|
||||||
else:
|
|
||||||
hashrate_3hr = round(random.uniform(20, 90), 2)
|
|
||||||
|
|
||||||
# Create a sequential name
|
|
||||||
idx = i + online_count # Index for offline workers starts after online workers
|
|
||||||
name = f"Miner_{idx+1}"
|
|
||||||
|
|
||||||
workers.append({
|
|
||||||
"name": name,
|
|
||||||
"status": "offline",
|
|
||||||
"type": model_info["type"],
|
|
||||||
"model": model_info["model"],
|
|
||||||
"hashrate_60sec": 0,
|
|
||||||
"hashrate_60sec_unit": hashrate_unit,
|
|
||||||
"hashrate_3hr": hashrate_3hr,
|
|
||||||
"hashrate_3hr_unit": hashrate_unit,
|
|
||||||
"efficiency": 0,
|
|
||||||
"last_share": last_share,
|
|
||||||
"earnings": 0, # Minimal earnings for offline workers
|
|
||||||
"power_consumption": 0,
|
|
||||||
"temperature": 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Distribute earnings based on hashrate proportion
|
|
||||||
# Reserve a small portion (5%) of earnings for offline workers
|
|
||||||
online_earnings_pool = total_unpaid_earnings * 0.95
|
|
||||||
offline_earnings_pool = total_unpaid_earnings * 0.05
|
|
||||||
|
|
||||||
# Distribute earnings based on hashrate proportion for online workers
|
|
||||||
total_effective_hashrate = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
|
|
||||||
if total_effective_hashrate > 0:
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "online":
|
|
||||||
hashrate_proportion = worker["hashrate_3hr"] / total_effective_hashrate
|
|
||||||
worker["earnings"] = round(online_earnings_pool * hashrate_proportion, 8)
|
|
||||||
|
|
||||||
# Distribute minimal earnings to offline workers
|
|
||||||
if offline_count > 0:
|
|
||||||
offline_per_worker = offline_earnings_pool / offline_count
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "offline":
|
|
||||||
worker["earnings"] = round(offline_per_worker, 8)
|
|
||||||
|
|
||||||
logging.info(f"Generated {len(workers)} workers with sequential names")
|
|
||||||
return workers
|
|
||||||
|
|
||||||
def generate_simulated_workers(self, num_workers, total_hashrate, hashrate_unit, total_unpaid_earnings=None, real_worker_names=None):
|
|
||||||
"""
|
|
||||||
Generate simulated worker data based on total hashrate.
|
|
||||||
This is a fallback method used when real data can't be fetched.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
num_workers (int): Number of workers
|
|
||||||
total_hashrate (float): Total hashrate
|
|
||||||
hashrate_unit (str): Hashrate unit
|
|
||||||
total_unpaid_earnings (float, optional): Total unpaid earnings
|
|
||||||
real_worker_names (list, optional): List of real worker names to use instead of random names
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of worker data dictionaries
|
|
||||||
"""
|
|
||||||
# Ensure we have at least 1 worker
|
|
||||||
num_workers = max(1, num_workers)
|
|
||||||
|
|
||||||
# Worker model types for simulation
|
|
||||||
models = [
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer S19k Pro", "max_hashrate": 110, "power": 3250},
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer T21", "max_hashrate": 130, "power": 3276},
|
|
||||||
{"type": "ASIC", "model": "Bitmain Antminer S19j Pro", "max_hashrate": 104, "power": 3150},
|
|
||||||
{"type": "Bitaxe", "model": "Bitaxe Gamma 601", "max_hashrate": 3.2, "power": 35}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Worker names for simulation - only used if no real worker names are provided
|
|
||||||
prefixes = ["Antminer", "Miner", "Rig", "Node", "Worker", "BitAxe", "BTC"]
|
|
||||||
|
|
||||||
# Calculate hashrate distribution - majority of hashrate to online workers
|
|
||||||
online_count = max(1, int(num_workers * 0.8)) # At least 1 online worker
|
|
||||||
offline_count = num_workers - online_count
|
|
||||||
|
|
||||||
# Average hashrate per online worker (ensure it's at least 0.5 TH/s)
|
|
||||||
avg_hashrate = max(0.5, total_hashrate / online_count if online_count > 0 else 0)
|
|
||||||
|
|
||||||
workers = []
|
|
||||||
current_time = datetime.now(ZoneInfo(get_timezone()))
|
|
||||||
|
|
||||||
# Default total unpaid earnings if not provided
|
|
||||||
if total_unpaid_earnings is None or total_unpaid_earnings <= 0:
|
|
||||||
total_unpaid_earnings = 0.001 # Default small amount
|
|
||||||
|
|
||||||
# Prepare name list - use real names if available, otherwise will generate random names
|
|
||||||
# If we have real names but not enough, we'll reuse them or generate additional random ones
|
|
||||||
name_list = []
|
|
||||||
if real_worker_names and len(real_worker_names) > 0:
|
|
||||||
logging.info(f"Using {len(real_worker_names)} real worker names")
|
|
||||||
# Ensure we have enough names by cycling through the list if needed
|
|
||||||
name_list = real_worker_names * (num_workers // len(real_worker_names) + 1)
|
|
||||||
name_list = name_list[:num_workers] # Truncate to exact number needed
|
|
||||||
|
|
||||||
# Generate online workers
|
|
||||||
for i in range(online_count):
|
|
||||||
# Select a model based on hashrate
|
|
||||||
model_info = models[0] if avg_hashrate > 50 else models[-1] if avg_hashrate < 5 else random.choice(models)
|
|
||||||
|
|
||||||
# For Antminers and regular ASICs, use ASIC model
|
|
||||||
if i < online_count - 1 or avg_hashrate > 5:
|
|
||||||
model_idx = random.randint(0, len(models) - 2) # Exclude Bitaxe for most workers
|
|
||||||
else:
|
|
||||||
model_idx = len(models) - 1 # Bitaxe for last worker if small hashrate
|
|
||||||
|
|
||||||
model_info = models[model_idx]
|
|
||||||
|
|
||||||
# Generate hashrate with some random variation
|
|
||||||
base_hashrate = min(model_info["max_hashrate"], avg_hashrate * random.uniform(0.5, 1.5))
|
|
||||||
hashrate_60sec = round(base_hashrate * random.uniform(0.9, 1.1), 2)
|
|
||||||
hashrate_3hr = round(base_hashrate * random.uniform(0.85, 1.0), 2)
|
|
||||||
|
|
||||||
# Generate last share time (within last 3 minutes)
|
|
||||||
minutes_ago = random.randint(0, 3)
|
|
||||||
last_share = (current_time - timedelta(minutes=minutes_ago)).strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
# Generate temperature (normal operating range)
|
|
||||||
temperature = random.randint(55, 70) if model_info["type"] == "ASIC" else random.randint(45, 55)
|
|
||||||
|
|
||||||
# Use a real name if available, otherwise generate a random name
|
|
||||||
if name_list and i < len(name_list):
|
|
||||||
name = name_list[i]
|
|
||||||
else:
|
|
||||||
# Create a unique name
|
|
||||||
if model_info["type"] == "Bitaxe":
|
|
||||||
name = f"{prefixes[-1]}{random.randint(1, 99):02d}"
|
|
||||||
else:
|
|
||||||
name = f"{random.choice(prefixes[:-1])}{random.randint(1, 99):02d}"
|
|
||||||
|
|
||||||
workers.append({
|
|
||||||
"name": name,
|
|
||||||
"status": "online",
|
|
||||||
"type": model_info["type"],
|
|
||||||
"model": model_info["model"],
|
|
||||||
"hashrate_60sec": hashrate_60sec,
|
|
||||||
"hashrate_60sec_unit": hashrate_unit,
|
|
||||||
"hashrate_3hr": hashrate_3hr,
|
|
||||||
"hashrate_3hr_unit": hashrate_unit,
|
|
||||||
"efficiency": round(random.uniform(65, 95), 1),
|
|
||||||
"last_share": last_share,
|
|
||||||
"earnings": 0, # Will be set after all workers are generated
|
|
||||||
"power_consumption": model_info["power"],
|
|
||||||
"temperature": temperature
|
|
||||||
})
|
|
||||||
|
|
||||||
# Generate offline workers
|
|
||||||
for i in range(offline_count):
|
|
||||||
# Select a model - more likely to be Bitaxe for offline
|
|
||||||
if random.random() > 0.6:
|
|
||||||
model_info = models[-1] # Bitaxe
|
|
||||||
else:
|
|
||||||
model_info = random.choice(models[:-1]) # ASIC
|
|
||||||
|
|
||||||
# Generate last share time (0.5 to 8 hours ago)
|
|
||||||
hours_ago = random.uniform(0.5, 8)
|
|
||||||
last_share = (current_time - timedelta(hours=hours_ago)).strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
# Generate hashrate (historical before going offline)
|
|
||||||
if model_info["type"] == "Bitaxe":
|
|
||||||
hashrate_3hr = round(random.uniform(1, 3), 2)
|
|
||||||
else:
|
|
||||||
hashrate_3hr = round(random.uniform(20, 90), 2)
|
|
||||||
|
|
||||||
# Use a real name if available, otherwise generate a random name
|
|
||||||
idx = i + online_count # Index for offline workers starts after online workers
|
|
||||||
if name_list and idx < len(name_list):
|
|
||||||
name = name_list[idx]
|
|
||||||
else:
|
|
||||||
# Create a unique name
|
|
||||||
if model_info["type"] == "Bitaxe":
|
|
||||||
name = f"{prefixes[-1]}{random.randint(1, 99):02d}"
|
|
||||||
else:
|
|
||||||
name = f"{random.choice(prefixes[:-1])}{random.randint(1, 99):02d}"
|
|
||||||
|
|
||||||
workers.append({
|
|
||||||
"name": name,
|
|
||||||
"status": "offline",
|
|
||||||
"type": model_info["type"],
|
|
||||||
"model": model_info["model"],
|
|
||||||
"hashrate_60sec": 0,
|
|
||||||
"hashrate_60sec_unit": hashrate_unit,
|
|
||||||
"hashrate_3hr": hashrate_3hr,
|
|
||||||
"hashrate_3hr_unit": hashrate_unit,
|
|
||||||
"efficiency": 0,
|
|
||||||
"last_share": last_share,
|
|
||||||
"earnings": 0, # Minimal earnings for offline workers
|
|
||||||
"power_consumption": 0,
|
|
||||||
"temperature": 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Calculate the current sum of online worker hashrates
|
|
||||||
current_total = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
|
|
||||||
|
|
||||||
# If we have online workers and the total doesn't match, apply a scaling factor
|
|
||||||
if online_count > 0 and abs(current_total - total_hashrate) > 0.01 and current_total > 0:
|
|
||||||
scaling_factor = total_hashrate / current_total
|
|
||||||
|
|
||||||
# Apply scaling to all online workers
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "online":
|
|
||||||
# Scale the 3hr hashrate to exactly match total
|
|
||||||
worker["hashrate_3hr"] = round(worker["hashrate_3hr"] * scaling_factor, 2)
|
|
||||||
|
|
||||||
# Scale the 60sec hashrate proportionally
|
|
||||||
if worker["hashrate_60sec"] > 0:
|
|
||||||
worker["hashrate_60sec"] = round(worker["hashrate_60sec"] * scaling_factor, 2)
|
|
||||||
|
|
||||||
# Reserve a small portion (5%) of earnings for offline workers
|
|
||||||
online_earnings_pool = total_unpaid_earnings * 0.95
|
|
||||||
offline_earnings_pool = total_unpaid_earnings * 0.05
|
|
||||||
|
|
||||||
# Distribute earnings based on hashrate proportion for online workers
|
|
||||||
total_effective_hashrate = sum(w["hashrate_3hr"] for w in workers if w["status"] == "online")
|
|
||||||
if total_effective_hashrate > 0:
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "online":
|
|
||||||
hashrate_proportion = worker["hashrate_3hr"] / total_effective_hashrate
|
|
||||||
worker["earnings"] = round(online_earnings_pool * hashrate_proportion, 8)
|
|
||||||
|
|
||||||
# Distribute minimal earnings to offline workers
|
|
||||||
if offline_count > 0:
|
|
||||||
offline_per_worker = offline_earnings_pool / offline_count
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "offline":
|
|
||||||
worker["earnings"] = round(offline_per_worker, 8)
|
|
||||||
|
|
||||||
# Final verification - ensure total earnings match
|
|
||||||
current_total_earnings = sum(w["earnings"] for w in workers)
|
|
||||||
if abs(current_total_earnings - total_unpaid_earnings) > 0.00000001:
|
|
||||||
# Adjust the first worker to account for any rounding errors
|
|
||||||
adjustment = total_unpaid_earnings - current_total_earnings
|
|
||||||
for worker in workers:
|
|
||||||
if worker["status"] == "online":
|
|
||||||
worker["earnings"] = round(worker["earnings"] + adjustment, 8)
|
|
||||||
break
|
|
||||||
|
|
||||||
return workers
|
|
941
workers.html
Normal file
941
workers.html
Normal file
@ -0,0 +1,941 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!-- Custom Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<!-- Retro Floating Refresh Bar -->
|
||||||
|
<link rel="stylesheet" href="/static/css/retro-refresh.css">
|
||||||
|
<!-- Meta viewport for responsive scaling -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Workers Overview - Ocean.xyz Pool Mining Dashboard v 0.2</title>
|
||||||
|
<!-- Font Awesome CDN for icon support -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #0a0a0a;
|
||||||
|
--bg-gradient: linear-gradient(135deg, #0a0a0a, #1a1a1a);
|
||||||
|
--primary-color: #f7931a;
|
||||||
|
--accent-color: #00ffff;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--card-padding: 0.5rem;
|
||||||
|
--text-size-base: 16px;
|
||||||
|
--terminal-font: 'VT323', monospace;
|
||||||
|
--header-font: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--card-padding: 0.75rem;
|
||||||
|
--text-size-base: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT Screen Effect */
|
||||||
|
body::before {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0; right: 0;
|
||||||
|
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
|
||||||
|
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03));
|
||||||
|
background-size: 100% 2px, 3px 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flicker Animation */
|
||||||
|
@keyframes flicker {
|
||||||
|
0% { opacity: 0.97; }
|
||||||
|
5% { opacity: 0.95; }
|
||||||
|
10% { opacity: 0.97; }
|
||||||
|
15% { opacity: 0.94; }
|
||||||
|
20% { opacity: 0.98; }
|
||||||
|
50% { opacity: 0.95; }
|
||||||
|
80% { opacity: 0.96; }
|
||||||
|
90% { opacity: 0.94; }
|
||||||
|
100% { opacity: 0.98; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
font-size: var(--text-size-base);
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-family: var(--header-font);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 0 10px var(--primary-color);
|
||||||
|
animation: flicker 4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.card-header,
|
||||||
|
.card-body,
|
||||||
|
.card-footer {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced card with scanlines */
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: var(--card-padding);
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanline effect for cards */
|
||||||
|
.card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.05),
|
||||||
|
rgba(0, 0, 0, 0.05) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 2px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #000;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--primary-color);
|
||||||
|
text-shadow: 0 0 5px var(--primary-color);
|
||||||
|
animation: flicker 4s infinite;
|
||||||
|
font-family: var(--header-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body hr {
|
||||||
|
border-top: 1px solid var(--primary-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounce Up Animation for Up Chevron */
|
||||||
|
@keyframes bounceUp {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
25% { transform: translateY(-2px); }
|
||||||
|
50% { transform: translateY(0); }
|
||||||
|
75% { transform: translateY(-2px); }
|
||||||
|
100% { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounce Down Animation for Down Chevron */
|
||||||
|
@keyframes bounceDown {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
25% { transform: translateY(2px); }
|
||||||
|
50% { transform: translateY(0); }
|
||||||
|
75% { transform: translateY(2px); }
|
||||||
|
100% { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply bounce animations */
|
||||||
|
.bounce-up {
|
||||||
|
animation: bounceUp 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce-down {
|
||||||
|
animation: bounceDown 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make chevrons slightly smaller */
|
||||||
|
.chevron {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Online dot with more glow */
|
||||||
|
.online-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #32CD32;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
animation: glow 1s infinite;
|
||||||
|
box-shadow: 0 0 10px #32CD32, 0 0 20px #32CD32;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 10px #32CD32, 0 0 15px #32CD32; }
|
||||||
|
50% { box-shadow: 0 0 15px #32CD32, 0 0 25px #32CD32; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Offline dot with more glow */
|
||||||
|
.offline-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: red;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
animation: glowRed 1s infinite;
|
||||||
|
box-shadow: 0 0 10px red, 0 0 20px red;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glowRed {
|
||||||
|
0%, 100% { box-shadow: 0 0 10px red, 0 0 15px red; }
|
||||||
|
50% { box-shadow: 0 0 15px red, 0 0 25px red; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refresh timer container */
|
||||||
|
#refreshUptime {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhance metric value styling with consistent glow */
|
||||||
|
.metric-value {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standardized glow effects for all metrics */
|
||||||
|
/* Yellow color family (BTC price, sats metrics, time to payout) */
|
||||||
|
.metric-value.yellow,
|
||||||
|
.yellow-glow {
|
||||||
|
color: #ffd700;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 215, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green color family (profits, earnings) */
|
||||||
|
.metric-value.green,
|
||||||
|
.green-glow {
|
||||||
|
color: #32CD32;
|
||||||
|
text-shadow: 0 0 6px rgba(50, 205, 50, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red color family (costs) */
|
||||||
|
.metric-value.red,
|
||||||
|
.red-glow {
|
||||||
|
color: #ff5555 !important;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 85, 85, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* White metrics (general stats) */
|
||||||
|
.metric-value.white,
|
||||||
|
.white-glow {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue metrics (time data) */
|
||||||
|
.metric-value.blue,
|
||||||
|
.blue-glow {
|
||||||
|
color: #00dfff;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 223, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special stronger glow only for online/offline indicators */
|
||||||
|
.status-green {
|
||||||
|
color: #39ff14 !important;
|
||||||
|
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-red {
|
||||||
|
color: #ff2d2d !important;
|
||||||
|
text-shadow: 0 0 10px #ff2d2d, 0 0 20px #ff2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card body elements */
|
||||||
|
.card-body strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
text-shadow: 0 0 2px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topRightLink {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
color: grey;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-shadow: 0 0 5px grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uptimeTimer strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uptimeTimer {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status indicator */
|
||||||
|
#connectionStatus {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(255,0,0,0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 9999;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 0, 0, 0.8);
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worker grid for worker cards */
|
||||||
|
.worker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worker card styles */
|
||||||
|
.worker-card {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.05),
|
||||||
|
rgba(0, 0, 0, 0.05) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 2px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-card-online {
|
||||||
|
border-color: #32CD32;
|
||||||
|
box-shadow: 0 0 8px rgba(50, 205, 50, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-card-offline {
|
||||||
|
border-color: #ff5555;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 85, 85, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-name {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-shadow: 0 0 5px var(--primary-color);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-stats {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-stats-label {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashrate-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #1137F5, #39ff14);
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worker badge */
|
||||||
|
.worker-type {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding: 1px 5px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-online {
|
||||||
|
background-color: rgba(50, 205, 50, 0.2);
|
||||||
|
border: 1px solid #32CD32;
|
||||||
|
color: #32CD32;
|
||||||
|
text-shadow: 0 0 5px rgba(50, 205, 50, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-offline {
|
||||||
|
background-color: rgba(255, 85, 85, 0.2);
|
||||||
|
border: 1px solid #ff5555;
|
||||||
|
color: #ff5555;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 85, 85, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats bars */
|
||||||
|
.stats-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #1137F5, #39ff14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search and filter controls */
|
||||||
|
.controls-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-button {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-button.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bitcoin Progress Bar Styles */
|
||||||
|
.bitcoin-progress-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #111;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 8px rgba(247, 147, 26, 0.5);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitcoin-progress-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: linear-gradient(90deg, #f7931a, #ffa500);
|
||||||
|
border-radius: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitcoin-progress-inner::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(255, 255, 255, 0.1) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 20%,
|
||||||
|
rgba(255, 255, 255, 0.1) 40%);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitcoin-icons {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-effect {
|
||||||
|
box-shadow: 0 0 15px #f7931a, 0 0 25px #f7931a;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
text-shadow: 0 0 5px var(--primary-color);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Last Updated text with subtle animation */
|
||||||
|
#lastUpdated {
|
||||||
|
animation: flicker 5s infinite;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor blink for terminal feel */
|
||||||
|
#terminal-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #f7931a;
|
||||||
|
margin-left: 2px;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 5px rgba(247, 147, 26, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary stats in the header */
|
||||||
|
.summary-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worker count ring */
|
||||||
|
.worker-ring {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: conic-gradient(
|
||||||
|
#32CD32 0% calc(var(--online-percent) * 100%),
|
||||||
|
#ff5555 calc(var(--online-percent) * 100%) 100%
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 15px rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-ring-inner {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini hashrate chart */
|
||||||
|
.mini-chart {
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.controls-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation links */
|
||||||
|
.navigation-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 5px 15px;
|
||||||
|
margin: 0 10px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
box-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
||||||
|
}
|
||||||
|
.loading-fade {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-card {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Fix for "Made by" link collision with title */
|
||||||
|
#topRightLink {
|
||||||
|
position: static !important;
|
||||||
|
display: block !important;
|
||||||
|
text-align: right !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust heading for better mobile display */
|
||||||
|
h1 {
|
||||||
|
font-size: 20px !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve container padding for mobile */
|
||||||
|
.container-fluid {
|
||||||
|
padding-left: 0.5rem !important;
|
||||||
|
padding-right: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure top section has appropriate spacing */
|
||||||
|
.row.mb-3 {
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a more aggressive breakpoint for very small screens */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
#topRightLink {
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Further reduce container padding */
|
||||||
|
.container-fluid {
|
||||||
|
padding-left: 0.3rem !important;
|
||||||
|
padding-right: 0.3rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Connection status indicator -->
|
||||||
|
<div id="connectionStatus"></div>
|
||||||
|
|
||||||
|
<!-- Updated section to fix mobile layout issues -->
|
||||||
|
<div class="top-section">
|
||||||
|
<!-- Top right link - moved to top for better mobile flow -->
|
||||||
|
<a href="https://x.com/DJObleezy" id="topRightLink" target="_blank" rel="noopener noreferrer">Made by @DJO₿leezy</a>
|
||||||
|
|
||||||
|
<!-- Title with margin to avoid collision -->
|
||||||
|
<h1 class="text-center">Workers Overview</h1>
|
||||||
|
<p class="text-center" id="lastUpdated"><strong>Last Updated:</strong> {{ current_time }}<span id="terminal-cursor"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation links -->
|
||||||
|
<div class="navigation-links">
|
||||||
|
<a href="/dashboard" class="nav-link">Main Dashboard</a>
|
||||||
|
<a href="/workers" class="nav-link active">Workers Overview</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary statistics -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Fleet Summary</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="summary-stats">
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="worker-ring" style="--online-percent: {{ workers_online / workers_total if workers_total > 0 else 0 }}">
|
||||||
|
<div class="worker-ring-inner">
|
||||||
|
<span id="workers-count">{{ workers_total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat-label">Workers</div>
|
||||||
|
<div>
|
||||||
|
<span class="green-glow" id="workers-online">{{ workers_online }}</span> /
|
||||||
|
<span class="red-glow" id="workers-offline">{{ workers_offline }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-stat-value white-glow" id="total-hashrate">
|
||||||
|
{% if total_hashrate is defined %}
|
||||||
|
{{ "%.1f"|format(total_hashrate) }} {{ hashrate_unit }}
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat-label">Total Hashrate</div>
|
||||||
|
<div class="mini-chart">
|
||||||
|
<canvas id="total-hashrate-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-stat-value green-glow" id="total-earnings">
|
||||||
|
{% if total_earnings is defined %}
|
||||||
|
{{ "%.8f"|format(total_earnings) }} BTC
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat-label">Lifetime Earnings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-stat-value yellow-glow" id="daily-sats">
|
||||||
|
{% if daily_sats is defined %}
|
||||||
|
{{ daily_sats|commafy }} sats
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat-label">Daily Sats</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-stat-value white-glow" id="avg-acceptance-rate">
|
||||||
|
{% if avg_acceptance_rate is defined %}
|
||||||
|
{{ "%.2f"|format(avg_acceptance_rate) }}%
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat-label">Acceptance Rate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls bar -->
|
||||||
|
<div class="controls-bar">
|
||||||
|
<input type="text" class="search-box" id="worker-search" placeholder="Search workers...">
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<button class="filter-button active" data-filter="all">All Workers</button>
|
||||||
|
<button class="filter-button" data-filter="online">Online</button>
|
||||||
|
<button class="filter-button" data-filter="offline">Offline</button>
|
||||||
|
<button class="filter-button" data-filter="asic">ASIC</button>
|
||||||
|
<button class="filter-button" data-filter="fpga">FPGA</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workers grid -->
|
||||||
|
<div class="worker-grid" id="worker-grid">
|
||||||
|
<!-- Worker cards will be generated here via JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bitcoin-themed Progress Bar and Uptime -->
|
||||||
|
<div id="refreshUptime" class="text-center mt-4">
|
||||||
|
<div id="refreshContainer">
|
||||||
|
<!-- Bitcoin-themed progress bar -->
|
||||||
|
<div class="bitcoin-progress-container">
|
||||||
|
<div id="bitcoin-progress-inner" class="bitcoin-progress-inner" style="width: 0%">
|
||||||
|
<!-- Small Bitcoin icons inside the bar -->
|
||||||
|
<div class="bitcoin-icons">
|
||||||
|
<i class="fab fa-bitcoin"></i>
|
||||||
|
<i class="fab fa-bitcoin"></i>
|
||||||
|
<i class="fab fa-bitcoin"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-text">60s to next update</div>
|
||||||
|
</div>
|
||||||
|
<div id="uptimeTimer"><strong>Uptime:</strong> 0h 0m 0s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External JavaScript libraries -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="/static/js/workers.js"></script>
|
||||||
|
<!-- Retro Floating Refresh Bar -->
|
||||||
|
<script src="/static/js/retro-refresh.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
641
workers.js
Normal file
641
workers.js
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Global variables for workers dashboard
|
||||||
|
let workerData = null;
|
||||||
|
let refreshTimer;
|
||||||
|
let pageLoadTime = Date.now();
|
||||||
|
let currentProgress = 0;
|
||||||
|
const PROGRESS_MAX = 60; // 60 seconds for a complete cycle
|
||||||
|
let lastUpdateTime = Date.now();
|
||||||
|
let filterState = {
|
||||||
|
currentFilter: 'all',
|
||||||
|
searchTerm: ''
|
||||||
|
};
|
||||||
|
let miniChart = null;
|
||||||
|
let connectionRetryCount = 0;
|
||||||
|
|
||||||
|
// Server time variables for uptime calculation - synced with main dashboard
|
||||||
|
let serverTimeOffset = 0;
|
||||||
|
let serverStartTime = null;
|
||||||
|
|
||||||
|
// New variable to track custom refresh timing
|
||||||
|
let lastManualRefreshTime = 0;
|
||||||
|
const MIN_REFRESH_INTERVAL = 10000; // Minimum 10 seconds between refreshes
|
||||||
|
|
||||||
|
// Initialize the page
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Set up initial UI
|
||||||
|
initializePage();
|
||||||
|
|
||||||
|
// Get server time for uptime calculation
|
||||||
|
updateServerTime();
|
||||||
|
|
||||||
|
// Set up refresh synchronization with main dashboard
|
||||||
|
setupRefreshSync();
|
||||||
|
|
||||||
|
// Fetch worker data immediately on page load
|
||||||
|
fetchWorkerData();
|
||||||
|
|
||||||
|
// Set up refresh timer
|
||||||
|
setInterval(updateProgressBar, 1000);
|
||||||
|
|
||||||
|
// Set up uptime timer - synced with main dashboard
|
||||||
|
setInterval(updateUptime, 1000);
|
||||||
|
|
||||||
|
// Start server time polling - same as main dashboard
|
||||||
|
setInterval(updateServerTime, 30000);
|
||||||
|
|
||||||
|
// Auto-refresh worker data - aligned with main dashboard if possible
|
||||||
|
setInterval(function() {
|
||||||
|
// Check if it's been at least PROGRESS_MAX seconds since last update
|
||||||
|
const timeSinceLastUpdate = Date.now() - lastUpdateTime;
|
||||||
|
if (timeSinceLastUpdate >= PROGRESS_MAX * 1000) {
|
||||||
|
// Check if there was a recent manual refresh
|
||||||
|
const timeSinceManualRefresh = Date.now() - lastManualRefreshTime;
|
||||||
|
if (timeSinceManualRefresh >= MIN_REFRESH_INTERVAL) {
|
||||||
|
console.log("Auto-refresh triggered after time interval");
|
||||||
|
fetchWorkerData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10000); // Check every 10 seconds to align better with main dashboard
|
||||||
|
|
||||||
|
// Set up filter button click handlers
|
||||||
|
$('.filter-button').click(function() {
|
||||||
|
$('.filter-button').removeClass('active');
|
||||||
|
$(this).addClass('active');
|
||||||
|
filterState.currentFilter = $(this).data('filter');
|
||||||
|
filterWorkers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up search input handler
|
||||||
|
$('#worker-search').on('input', function() {
|
||||||
|
filterState.searchTerm = $(this).val().toLowerCase();
|
||||||
|
filterWorkers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up refresh synchronization with main dashboard
|
||||||
|
function setupRefreshSync() {
|
||||||
|
// Listen for storage events (triggered by main dashboard)
|
||||||
|
window.addEventListener('storage', function(event) {
|
||||||
|
// Check if this is our dashboard refresh event
|
||||||
|
if (event.key === 'dashboardRefreshEvent') {
|
||||||
|
console.log("Detected dashboard refresh event");
|
||||||
|
|
||||||
|
// Prevent too frequent refreshes
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRefresh = now - lastUpdateTime;
|
||||||
|
|
||||||
|
if (timeSinceLastRefresh >= MIN_REFRESH_INTERVAL) {
|
||||||
|
console.log("Syncing refresh with main dashboard");
|
||||||
|
// Reset progress bar and immediately fetch
|
||||||
|
resetProgressBar();
|
||||||
|
// Refresh the worker data
|
||||||
|
fetchWorkerData();
|
||||||
|
} else {
|
||||||
|
console.log("Skipping too-frequent refresh", timeSinceLastRefresh);
|
||||||
|
// Just reset the progress bar to match main dashboard
|
||||||
|
resetProgressBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// On page load, check if we should align with main dashboard timing
|
||||||
|
try {
|
||||||
|
const lastDashboardRefresh = localStorage.getItem('dashboardRefreshTime');
|
||||||
|
if (lastDashboardRefresh) {
|
||||||
|
const lastRefreshTime = parseInt(lastDashboardRefresh);
|
||||||
|
const timeSinceLastDashboardRefresh = Date.now() - lastRefreshTime;
|
||||||
|
|
||||||
|
// If main dashboard refreshed recently, adjust our timer
|
||||||
|
if (timeSinceLastDashboardRefresh < PROGRESS_MAX * 1000) {
|
||||||
|
console.log("Adjusting timer to align with main dashboard");
|
||||||
|
currentProgress = Math.floor(timeSinceLastDashboardRefresh / 1000);
|
||||||
|
updateProgressBar(currentProgress);
|
||||||
|
|
||||||
|
// Calculate when next update will happen (roughly 60 seconds from last dashboard refresh)
|
||||||
|
const timeUntilNextRefresh = (PROGRESS_MAX * 1000) - timeSinceLastDashboardRefresh;
|
||||||
|
|
||||||
|
// Schedule a one-time check near the expected refresh time
|
||||||
|
if (timeUntilNextRefresh > 0) {
|
||||||
|
console.log(`Scheduling coordinated refresh in ${Math.floor(timeUntilNextRefresh/1000)} seconds`);
|
||||||
|
setTimeout(function() {
|
||||||
|
// Check if a refresh happened in the last few seconds via localStorage event
|
||||||
|
const newLastRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
|
||||||
|
const secondsSinceLastRefresh = (Date.now() - newLastRefresh) / 1000;
|
||||||
|
|
||||||
|
// If dashboard hasn't refreshed in the last 5 seconds, do our own refresh
|
||||||
|
if (secondsSinceLastRefresh > 5) {
|
||||||
|
console.log("Coordinated refresh time reached, fetching data");
|
||||||
|
fetchWorkerData();
|
||||||
|
} else {
|
||||||
|
console.log("Dashboard already refreshed recently, skipping coordinated refresh");
|
||||||
|
}
|
||||||
|
}, timeUntilNextRefresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error reading dashboard refresh time:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dashboard refresh periodically
|
||||||
|
setInterval(function() {
|
||||||
|
try {
|
||||||
|
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRefresh = (now - lastUpdateTime) / 1000;
|
||||||
|
const timeSinceDashboardRefresh = (now - lastDashboardRefresh) / 1000;
|
||||||
|
|
||||||
|
// If dashboard refreshed more recently than we did and we haven't refreshed in at least 10 seconds
|
||||||
|
if (lastDashboardRefresh > lastUpdateTime && timeSinceLastRefresh > 10) {
|
||||||
|
console.log("Catching up with dashboard refresh");
|
||||||
|
resetProgressBar();
|
||||||
|
fetchWorkerData();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error in periodic dashboard check:", e);
|
||||||
|
}
|
||||||
|
}, 5000); // Check every 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server time update via polling - same as main.js
|
||||||
|
function updateServerTime() {
|
||||||
|
$.ajax({
|
||||||
|
url: "/api/time",
|
||||||
|
method: "GET",
|
||||||
|
timeout: 5000,
|
||||||
|
success: function(data) {
|
||||||
|
serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now();
|
||||||
|
serverStartTime = new Date(data.server_start_time).getTime();
|
||||||
|
},
|
||||||
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
|
console.error("Error fetching server time:", textStatus, errorThrown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update uptime display - synced with main dashboard
|
||||||
|
function updateUptime() {
|
||||||
|
if (serverStartTime) {
|
||||||
|
const currentServerTime = Date.now() + serverTimeOffset;
|
||||||
|
const diff = currentServerTime - serverStartTime;
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
$("#uptimeTimer").html("<strong>Uptime:</strong> " + hours + "h " + minutes + "m " + seconds + "s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page elements
|
||||||
|
function initializePage() {
|
||||||
|
// Initialize mini chart for total hashrate if the element exists
|
||||||
|
if (document.getElementById('total-hashrate-chart')) {
|
||||||
|
initializeMiniChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
$('#worker-grid').html('<div class="text-center p-5"><i class="fas fa-spinner fa-spin"></i> Loading worker data...</div>');
|
||||||
|
|
||||||
|
// Add retry button (hidden by default)
|
||||||
|
if (!$('#retry-button').length) {
|
||||||
|
$('body').append('<button id="retry-button" style="position: fixed; bottom: 20px; left: 20px; z-index: 1000; background: #f7931a; color: black; border: none; padding: 8px 16px; display: none; border-radius: 4px; cursor: pointer;">Retry Loading Data</button>');
|
||||||
|
|
||||||
|
$('#retry-button').on('click', function() {
|
||||||
|
$(this).text('Retrying...').prop('disabled', true);
|
||||||
|
fetchWorkerData(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
$('#retry-button').text('Retry Loading Data').prop('disabled', false);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch worker data from API
|
||||||
|
function fetchWorkerData(forceRefresh = false) {
|
||||||
|
// Track this as a manual refresh for throttling purposes
|
||||||
|
lastManualRefreshTime = Date.now();
|
||||||
|
|
||||||
|
$('#worker-grid').addClass('loading-fade');
|
||||||
|
|
||||||
|
// Update progress bar to show data is being fetched
|
||||||
|
resetProgressBar();
|
||||||
|
|
||||||
|
// Choose API URL based on whether we're forcing a refresh
|
||||||
|
const apiUrl = `/api/workers${forceRefresh ? '?force=true' : ''}`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: apiUrl,
|
||||||
|
method: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 15000, // 15 second timeout
|
||||||
|
success: function(data) {
|
||||||
|
workerData = data;
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
// Update UI with new data
|
||||||
|
updateWorkerGrid();
|
||||||
|
updateSummaryStats();
|
||||||
|
updateMiniChart();
|
||||||
|
updateLastUpdated();
|
||||||
|
|
||||||
|
// Hide retry button
|
||||||
|
$('#retry-button').hide();
|
||||||
|
|
||||||
|
// Reset connection retry count
|
||||||
|
connectionRetryCount = 0;
|
||||||
|
|
||||||
|
console.log("Worker data updated successfully");
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error("Error fetching worker data:", error);
|
||||||
|
|
||||||
|
// Show error in worker grid
|
||||||
|
$('#worker-grid').html(`
|
||||||
|
<div class="text-center p-5 text-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<p>Error loading worker data: ${error || 'Unknown error'}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Show retry button
|
||||||
|
$('#retry-button').show();
|
||||||
|
|
||||||
|
// Implement exponential backoff for automatic retry
|
||||||
|
connectionRetryCount++;
|
||||||
|
const delay = Math.min(30000, 1000 * Math.pow(1.5, Math.min(5, connectionRetryCount)));
|
||||||
|
console.log(`Will retry in ${delay/1000} seconds (attempt ${connectionRetryCount})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchWorkerData(true); // Force refresh on retry
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
$('#worker-grid').removeClass('loading-fade');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the worker grid with data
|
||||||
|
// UPDATED FUNCTION
|
||||||
|
function updateWorkerGrid() {
|
||||||
|
if (!workerData || !workerData.workers) {
|
||||||
|
console.error("No worker data available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerGrid = $('#worker-grid');
|
||||||
|
workerGrid.empty();
|
||||||
|
|
||||||
|
// Apply current filters before rendering
|
||||||
|
const filteredWorkers = filterWorkersData(workerData.workers);
|
||||||
|
|
||||||
|
if (filteredWorkers.length === 0) {
|
||||||
|
workerGrid.html(`
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<p>No workers match your filter criteria</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total unpaid earnings (from the dashboard)
|
||||||
|
const totalUnpaidEarnings = workerData.total_earnings || 0;
|
||||||
|
|
||||||
|
// Sum up hashrates of online workers to calculate share percentages
|
||||||
|
const totalHashrate = workerData.workers
|
||||||
|
.filter(w => w.status === 'online')
|
||||||
|
.reduce((sum, w) => sum + parseFloat(w.hashrate_3hr || 0), 0);
|
||||||
|
|
||||||
|
// Calculate share percentage for each worker
|
||||||
|
const onlineWorkers = workerData.workers.filter(w => w.status === 'online');
|
||||||
|
const offlineWorkers = workerData.workers.filter(w => w.status === 'offline');
|
||||||
|
|
||||||
|
// Allocate 95% to online workers, 5% to offline workers
|
||||||
|
const onlinePool = totalUnpaidEarnings * 0.95;
|
||||||
|
const offlinePool = totalUnpaidEarnings * 0.05;
|
||||||
|
|
||||||
|
// Generate worker cards
|
||||||
|
filteredWorkers.forEach(worker => {
|
||||||
|
// Calculate earnings share based on hashrate proportion
|
||||||
|
let earningsDisplay = worker.earnings;
|
||||||
|
|
||||||
|
// Explicitly recalculate earnings share for display consistency
|
||||||
|
if (worker.status === 'online' && totalHashrate > 0) {
|
||||||
|
const hashrateShare = parseFloat(worker.hashrate_3hr || 0) / totalHashrate;
|
||||||
|
earningsDisplay = (onlinePool * hashrateShare).toFixed(8);
|
||||||
|
} else if (worker.status === 'offline' && offlineWorkers.length > 0) {
|
||||||
|
earningsDisplay = (offlinePool / offlineWorkers.length).toFixed(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worker card
|
||||||
|
const card = $('<div class="worker-card"></div>');
|
||||||
|
|
||||||
|
// Add class based on status
|
||||||
|
if (worker.status === 'online') {
|
||||||
|
card.addClass('worker-card-online');
|
||||||
|
} else {
|
||||||
|
card.addClass('worker-card-offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add worker type badge
|
||||||
|
card.append(`<div class="worker-type">${worker.type}</div>`);
|
||||||
|
|
||||||
|
// Add worker name
|
||||||
|
card.append(`<div class="worker-name">${worker.name}</div>`);
|
||||||
|
|
||||||
|
// Add status badge
|
||||||
|
if (worker.status === 'online') {
|
||||||
|
card.append('<div class="status-badge status-badge-online">ONLINE</div>');
|
||||||
|
} else {
|
||||||
|
card.append('<div class="status-badge status-badge-offline">OFFLINE</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hashrate bar
|
||||||
|
const maxHashrate = 200; // TH/s - adjust based on your fleet
|
||||||
|
const hashratePercent = Math.min(100, (worker.hashrate_3hr / maxHashrate) * 100);
|
||||||
|
card.append(`
|
||||||
|
<div class="worker-stats-row">
|
||||||
|
<div class="worker-stats-label">Hashrate (3hr):</div>
|
||||||
|
<div class="white-glow">${worker.hashrate_3hr} ${worker.hashrate_3hr_unit}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-bar-container">
|
||||||
|
<div class="stats-bar" style="width: ${hashratePercent}%"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add additional stats - NOTE: Using recalculated earnings
|
||||||
|
card.append(`
|
||||||
|
<div class="worker-stats">
|
||||||
|
<div class="worker-stats-row">
|
||||||
|
<div class="worker-stats-label">Last Share:</div>
|
||||||
|
<div class="blue-glow">${worker.last_share.split(' ')[1]}</div>
|
||||||
|
</div>
|
||||||
|
<div class="worker-stats-row">
|
||||||
|
<div class="worker-stats-label">Earnings:</div>
|
||||||
|
<div class="green-glow">${earningsDisplay}</div>
|
||||||
|
</div>
|
||||||
|
<div class="worker-stats-row">
|
||||||
|
<div class="worker-stats-label">Accept Rate:</div>
|
||||||
|
<div class="white-glow">${worker.acceptance_rate}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="worker-stats-row">
|
||||||
|
<div class="worker-stats-label">Temp:</div>
|
||||||
|
<div class="${worker.temperature > 65 ? 'red-glow' : 'white-glow'}">${worker.temperature > 0 ? worker.temperature + '°C' : 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add card to grid
|
||||||
|
workerGrid.append(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the sum of displayed earnings equals the total
|
||||||
|
console.log(`Total unpaid earnings: ${totalUnpaidEarnings} BTC`);
|
||||||
|
console.log(`Sum of worker displayed earnings: ${
|
||||||
|
filteredWorkers.reduce((sum, w) => {
|
||||||
|
if (w.status === 'online' && totalHashrate > 0) {
|
||||||
|
const hashrateShare = parseFloat(w.hashrate_3hr || 0) / totalHashrate;
|
||||||
|
return sum + parseFloat((onlinePool * hashrateShare).toFixed(8));
|
||||||
|
} else if (w.status === 'offline' && offlineWorkers.length > 0) {
|
||||||
|
return sum + parseFloat((offlinePool / offlineWorkers.length).toFixed(8));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0)
|
||||||
|
} BTC`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter worker data based on current filter state
|
||||||
|
function filterWorkersData(workers) {
|
||||||
|
if (!workers) return [];
|
||||||
|
|
||||||
|
return workers.filter(worker => {
|
||||||
|
const workerName = worker.name.toLowerCase();
|
||||||
|
const isOnline = worker.status === 'online';
|
||||||
|
const workerType = worker.type.toLowerCase();
|
||||||
|
|
||||||
|
// Check if worker matches filter
|
||||||
|
let matchesFilter = false;
|
||||||
|
if (filterState.currentFilter === 'all') {
|
||||||
|
matchesFilter = true;
|
||||||
|
} else if (filterState.currentFilter === 'online' && isOnline) {
|
||||||
|
matchesFilter = true;
|
||||||
|
} else if (filterState.currentFilter === 'offline' && !isOnline) {
|
||||||
|
matchesFilter = true;
|
||||||
|
} else if (filterState.currentFilter === 'asic' && workerType === 'asic') {
|
||||||
|
matchesFilter = true;
|
||||||
|
} else if (filterState.currentFilter === 'fpga' && workerType === 'fpga') {
|
||||||
|
matchesFilter = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if worker matches search term
|
||||||
|
const matchesSearch = workerName.includes(filterState.searchTerm);
|
||||||
|
|
||||||
|
return matchesFilter && matchesSearch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter to rendered worker cards
|
||||||
|
function filterWorkers() {
|
||||||
|
if (!workerData || !workerData.workers) return;
|
||||||
|
|
||||||
|
// Re-render the worker grid with current filters
|
||||||
|
updateWorkerGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified updateSummaryStats function for workers.js
|
||||||
|
function updateSummaryStats() {
|
||||||
|
if (!workerData) return;
|
||||||
|
|
||||||
|
// Update worker counts
|
||||||
|
$('#workers-count').text(workerData.workers_total || 0);
|
||||||
|
$('#workers-online').text(workerData.workers_online || 0);
|
||||||
|
$('#workers-offline').text(workerData.workers_offline || 0);
|
||||||
|
|
||||||
|
// Update worker ring percentage
|
||||||
|
const onlinePercent = workerData.workers_total > 0 ?
|
||||||
|
workerData.workers_online / workerData.workers_total : 0;
|
||||||
|
$('.worker-ring').css('--online-percent', onlinePercent);
|
||||||
|
|
||||||
|
// IMPORTANT: Update total hashrate using EXACT format matching main dashboard
|
||||||
|
// This ensures the displayed value matches exactly what's on the main page
|
||||||
|
if (workerData.total_hashrate !== undefined) {
|
||||||
|
// Format with exactly 1 decimal place - matches main dashboard format
|
||||||
|
const formattedHashrate = Number(workerData.total_hashrate).toFixed(1);
|
||||||
|
$('#total-hashrate').text(`${formattedHashrate} ${workerData.hashrate_unit || 'TH/s'}`);
|
||||||
|
} else {
|
||||||
|
$('#total-hashrate').text(`0.0 ${workerData.hashrate_unit || 'TH/s'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other summary stats
|
||||||
|
$('#total-earnings').text(`${(workerData.total_earnings || 0).toFixed(8)} BTC`);
|
||||||
|
$('#daily-sats').text(`${numberWithCommas(workerData.daily_sats || 0)} sats`);
|
||||||
|
$('#avg-acceptance-rate').text(`${(workerData.avg_acceptance_rate || 0).toFixed(2)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize mini chart
|
||||||
|
function initializeMiniChart() {
|
||||||
|
const ctx = document.getElementById('total-hashrate-chart').getContext('2d');
|
||||||
|
|
||||||
|
// Generate some sample data to start
|
||||||
|
const labels = Array(24).fill('').map((_, i) => i);
|
||||||
|
const data = [750, 760, 755, 770, 780, 775, 760, 765, 770, 775, 780, 790, 785, 775, 770, 765, 780, 785, 775, 770, 775, 780, 775, 774.8];
|
||||||
|
|
||||||
|
miniChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data,
|
||||||
|
borderColor: '#1137F5',
|
||||||
|
backgroundColor: 'rgba(57, 255, 20, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: {
|
||||||
|
display: false,
|
||||||
|
min: Math.min(...data) * 0.9,
|
||||||
|
max: Math.max(...data) * 1.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { enabled: false }
|
||||||
|
},
|
||||||
|
animation: false,
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mini chart with real data
|
||||||
|
function updateMiniChart() {
|
||||||
|
if (!miniChart || !workerData || !workerData.hashrate_history) return;
|
||||||
|
|
||||||
|
// Extract hashrate data from history
|
||||||
|
const historyData = workerData.hashrate_history;
|
||||||
|
if (!historyData || historyData.length === 0) return;
|
||||||
|
|
||||||
|
// Get the values for the chart
|
||||||
|
const values = historyData.map(item => parseFloat(item.value) || 0);
|
||||||
|
const labels = historyData.map(item => item.time);
|
||||||
|
|
||||||
|
// Update chart data
|
||||||
|
miniChart.data.labels = labels;
|
||||||
|
miniChart.data.datasets[0].data = values;
|
||||||
|
|
||||||
|
// Update y-axis range
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
miniChart.options.scales.y.min = min * 0.9;
|
||||||
|
miniChart.options.scales.y.max = max * 1.1;
|
||||||
|
|
||||||
|
// Update the chart
|
||||||
|
miniChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
function updateProgressBar() {
|
||||||
|
if (currentProgress < PROGRESS_MAX) {
|
||||||
|
currentProgress++;
|
||||||
|
const progressPercent = (currentProgress / PROGRESS_MAX) * 100;
|
||||||
|
$("#bitcoin-progress-inner").css("width", progressPercent + "%");
|
||||||
|
|
||||||
|
// Add glowing effect when close to completion
|
||||||
|
if (progressPercent > 80) {
|
||||||
|
$("#bitcoin-progress-inner").addClass("glow-effect");
|
||||||
|
} else {
|
||||||
|
$("#bitcoin-progress-inner").removeClass("glow-effect");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update remaining seconds text
|
||||||
|
let remainingSeconds = PROGRESS_MAX - currentProgress;
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
$("#progress-text").text("Waiting for update...");
|
||||||
|
$("#bitcoin-progress-inner").addClass("waiting-for-update");
|
||||||
|
} else {
|
||||||
|
$("#progress-text").text(remainingSeconds + "s to next update");
|
||||||
|
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for main dashboard refresh near the end to ensure sync
|
||||||
|
if (currentProgress >= 55) { // When we're getting close to refresh time
|
||||||
|
try {
|
||||||
|
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
|
||||||
|
const secondsSinceDashboardRefresh = (Date.now() - lastDashboardRefresh) / 1000;
|
||||||
|
|
||||||
|
// If main dashboard just refreshed (within last 5 seconds)
|
||||||
|
if (secondsSinceDashboardRefresh <= 5) {
|
||||||
|
console.log("Detected recent dashboard refresh, syncing now");
|
||||||
|
resetProgressBar();
|
||||||
|
fetchWorkerData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error checking dashboard refresh status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset progress bar if it's time to refresh
|
||||||
|
// But first check if the main dashboard refreshed recently
|
||||||
|
try {
|
||||||
|
const lastDashboardRefresh = parseInt(localStorage.getItem('dashboardRefreshTime') || '0');
|
||||||
|
const secondsSinceDashboardRefresh = (Date.now() - lastDashboardRefresh) / 1000;
|
||||||
|
|
||||||
|
// If dashboard refreshed in the last 10 seconds, wait for it instead of refreshing ourselves
|
||||||
|
if (secondsSinceDashboardRefresh < 10) {
|
||||||
|
console.log("Waiting for dashboard refresh event instead of refreshing independently");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error checking dashboard refresh status:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If main dashboard hasn't refreshed recently, do our own refresh
|
||||||
|
if (Date.now() - lastUpdateTime > PROGRESS_MAX * 1000) {
|
||||||
|
console.log("Progress bar expired, fetching data");
|
||||||
|
fetchWorkerData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset progress bar
|
||||||
|
function resetProgressBar() {
|
||||||
|
currentProgress = 0;
|
||||||
|
$("#bitcoin-progress-inner").css("width", "0%");
|
||||||
|
$("#bitcoin-progress-inner").removeClass("glow-effect");
|
||||||
|
$("#bitcoin-progress-inner").removeClass("waiting-for-update");
|
||||||
|
$("#progress-text").text(PROGRESS_MAX + "s to next update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last updated timestamp
|
||||||
|
function updateLastUpdated() {
|
||||||
|
if (!workerData || !workerData.timestamp) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = new Date(workerData.timestamp);
|
||||||
|
$("#lastUpdated").html("<strong>Last Updated:</strong> " +
|
||||||
|
timestamp.toLocaleString() + "<span id='terminal-cursor'></span>");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error formatting timestamp:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format numbers with commas
|
||||||
|
function numberWithCommas(x) {
|
||||||
|
if (x == null) return "N/A";
|
||||||
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user