Version 1.2.7: Fix API client health monitoring and add betting mode configuration

- Fixed 'Unhealthy components detected: api_client' warning by implementing comprehensive heartbeat system
- Added heartbeat calls in main loop, HTTP requests, database operations, and error handling
- Added betting mode configuration UI in web dashboard with database persistence
- Created Migration_015_AddBettingModeTable for betting_modes table
- Added betting mode API endpoints (GET/POST /api/betting-mode)
- Updated documentation with betting mode configuration and API client health monitoring
- Enhanced error handling to prevent thread hangs and maintain health status
- Added troubleshooting section for API client health issues
parent 4ddf7439
...@@ -2,6 +2,34 @@ ...@@ -2,6 +2,34 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.2.7] - 2025-08-26
### Added
- **Betting Mode Configuration**: Complete betting mode management system with web dashboard integration
- **Database Migration System**: Migration_015_AddBettingModeTable for betting_modes table with proper schema
- **Betting Mode API**: RESTful endpoints for getting and setting user betting modes
- **Configuration UI Enhancement**: Added betting mode settings section to configuration page
### Fixed
- **API Client Health Monitoring**: Resolved "Unhealthy components detected: ['api_client']" warning
- **Heartbeat System**: Implemented comprehensive heartbeat updates throughout API client operations
- **Thread Health Checks**: Enhanced error handling to prevent thread hangs and maintain health status
- **Database Operation Protection**: Added heartbeat calls during potentially slow database operations
- **HTTP Request Protection**: Added heartbeat calls before and after HTTP requests to prevent timeouts
### Enhanced
- **Error Handling**: Improved API client error handling with heartbeat updates even during failures
- **Logging**: Added debug logging for heartbeat updates during error handling
- **Performance**: Optimized heartbeat system to minimize overhead while ensuring health monitoring
- **Configuration Persistence**: Enhanced betting mode configuration with database persistence
### Technical Details
- **API Client Heartbeat**: Added heartbeat calls in main loop, HTTP requests, database operations, and error handling
- **Betting Mode Model**: Created BettingModeModel with user-specific settings and database constraints
- **Migration System**: Added Migration_015 with proper SQLite schema and indexing
- **Web Dashboard Integration**: Complete betting mode UI with real-time feedback and validation
- **Cross-Platform Compatibility**: Betting mode system works consistently across all supported platforms
## [1.2.6] - 2025-08-26 ## [1.2.6] - 2025-08-26
### Added ### Added
......
...@@ -160,6 +160,33 @@ Edit configuration through the web dashboard or modify JSON files directly: ...@@ -160,6 +160,33 @@ Edit configuration through the web dashboard or modify JSON files directly:
} }
``` ```
### Betting Mode Configuration
The application supports two betting modes that can be configured per user:
#### Betting Modes
- **All Bets on Start**: Place all bets simultaneously when games begin (default)
- **One Bet at a Time**: Place bets individually for more controlled betting
#### Configuration
Betting mode is configured through the web dashboard:
1. Navigate to Configuration → Betting Mode Settings
2. Select desired betting mode from dropdown
3. Click "Save Betting Mode"
4. Settings are saved per user and persist across sessions
#### API Configuration
```json
{
"betting_mode": {
"user_id": 1,
"mode": "all_bets_on_start",
"created_at": "2025-08-26T11:30:00Z",
"updated_at": "2025-08-26T11:30:00Z"
}
}
```
### Environment Variables ### Environment Variables
Create a `.env` file in the project root: Create a `.env` file in the project root:
...@@ -689,6 +716,43 @@ Content-Type: application/json ...@@ -689,6 +716,43 @@ Content-Type: application/json
} }
``` ```
#### Get Betting Mode
```http
GET /api/betting-mode
Authorization: Bearer <token>
```
**Response:**
```json
{
"success": true,
"betting_mode": "all_bets_on_start",
"user_id": 1
}
```
#### Update Betting Mode
```http
POST /api/betting-mode
Authorization: Bearer <token>
Content-Type: application/json
{
"betting_mode": "one_bet_at_a_time"
}
```
**Response:**
```json
{
"success": true,
"message": "Betting mode updated successfully",
"betting_mode": "one_bet_at_a_time"
}
```
## Match Timer System ## Match Timer System
The application includes a comprehensive match timer system with automatic match progression, visual countdown displays, and command-line timer configuration. The application includes a comprehensive match timer system with automatic match progression, visual countdown displays, and command-line timer configuration.
...@@ -978,6 +1042,18 @@ This message is processed by the games thread to change the match status from "b ...@@ -978,6 +1042,18 @@ This message is processed by the games thread to change the match status from "b
4. Ensure proper file permissions on database file 4. Ensure proper file permissions on database file
5. Verify SQLite installation and compatibility 5. Verify SQLite installation and compatibility
#### API Client Health Issues
**Symptoms**: "Unhealthy components detected: ['api_client']" warning in logs
**Solutions**:
1. Check API client thread is running and not blocked
2. Verify network connectivity for API endpoints
3. Check API token validity and expiration
4. Review API client logs for specific error messages
5. Ensure database operations are not causing long delays
6. Check for HTTP request timeouts or network issues
7. Verify heartbeat system is functioning (added in version 1.2.7)
#### Performance Issues #### Performance Issues
**Symptoms**: Interface slow, updates delayed **Symptoms**: Interface slow, updates delayed
......
...@@ -24,6 +24,14 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -24,6 +24,14 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements ## Recent Improvements
### Version 1.2.7 (August 2025)
-**API Client Health Monitoring**: Fixed "Unhealthy components detected: ['api_client']" warning by implementing comprehensive heartbeat system
-**Betting Mode Configuration**: Added complete betting mode management in web dashboard with database persistence
-**Database Migration System**: Added Migration_015 for betting_modes table with proper schema and constraints
-**Enhanced Error Handling**: Improved API client error handling to prevent thread hangs and maintain health status
-**Configuration UI Enhancement**: Added betting mode settings section to configuration page with real-time feedback
### Version 1.2.6 (August 2025) ### Version 1.2.6 (August 2025)
-**Automated Game Timer**: New `--start-timer` command line switch that sends START_GAME message after specified delay (default 4 minutes) -**Automated Game Timer**: New `--start-timer` command line switch that sends START_GAME message after specified delay (default 4 minutes)
...@@ -168,6 +176,9 @@ python main.py --start-timer ...@@ -168,6 +176,9 @@ python main.py --start-timer
# Start with custom timer delay (2 minutes) # Start with custom timer delay (2 minutes)
python main.py --start-timer 2 python main.py --start-timer 2
# Enable debug mode showing only message bus messages
python main.py --dev-message
# Show help # Show help
python main.py --help python main.py --help
``` ```
...@@ -192,6 +203,7 @@ Configuration is stored in SQLite database with automatic versioning. Access the ...@@ -192,6 +203,7 @@ Configuration is stored in SQLite database with automatic versioning. Access the
- System settings - System settings
- API token management - API token management
- User account management - User account management
- Betting mode configuration (all bets on start vs one bet at a time)
### Default Login ### Default Login
...@@ -270,6 +282,10 @@ Threads communicate via Python Queues with structured messages: ...@@ -270,6 +282,10 @@ Threads communicate via Python Queues with structured messages:
- `PUT /api/config/{section}` - Update configuration section - `PUT /api/config/{section}` - Update configuration section
- `GET /api/config/{section}` - Get specific configuration section - `GET /api/config/{section}` - Get specific configuration section
#### Betting Mode Management
- `GET /api/betting-mode` - Get current betting mode for user
- `POST /api/betting-mode` - Update betting mode for user
#### Template Management #### Template Management
- `GET /api/templates` - List available overlay templates - `GET /api/templates` - List available overlay templates
- `POST /api/templates/upload` - Upload new template file - `POST /api/templates/upload` - Upload new template file
...@@ -348,6 +364,11 @@ Threads communicate via Python Queues with structured messages: ...@@ -348,6 +364,11 @@ Threads communicate via Python Queues with structured messages:
**Database errors during operation** **Database errors during operation**
- Fixed in version 1.1 - all database operations now properly handle session closure - Fixed in version 1.1 - all database operations now properly handle session closure
**API client showing as unhealthy**
- Fixed in version 1.2.7 - enhanced heartbeat system prevents "Unhealthy components detected: ['api_client']" warnings
- API client now maintains regular heartbeat updates even during long-running operations
- Improved error handling prevents thread hangs that could cause health check failures
### Building Issues ### Building Issues
**PyInstaller build fails with missing modules** **PyInstaller build fails with missing modules**
......
...@@ -475,7 +475,7 @@ The application stores its configuration and database in: ...@@ -475,7 +475,7 @@ The application stores its configuration and database in:
## Web Interface ## Web Interface
By default, the web interface is available at: http://localhost:5000 By default, the web interface is available at: http://localhost:5001
Default login credentials: Default login credentials:
- Username: admin - Username: admin
......
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 0 session .eJwlzj0OwjAMQOG7ZGaof2LHvUyVxLZgTemEuDuV2N-Tvk85csX5LPt7XfEox8vLXoIZkY2FESapD3Yga6kIHNw6JUnidGftW6ZynVul6BGm5lBTEkzlrprNmB6OTdQbior3SjrqmDxti_tRkQ5JqTCEOKyjlBtynbH-GizfH6DyL20.aKzyUQ.JbC1Zst6dLOPb6oWBcnMziVCKFk
#HttpOnly_127.0.0.1 FALSE / FALSE 1787700689 remember_token 2|6839ea0e127e93742c1d52ba7c14f92c9b5b4e4a2fe97bb74c5b862f79ef1ce97a0297989c74f951dd7eba7340fd23ec4cb5e25a37c3dc28d53f13921f5f0776
#!/usr/bin/env python3
"""
Script to create test matches for the cashier interface
"""
import sys
from pathlib import Path
from datetime import datetime, date, timedelta
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.database.models import MatchModel
from mbetterclient.database.manager import DatabaseManager
from mbetterclient.config.settings import get_user_data_dir
def create_test_matches():
"""Create some test matches for the cashier interface"""
# Use the default database path
db_path = get_user_data_dir() / "mbetterclient.db"
db_manager = DatabaseManager(str(db_path))
if not db_manager.initialize():
print("Failed to initialize database")
return False
session = db_manager.get_session()
try:
# Check if matches already exist
existing_count = session.query(MatchModel).count()
if existing_count > 0:
print(f"Database already has {existing_count} matches. Skipping creation.")
return True
# Create test matches for today
today = date.today()
now = datetime.now()
# Create matches for fixture 1 (today's matches)
fixture_1_matches = [
MatchModel(
fixture_id=1,
match_number=1,
fighter1_township="Kampala Central",
fighter2_township="Nakawa",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=10),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=2,
fighter1_township="Makindye",
fighter2_township="Rubaga",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=11),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=3,
fighter1_township="Kawempe",
fighter2_township="Wandegeya",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=14),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=4,
fighter1_township="Ntinda",
fighter2_township="Kololo",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=15),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=5,
fighter1_township="Lubaga",
fighter2_township="Mengo",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=16),
status="pending",
created_at=now - timedelta(days=1)
)
]
# Create matches for fixture 2 (tomorrow's matches)
tomorrow = today + timedelta(days=1)
fixture_2_matches = [
MatchModel(
fixture_id=2,
match_number=1,
fighter1_township="Entebbe",
fighter2_township="Mukono",
venue_kampala_township="Main Arena",
start_time=datetime.combine(tomorrow, datetime.min.time()) + timedelta(hours=10),
status="scheduled",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=2,
match_number=2,
fighter1_township="Jinja",
fighter2_township="Iganga",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(tomorrow, datetime.min.time()) + timedelta(hours=11),
status="scheduled",
created_at=now - timedelta(days=1)
)
]
# Add all matches to the session
all_matches = fixture_1_matches + fixture_2_matches
for match in all_matches:
session.add(match)
# Commit the changes
session.commit()
print(f"Successfully created {len(all_matches)} test matches:")
print(f" - Fixture 1: {len(fixture_1_matches)} matches (today)")
print(f" - Fixture 2: {len(fixture_2_matches)} matches (tomorrow)")
# Verify the matches were created
total_count = session.query(MatchModel).count()
print(f"Total matches in database: {total_count}")
return True
except Exception as e:
print(f"Error creating test matches: {e}")
session.rollback()
return False
finally:
session.close()
db_manager.close()
if __name__ == "__main__":
print("Creating test matches for cashier interface...")
success = create_test_matches()
if success:
print("\n✅ Test matches created successfully!")
print("The cashier interface should now display pending matches.")
else:
print("\n❌ Failed to create test matches.")
sys.exit(1)
\ No newline at end of file
import re
with open('mbetterclient/web_dashboard/templates/dashboard/fixtures.html', 'r') as f:
content = f.read()
# Extract JavaScript
js_matches = re.findall(r'<script[^>]*>(.*?)</script>', content, re.DOTALL)
if js_matches:
js_code = js_matches[0]
# Write to a temporary file to examine
with open('temp_js.js', 'w') as f:
f.write(js_code)
print("JavaScript extracted and saved to temp_js.js")
print("First 200 characters:")
print(js_code[:200])
print("\nLast 200 characters:")
print(js_code[-200:])
else:
print("No JavaScript found")
\ No newline at end of file
...@@ -41,7 +41,7 @@ def setup_signal_handlers(app): ...@@ -41,7 +41,7 @@ def setup_signal_handlers(app):
except ImportError: except ImportError:
pass # Qt not available pass # Qt not available
except Exception as e: except Exception as e:
logging.debug(f"Qt shutdown check failed: {e}") logging.debug("Qt shutdown check failed: {}".format(e))
# Fallback to normal app shutdown if Qt not running # Fallback to normal app shutdown if Qt not running
if app: if app:
...@@ -74,6 +74,7 @@ Examples: ...@@ -74,6 +74,7 @@ Examples:
python main.py --no-fullscreen # Run in windowed mode python main.py --no-fullscreen # Run in windowed mode
python main.py --web-port 8080 # Custom web dashboard port python main.py --web-port 8080 # Custom web dashboard port
python main.py --debug # Enable debug logging python main.py --debug # Enable debug logging
python main.py --dev-message # Show only message bus messages
""" """
) )
...@@ -126,6 +127,12 @@ Examples: ...@@ -126,6 +127,12 @@ Examples:
action='store_true', action='store_true',
help='Enable development mode with additional debugging' help='Enable development mode with additional debugging'
) )
parser.add_argument(
'--dev-message',
action='store_true',
help='Enable debug mode showing only message bus messages'
)
parser.add_argument( parser.add_argument(
'--no-qt', '--no-qt',
...@@ -214,7 +221,7 @@ def main(): ...@@ -214,7 +221,7 @@ def main():
logger.info("=" * 60) logger.info("=" * 60)
logger.info("MbetterClient Starting") logger.info("MbetterClient Starting")
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"Arguments: {vars(args)}") logger.info("Arguments: {}".format(vars(args)))
# Create application settings # Create application settings
settings = AppSettings() settings = AppSettings()
...@@ -222,6 +229,7 @@ def main(): ...@@ -222,6 +229,7 @@ def main():
settings.web_host = args.web_host settings.web_host = args.web_host
settings.web_port = args.web_port settings.web_port = args.web_port
settings.debug_mode = args.debug or args.dev_mode settings.debug_mode = args.debug or args.dev_mode
settings.dev_message = args.dev_message
settings.enable_qt = not args.no_qt settings.enable_qt = not args.no_qt
settings.enable_web = not args.no_web settings.enable_web = not args.no_web
settings.qt.use_native_overlay = args.native_overlay settings.qt.use_native_overlay = args.native_overlay
...@@ -248,7 +256,7 @@ def main(): ...@@ -248,7 +256,7 @@ def main():
settings.database_path = args.db_path settings.database_path = args.db_path
# Create and initialize application # Create and initialize application
app = MbetterClientApplication(settings) app = MbetterClientApplication(settings, start_timer=args.start_timer)
# Setup signal handlers for graceful shutdown # Setup signal handlers for graceful shutdown
setup_signal_handlers(app) setup_signal_handlers(app)
...@@ -270,7 +278,7 @@ def main(): ...@@ -270,7 +278,7 @@ def main():
print("\nInterrupted by user") print("\nInterrupted by user")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"Fatal error: {e}") print("Fatal error: {}".format(e))
if args.debug if 'args' in locals() else False: if args.debug if 'args' in locals() else False:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
......
...@@ -241,6 +241,10 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -241,6 +241,10 @@ class UpdatesResponseHandler(ResponseHandler):
matches = fixture_data.get('matches', []) matches = fixture_data.get('matches', [])
for match_data in matches: for match_data in matches:
try: try:
# Update heartbeat during processing to prevent health check failures
if self.api_client:
self.api_client.heartbeat()
# Add fixture-level data to match if available # Add fixture-level data to match if available
if 'fixture_id' in fixture_data: if 'fixture_id' in fixture_data:
match_data['fixture_id'] = fixture_data['fixture_id'] match_data['fixture_id'] = fixture_data['fixture_id']
...@@ -264,6 +268,10 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -264,6 +268,10 @@ class UpdatesResponseHandler(ResponseHandler):
# Continue processing other matches even if this one fails # Continue processing other matches even if this one fails
continue continue
# Update heartbeat before potentially slow commit operation
if self.api_client:
self.api_client.heartbeat()
session.commit() session.commit()
finally: finally:
...@@ -381,9 +389,16 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -381,9 +389,16 @@ class UpdatesResponseHandler(ResponseHandler):
# Save to persistent storage # Save to persistent storage
zip_path = self.zip_storage_dir / zip_filename zip_path = self.zip_storage_dir / zip_filename
# Update heartbeat before potentially slow file write
if self.api_client:
self.api_client.heartbeat()
with open(zip_path, 'wb') as f: with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
# Update heartbeat every few chunks for large files
if self.api_client and f.tell() % (1024 * 1024) == 0: # Every MB
self.api_client.heartbeat()
logger.info(f"Downloaded ZIP file: {zip_filename}") logger.info(f"Downloaded ZIP file: {zip_filename}")
return True return True
...@@ -618,21 +633,29 @@ class APIClient(ThreadedComponent): ...@@ -618,21 +633,29 @@ class APIClient(ThreadedComponent):
# Main execution loop # Main execution loop
while self.running: while self.running:
try: try:
# Update heartbeat at the start of each loop
self.heartbeat()
# Process messages # Process messages
message = self.message_bus.get_message(self.name, timeout=1.0) message = self.message_bus.get_message(self.name, timeout=1.0)
if message: if message:
self._process_message(message) self._process_message(message)
# Update heartbeat before potentially long operations
self.heartbeat()
# Execute scheduled API requests # Execute scheduled API requests
self._execute_scheduled_requests() self._execute_scheduled_requests()
# Update heartbeat # Update heartbeat after operations
self.heartbeat() self.heartbeat()
time.sleep(1.0) time.sleep(1.0)
except Exception as e: except Exception as e:
logger.error(f"APIClient run loop error: {e}") logger.error(f"APIClient run loop error: {e}")
# Update heartbeat even in error cases
self.heartbeat()
time.sleep(5.0) time.sleep(5.0)
except Exception as e: except Exception as e:
...@@ -649,6 +672,9 @@ class APIClient(ThreadedComponent): ...@@ -649,6 +672,9 @@ class APIClient(ThreadedComponent):
def _get_last_fixture_timestamp(self) -> Optional[str]: def _get_last_fixture_timestamp(self) -> Optional[str]:
"""Get the server activation timestamp of the last active fixture in the database""" """Get the server activation timestamp of the last active fixture in the database"""
try: try:
# Update heartbeat before database operation
self.heartbeat()
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get the most recent match with fixture_active_time set # Get the most recent match with fixture_active_time set
...@@ -656,6 +682,9 @@ class APIClient(ThreadedComponent): ...@@ -656,6 +682,9 @@ class APIClient(ThreadedComponent):
MatchModel.fixture_active_time.isnot(None) MatchModel.fixture_active_time.isnot(None)
).order_by(MatchModel.fixture_active_time.desc()).first() ).order_by(MatchModel.fixture_active_time.desc()).first()
# Update heartbeat after database query
self.heartbeat()
if last_active_match and last_active_match.fixture_active_time: if last_active_match and last_active_match.fixture_active_time:
# Return Unix timestamp as string (long integer number) # Return Unix timestamp as string (long integer number)
return str(last_active_match.fixture_active_time) return str(last_active_match.fixture_active_time)
...@@ -668,11 +697,16 @@ class APIClient(ThreadedComponent): ...@@ -668,11 +697,16 @@ class APIClient(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Failed to get last fixture activation timestamp: {e}") logger.error(f"Failed to get last fixture activation timestamp: {e}")
# Update heartbeat even on error
self.heartbeat()
return None return None
def _execute_endpoint_request(self, endpoint: APIEndpoint): def _execute_endpoint_request(self, endpoint: APIEndpoint):
"""Execute a single API request with custom retry logic for token-based endpoints""" """Execute a single API request with custom retry logic for token-based endpoints"""
try: try:
# Update heartbeat before starting potentially long operation
self.heartbeat()
endpoint.last_request = datetime.utcnow() endpoint.last_request = datetime.utcnow()
endpoint.total_requests += 1 endpoint.total_requests += 1
self.stats['total_requests'] += 1 self.stats['total_requests'] += 1
...@@ -746,6 +780,9 @@ class APIClient(ThreadedComponent): ...@@ -746,6 +780,9 @@ class APIClient(ThreadedComponent):
response = self.session.request(**request_kwargs) response = self.session.request(**request_kwargs)
response.raise_for_status() response.raise_for_status()
# Update heartbeat after HTTP request completes
self.heartbeat()
# Debug log the complete response # Debug log the complete response
logger.debug(f"Response status code: {response.status_code}") logger.debug(f"Response status code: {response.status_code}")
logger.debug(f"Response headers: {dict(response.headers)}") logger.debug(f"Response headers: {dict(response.headers)}")
...@@ -762,6 +799,9 @@ class APIClient(ThreadedComponent): ...@@ -762,6 +799,9 @@ class APIClient(ThreadedComponent):
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default']) handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response) processed_data = handler.handle_response(endpoint, response)
# Update heartbeat after response processing
self.heartbeat()
# Update endpoint status # Update endpoint status
endpoint.last_success = datetime.utcnow() endpoint.last_success = datetime.utcnow()
endpoint.last_error = None endpoint.last_error = None
...@@ -787,6 +827,7 @@ class APIClient(ThreadedComponent): ...@@ -787,6 +827,7 @@ class APIClient(ThreadedComponent):
except Exception as e: except Exception as e:
# Handle request failure # Handle request failure
self.heartbeat() # Update heartbeat even on failure
self._handle_request_failure(endpoint, e) self._handle_request_failure(endpoint, e)
def _execute_with_custom_retry(self, endpoint: APIEndpoint, request_kwargs: dict) -> bool: def _execute_with_custom_retry(self, endpoint: APIEndpoint, request_kwargs: dict) -> bool:
...@@ -801,6 +842,9 @@ class APIClient(ThreadedComponent): ...@@ -801,6 +842,9 @@ class APIClient(ThreadedComponent):
response = self.session.request(**request_kwargs) response = self.session.request(**request_kwargs)
response.raise_for_status() response.raise_for_status()
# Update heartbeat after HTTP retry request completes
self.heartbeat()
# Debug log the complete response (retry scenario) # Debug log the complete response (retry scenario)
logger.debug(f"Retry response status code: {response.status_code}") logger.debug(f"Retry response status code: {response.status_code}")
logger.debug(f"Retry response headers: {dict(response.headers)}") logger.debug(f"Retry response headers: {dict(response.headers)}")
...@@ -817,6 +861,9 @@ class APIClient(ThreadedComponent): ...@@ -817,6 +861,9 @@ class APIClient(ThreadedComponent):
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default']) handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response) processed_data = handler.handle_response(endpoint, response)
# Update heartbeat after response processing
self.heartbeat()
# Update endpoint status - success! # Update endpoint status - success!
endpoint.last_success = datetime.utcnow() endpoint.last_success = datetime.utcnow()
endpoint.last_error = None endpoint.last_error = None
...@@ -849,7 +896,13 @@ class APIClient(ThreadedComponent): ...@@ -849,7 +896,13 @@ class APIClient(ThreadedComponent):
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning(f"API retry {attempt + 1} failed for {endpoint.name}: {e}. Waiting {retry_delay}s before next retry.") logger.warning(f"API retry {attempt + 1} failed for {endpoint.name}: {e}. Waiting {retry_delay}s before next retry.")
time.sleep(retry_delay) # Sleep in smaller chunks to allow heartbeat updates during long delays
remaining_delay = retry_delay
while remaining_delay > 0 and self.running:
sleep_chunk = min(5.0, remaining_delay) # Sleep in 5-second chunks
time.sleep(sleep_chunk)
self.heartbeat() # Update heartbeat during retry delays
remaining_delay -= sleep_chunk
else: else:
logger.error(f"All {max_retries} retries failed for {endpoint.name}: {e}") logger.error(f"All {max_retries} retries failed for {endpoint.name}: {e}")
...@@ -859,12 +912,18 @@ class APIClient(ThreadedComponent): ...@@ -859,12 +912,18 @@ class APIClient(ThreadedComponent):
def _handle_request_failure(self, endpoint: APIEndpoint, error: Exception): def _handle_request_failure(self, endpoint: APIEndpoint, error: Exception):
"""Handle request failure and send error message""" """Handle request failure and send error message"""
# Update heartbeat when handling failures
self.heartbeat()
endpoint.last_error = str(error) endpoint.last_error = str(error)
endpoint.consecutive_failures += 1 endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1 self.stats['failed_requests'] += 1
logger.error(f"API request failed: {endpoint.name} - {error}") logger.error(f"API request failed: {endpoint.name} - {error}")
# Log heartbeat status for debugging
logger.debug(f"API client heartbeat updated during error handling for {endpoint.name}")
# Send error message # Send error message
error_message = Message( error_message = Message(
type=MessageType.API_RESPONSE, type=MessageType.API_RESPONSE,
......
...@@ -22,10 +22,13 @@ logger = logging.getLogger(__name__) ...@@ -22,10 +22,13 @@ logger = logging.getLogger(__name__)
class MbetterClientApplication: class MbetterClientApplication:
"""Main application class that coordinates all components""" """Main application class that coordinates all components"""
def __init__(self, settings: AppSettings): def __init__(self, settings: AppSettings, start_timer: Optional[int] = None):
self.settings = settings self.settings = settings
self.running = False self.running = False
self.shutdown_event = threading.Event() self.shutdown_event = threading.Event()
# Store the command line start_timer setting separately (not affected by database override)
self._start_timer_minutes = start_timer
# Core components # Core components
self.db_manager: Optional[DatabaseManager] = None self.db_manager: Optional[DatabaseManager] = None
...@@ -146,7 +149,7 @@ class MbetterClientApplication: ...@@ -146,7 +149,7 @@ class MbetterClientApplication:
def _initialize_message_bus(self) -> bool: def _initialize_message_bus(self) -> bool:
"""Initialize message bus""" """Initialize message bus"""
try: try:
self.message_bus = MessageBus(max_queue_size=1000) self.message_bus = MessageBus(max_queue_size=1000, dev_message=self.settings.dev_message)
# Register core component # Register core component
self.message_bus.register_component("core") self.message_bus.register_component("core")
...@@ -155,7 +158,6 @@ class MbetterClientApplication: ...@@ -155,7 +158,6 @@ class MbetterClientApplication:
self.message_bus.subscribe("core", MessageType.SYSTEM_SHUTDOWN, self._handle_shutdown_message) self.message_bus.subscribe("core", MessageType.SYSTEM_SHUTDOWN, self._handle_shutdown_message)
self.message_bus.subscribe("core", MessageType.CONFIG_UPDATE, self._handle_config_update) self.message_bus.subscribe("core", MessageType.CONFIG_UPDATE, self._handle_config_update)
self.message_bus.subscribe("core", MessageType.LOG_ENTRY, self._handle_log_entry) self.message_bus.subscribe("core", MessageType.LOG_ENTRY, self._handle_log_entry)
self.message_bus.subscribe("core", MessageType.START_GAME, self._handle_start_game_message)
logger.info("Message bus initialized") logger.info("Message bus initialized")
return True return True
...@@ -471,22 +473,29 @@ class MbetterClientApplication: ...@@ -471,22 +473,29 @@ class MbetterClientApplication:
) )
self.message_bus.publish(ready_message, broadcast=True) self.message_bus.publish(ready_message, broadcast=True)
# Start automated game timer if specified on command line
if self._start_timer_minutes is not None:
logger.info(f"Command line timer enabled, starting {self._start_timer_minutes} minute timer")
self._start_game_timer()
logger.info("MbetterClient application started successfully") logger.info("MbetterClient application started successfully")
# If Qt player is enabled, run its event loop on the main thread # If Qt player is enabled, run its event loop on the main thread
if qt_player_initialized: if qt_player_initialized:
logger.info("Running Qt player event loop on main thread") logger.info("Running Qt player event loop on main thread")
# Setup Qt-specific signal handling since Qt takes over the main thread # Setup Qt-specific signal handling since Qt takes over the main thread
if hasattr(self.qt_player, 'app') and self.qt_player.app: if hasattr(self.qt_player, 'app') and self.qt_player.app:
# Connect Qt's aboutToQuit signal to our shutdown # Connect Qt's aboutToQuit signal to our shutdown
self.qt_player.app.aboutToQuit.connect(self._qt_about_to_quit) self.qt_player.app.aboutToQuit.connect(self._qt_about_to_quit)
# Ensure Qt exits when last window closes # Ensure Qt exits when last window closes
self.qt_player.app.setQuitOnLastWindowClosed(True) self.qt_player.app.setQuitOnLastWindowClosed(True)
return self.qt_player.run() return self.qt_player.run()
else: else:
# Wait for shutdown with a timeout to prevent indefinite hanging # No UI components - keep application running in background mode
logger.info("No UI components enabled - running in background mode")
# Wait for shutdown signal (Ctrl+C, etc.)
while self.running and not self.shutdown_event.is_set(): while self.running and not self.shutdown_event.is_set():
self.shutdown_event.wait(timeout=1.0) self.shutdown_event.wait(timeout=1.0)
...@@ -544,18 +553,20 @@ class MbetterClientApplication: ...@@ -544,18 +553,20 @@ class MbetterClientApplication:
"""Process messages received by the core component""" """Process messages received by the core component"""
try: try:
logger.debug(f"Core processing message: {message}") logger.debug(f"Core processing message: {message}")
if message.type == MessageType.SYSTEM_STATUS: if message.type == MessageType.SYSTEM_STATUS:
self._handle_system_status(message) self._handle_system_status(message)
elif message.type == MessageType.SYSTEM_ERROR: elif message.type == MessageType.SYSTEM_ERROR:
self._handle_system_error(message) self._handle_system_error(message)
elif message.type == MessageType.CONFIG_REQUEST: elif message.type == MessageType.CONFIG_REQUEST:
self._handle_config_request(message) self._handle_config_request(message)
elif message.type == MessageType.START_GAME:
self._handle_start_game_message(message)
elif message.type == MessageType.SYSTEM_SHUTDOWN: elif message.type == MessageType.SYSTEM_SHUTDOWN:
self._handle_shutdown_message(message) self._handle_shutdown_message(message)
else: else:
logger.debug(f"Unhandled message type in core: {message.type}") logger.debug(f"Unhandled message type in core: {message.type}")
except Exception as e: except Exception as e:
logger.error(f"Failed to process core message: {e}") logger.error(f"Failed to process core message: {e}")
...@@ -687,21 +698,13 @@ class MbetterClientApplication: ...@@ -687,21 +698,13 @@ class MbetterClientApplication:
logger.error(f"Failed to handle log entry: {e}") logger.error(f"Failed to handle log entry: {e}")
def _handle_start_game_message(self, message: Message): def _handle_start_game_message(self, message: Message):
"""Handle START_GAME message by starting the timer""" """Handle START_GAME message - only cancel the start-timer as its job is done"""
try: try:
if not self.settings.timer.enabled: # The core should only cancel its start-timer when START_GAME is received
logger.debug("Timer not enabled, ignoring START_GAME message") # The actual START_GAME processing is done by games_thread
return logger.info("START_GAME message received - cancelling command line start-timer as it has completed its job")
# Cancel any existing timer
self._cancel_game_timer() self._cancel_game_timer()
# Start new timer
delay_seconds = self.settings.timer.delay_minutes * 60
logger.info(f"START_GAME message received from {message.sender}, starting timer for {self.settings.timer.delay_minutes} minutes ({delay_seconds} seconds)")
self._game_start_timer()
except Exception as e: except Exception as e:
logger.error(f"Failed to handle START_GAME message: {e}") logger.error(f"Failed to handle START_GAME message: {e}")
...@@ -713,11 +716,11 @@ class MbetterClientApplication: ...@@ -713,11 +716,11 @@ class MbetterClientApplication:
def _start_game_timer(self): def _start_game_timer(self):
"""Start the timer for automated game start""" """Start the timer for automated game start"""
if not self.settings.timer.enabled: if self._start_timer_minutes is None:
return return
delay_seconds = self.settings.timer.delay_minutes * 60 delay_seconds = self._start_timer_minutes * 60
logger.info(f"Starting game timer: {self.settings.timer.delay_minutes} minutes ({delay_seconds} seconds)") logger.info(f"Starting command line game timer: {self._start_timer_minutes} minutes ({delay_seconds} seconds)")
self._game_start_timer = threading.Timer(delay_seconds, self._on_game_timer_expired) self._game_start_timer = threading.Timer(delay_seconds, self._on_game_timer_expired)
self._game_start_timer.daemon = True self._game_start_timer.daemon = True
...@@ -725,19 +728,19 @@ class MbetterClientApplication: ...@@ -725,19 +728,19 @@ class MbetterClientApplication:
def _on_game_timer_expired(self): def _on_game_timer_expired(self):
"""Called when the game start timer expires""" """Called when the game start timer expires"""
logger.info("Game start timer expired, sending START_GAME_DELAYED message") logger.info("Game start timer expired, sending START_GAME message")
try: try:
# Create and send START_GAME_DELAYED message # Create and send START_GAME message
start_game_delayed_message = MessageBuilder.start_game_delayed( start_game_message = MessageBuilder.start_game(
sender="timer", sender="timer",
delay_minutes=self.settings.timer.delay_minutes fixture_id=None # Let the games thread find the best fixture
) )
self.message_bus.publish(start_game_delayed_message, broadcast=True) self.message_bus.publish(start_game_message, broadcast=True)
logger.info("START_GAME_DELAYED message sent successfully") logger.info("START_GAME message sent successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to send START_GAME_DELAYED message: {e}") logger.error(f"Failed to send START_GAME message: {e}")
def _cancel_game_timer(self): def _cancel_game_timer(self):
"""Cancel the game start timer if it's running""" """Cancel the game start timer if it's running"""
......
This diff is collapsed.
...@@ -61,6 +61,11 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -61,6 +61,11 @@ class MatchTimerComponent(ThreadedComponent):
while self.running: while self.running:
try: try:
# Process any pending messages first
message = self.message_bus.get_message(self.name, timeout=0.1)
if message:
self._process_message(message)
current_time = time.time() current_time = time.time()
# Check if timer needs to be updated # Check if timer needs to be updated
...@@ -72,8 +77,10 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -72,8 +77,10 @@ class MatchTimerComponent(ThreadedComponent):
# Timer reached zero, start next match # Timer reached zero, start next match
self._on_timer_expired() self._on_timer_expired()
else: else:
# Update last activity # Send periodic timer updates (every 1 second)
self._last_update = current_time if current_time - self._last_update >= 1.0:
self._send_timer_update()
self._last_update = current_time
# Check if we should stop the timer (no more matches) # Check if we should stop the timer (no more matches)
if self.timer_running and self._should_stop_timer(): if self.timer_running and self._should_stop_timer():
...@@ -92,6 +99,22 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -92,6 +99,22 @@ class MatchTimerComponent(ThreadedComponent):
logger.info("MatchTimer component stopped") logger.info("MatchTimer component stopped")
def _process_message(self, message):
"""Process incoming messages directly"""
try:
logger.debug(f"MatchTimer processing message: {message}")
# Handle messages directly since some messages don't trigger subscription handlers
if message.type == MessageType.START_GAME:
self._handle_start_game(message)
elif message.type == MessageType.SCHEDULE_GAMES:
self._handle_schedule_games(message)
elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message)
except Exception as e:
logger.error(f"Failed to process message: {e}")
def shutdown(self): def shutdown(self):
"""Shutdown the match timer""" """Shutdown the match timer"""
with self._timer_lock: with self._timer_lock:
......
...@@ -133,9 +133,10 @@ class Message: ...@@ -133,9 +133,10 @@ class Message:
class MessageBus: class MessageBus:
"""Central message bus for inter-thread communication""" """Central message bus for inter-thread communication"""
def __init__(self, max_queue_size: int = 1000): def __init__(self, max_queue_size: int = 1000, dev_message: bool = False):
self.max_queue_size = max_queue_size self.max_queue_size = max_queue_size
self.dev_message = dev_message
self._queues: Dict[str, Queue] = {} self._queues: Dict[str, Queue] = {}
self._handlers: Dict[str, Dict[MessageType, List[Callable]]] = {} self._handlers: Dict[str, Dict[MessageType, List[Callable]]] = {}
self._global_handlers: Dict[MessageType, List[Callable]] = {} self._global_handlers: Dict[MessageType, List[Callable]] = {}
...@@ -143,8 +144,11 @@ class MessageBus: ...@@ -143,8 +144,11 @@ class MessageBus:
self._lock = threading.RLock() self._lock = threading.RLock()
self._message_history: List[Message] = [] self._message_history: List[Message] = []
self._max_history = 1000 self._max_history = 1000
logger.info("MessageBus initialized") if dev_message:
logger.info("MessageBus initialized with dev_message mode enabled")
else:
logger.info("MessageBus initialized")
def register_component(self, component_name: str) -> Queue: def register_component(self, component_name: str) -> Queue:
"""Register a component and get its message queue""" """Register a component and get its message queue"""
...@@ -200,8 +204,9 @@ class MessageBus: ...@@ -200,8 +204,9 @@ class MessageBus:
# Add to message history # Add to message history
self._add_to_history(message) self._add_to_history(message)
# Log the message # Log the message (only in dev_message mode)
logger.debug(f"Publishing message: {message}") if self.dev_message:
logger.info(f"📨 MESSAGE_BUS: {message}")
if broadcast or message.recipient is None: if broadcast or message.recipient is None:
# Broadcast to all components # Broadcast to all components
......
...@@ -982,6 +982,55 @@ class Migration_014_AddExtractionAndGameConfigTables(DatabaseMigration): ...@@ -982,6 +982,55 @@ class Migration_014_AddExtractionAndGameConfigTables(DatabaseMigration):
return False return False
class Migration_015_AddBettingModeTable(DatabaseMigration):
"""Add betting_modes table for user betting preferences"""
def __init__(self):
super().__init__("015", "Add betting_modes table for user betting preferences")
def up(self, db_manager) -> bool:
"""Create betting_modes table"""
try:
with db_manager.engine.connect() as conn:
# Create betting_modes table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS betting_modes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
mode VARCHAR(50) NOT NULL DEFAULT 'all_bets_on_start',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""))
# Create indexes for betting_modes table
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_betting_modes_user_id ON betting_modes(user_id)
"""))
conn.commit()
logger.info("Betting modes table created successfully")
return True
except Exception as e:
logger.error(f"Failed to create betting modes table: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop betting_modes table"""
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS betting_modes"))
conn.commit()
logger.info("Betting modes table dropped")
return True
except Exception as e:
logger.error(f"Failed to drop betting modes table: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
...@@ -998,6 +1047,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -998,6 +1047,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_012_RemoveFixtureIdUniqueConstraint(), Migration_012_RemoveFixtureIdUniqueConstraint(),
Migration_013_AddStatusFieldToMatches(), Migration_013_AddStatusFieldToMatches(),
Migration_014_AddExtractionAndGameConfigTables(), Migration_014_AddExtractionAndGameConfigTables(),
Migration_015_AddBettingModeTable(),
] ]
......
...@@ -20,6 +20,7 @@ class MatchStatus(str, Enum): ...@@ -20,6 +20,7 @@ class MatchStatus(str, Enum):
SCHEDULED = "scheduled" SCHEDULED = "scheduled"
BET = "bet" BET = "bet"
INGAME = "ingame" INGAME = "ingame"
DONE = "done"
CANCELLED = "cancelled" CANCELLED = "cancelled"
FAILED = "failed" FAILED = "failed"
PAUSED = "paused" PAUSED = "paused"
...@@ -95,6 +96,7 @@ class UserModel(BaseModel): ...@@ -95,6 +96,7 @@ class UserModel(BaseModel):
# Relationships # Relationships
api_tokens = relationship('ApiTokenModel', back_populates='user', cascade='all, delete-orphan') api_tokens = relationship('ApiTokenModel', back_populates='user', cascade='all, delete-orphan')
log_entries = relationship('LogEntryModel', back_populates='user') log_entries = relationship('LogEntryModel', back_populates='user')
betting_mode = relationship('BettingModeModel', back_populates='user', cascade='all, delete-orphan', uselist=False)
def set_password(self, password: str): def set_password(self, password: str):
"""Set password hash using SHA-256 with salt (consistent with AuthManager)""" """Set password hash using SHA-256 with salt (consistent with AuthManager)"""
...@@ -482,7 +484,7 @@ class MatchModel(BaseModel): ...@@ -482,7 +484,7 @@ class MatchModel(BaseModel):
result = Column(String(255), comment='Match result/outcome') result = Column(String(255), comment='Match result/outcome')
done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)') done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)')
running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)') running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)')
status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum') status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'done', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum')
fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server') fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server')
# File metadata # File metadata
...@@ -657,4 +659,22 @@ class GameConfigModel(BaseModel): ...@@ -657,4 +659,22 @@ class GameConfigModel(BaseModel):
self.value_type = 'string' self.value_type = 'string'
def __repr__(self): def __repr__(self):
return f'<GameConfig {self.config_key}={self.config_value}>' return f'<GameConfig {self.config_key}={self.config_value}>'
\ No newline at end of file
class BettingModeModel(BaseModel):
"""Betting mode configuration"""
__tablename__ = 'betting_modes'
__table_args__ = (
Index('ix_betting_modes_user_id', 'user_id'),
UniqueConstraint('user_id', name='uq_betting_modes_user_id'),
)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True, comment='User who owns this betting mode setting')
mode = Column(String(50), nullable=False, default='all_bets_on_start', comment='Betting mode: all_bets_on_start or one_bet_at_a_time')
# Relationships
user = relationship('UserModel', back_populates='betting_mode')
def __repr__(self):
return f'<BettingMode user_id={self.user_id} mode={self.mode}>'
\ No newline at end of file
...@@ -538,14 +538,51 @@ class DashboardAPI: ...@@ -538,14 +538,51 @@ class DashboardAPI:
def _get_components_status(self) -> Dict[str, Any]: def _get_components_status(self) -> Dict[str, Any]:
"""Get status of all components""" """Get status of all components"""
# This would normally maintain a cache of component status try:
# or query components through message bus from flask import g
return {
"qt_player": "unknown", # Get main application from Flask context
"web_dashboard": "running", main_app = g.get('main_app')
"api_client": "unknown", if main_app and hasattr(main_app, 'thread_manager'):
"message_bus": "running" # Get real component status from thread manager
} components_status = {}
# Get all registered components
component_names = main_app.thread_manager.get_component_names()
for component_name in component_names:
if main_app.thread_manager.is_component_running(component_name):
if main_app.thread_manager.is_component_healthy(component_name):
components_status[component_name] = "running"
else:
components_status[component_name] = "unhealthy"
else:
components_status[component_name] = "stopped"
# Always show web dashboard as running since we're responding
components_status["web_dashboard"] = "running"
# Always show message bus as running since we can query it
components_status["message_bus"] = "running"
return components_status
else:
# Fallback to hardcoded values if main app not available
return {
"qt_player": "unknown",
"web_dashboard": "running",
"api_client": "unknown",
"message_bus": "running"
}
except Exception as e:
logger.error(f"Failed to get real component status: {e}")
# Fallback to hardcoded values on error
return {
"qt_player": "unknown",
"web_dashboard": "running",
"api_client": "unknown",
"message_bus": "running"
}
def send_test_message(self, recipient: str, message_type: str, def send_test_message(self, recipient: str, message_type: str,
data: Dict[str, Any]) -> Dict[str, Any]: data: Dict[str, Any]) -> Dict[str, Any]:
......
...@@ -298,8 +298,16 @@ class WebDashboard(ThreadedComponent): ...@@ -298,8 +298,16 @@ class WebDashboard(ThreadedComponent):
def _process_message(self, message: Message): def _process_message(self, message: Message):
"""Process received message""" """Process received message"""
try: try:
# Messages are handled by subscribed handlers logger.debug(f"WebDashboard processing message: {message}")
pass
# Handle messages directly since some messages don't trigger subscription handlers
if message.type == MessageType.CONFIG_UPDATE:
self._handle_config_update(message)
elif message.type == MessageType.SYSTEM_STATUS:
self._handle_system_status(message)
elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message)
except Exception as e: except Exception as e:
logger.error(f"Failed to process message: {e}") logger.error(f"Failed to process message: {e}")
...@@ -336,6 +344,7 @@ class WebDashboard(ThreadedComponent): ...@@ -336,6 +344,7 @@ class WebDashboard(ThreadedComponent):
"""Handle custom messages (like timer state responses)""" """Handle custom messages (like timer state responses)"""
try: try:
response = message.data.get("response") response = message.data.get("response")
timer_update = message.data.get("timer_update")
if response == "timer_state": if response == "timer_state":
# Update stored timer state # Update stored timer state
...@@ -355,6 +364,26 @@ class WebDashboard(ThreadedComponent): ...@@ -355,6 +364,26 @@ class WebDashboard(ThreadedComponent):
} }
logger.debug("Timer stopped") logger.debug("Timer stopped")
elif timer_update:
# Handle periodic timer updates from match_timer component
self.current_timer_state.update(timer_update)
logger.debug(f"Timer update received: {timer_update}")
# Broadcast timer update to connected clients via global message bus
try:
timer_update_message = Message(
type=MessageType.CUSTOM,
sender=self.name,
data={
"timer_update": timer_update,
"timestamp": time.time()
}
)
self.message_bus.publish(timer_update_message, broadcast=True)
logger.debug("Timer update broadcasted to clients")
except Exception as broadcast_e:
logger.error(f"Failed to broadcast timer update: {broadcast_e}")
except Exception as e: except Exception as e:
logger.error(f"Failed to handle custom message: {e}") logger.error(f"Failed to handle custom message: {e}")
......
...@@ -1299,12 +1299,12 @@ def get_cashier_pending_matches(): ...@@ -1299,12 +1299,12 @@ def get_cashier_pending_matches():
for fixture_row in all_fixtures: for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending # Check if all matches in this fixture are pending, scheduled, or bet
fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all() fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all()
if fixture_matches and all(match.status == 'pending' for match in fixture_matches): if fixture_matches and all(match.status in ['pending', 'scheduled', 'bet'] for match in fixture_matches):
selected_fixture_id = fixture_id selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending") logger.info(f"Selected fixture {selected_fixture_id} - all matches are in playable status (pending/scheduled/bet)")
break break
# If no fixture with all pending matches found, use the first fixture by creation date # If no fixture with all pending matches found, use the first fixture by creation date
...@@ -1663,12 +1663,12 @@ def start_next_match(): ...@@ -1663,12 +1663,12 @@ def start_next_match():
for fixture_row in all_fixtures: for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending # Check if all matches in this fixture are pending, scheduled, or bet
fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all() fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all()
if fixture_matches and all(match.status == 'pending' for match in fixture_matches): if fixture_matches and all(match.status in ['pending', 'scheduled', 'bet'] for match in fixture_matches):
selected_fixture_id = fixture_id selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending") logger.info(f"Selected fixture {selected_fixture_id} - all matches are in playable status (pending/scheduled/bet)")
break break
# If no fixture with all pending matches found, use the first fixture by creation date # If no fixture with all pending matches found, use the first fixture by creation date
...@@ -1815,21 +1815,51 @@ def notifications(): ...@@ -1815,21 +1815,51 @@ def notifications():
} }
notification_queue.append(notification_data) notification_queue.append(notification_data)
notification_received.set() notification_received.set()
elif message.type == MessageType.CUSTOM and "timer_update" in message.data:
# Handle timer updates from match_timer component
notification_data = {
"type": "TIMER_UPDATE",
"data": message.data["timer_update"],
"timestamp": message.timestamp,
"sender": message.sender
}
notification_queue.append(notification_data)
notification_received.set()
elif message.type == MessageType.CUSTOM and "fixture_status_update" in message.data:
# Handle fixture status updates from games_thread
notification_data = {
"type": "FIXTURE_STATUS_UPDATE",
"data": message.data["fixture_status_update"],
"timestamp": message.timestamp,
"sender": message.sender
}
notification_queue.append(notification_data)
notification_received.set()
# Subscribe to relevant message types # Subscribe to relevant message types
if api_bp.message_bus: if api_bp.message_bus:
api_bp.message_bus.subscribe_global(MessageType.START_GAME, message_handler) api_bp.message_bus.subscribe_global(MessageType.START_GAME, message_handler)
api_bp.message_bus.subscribe_global(MessageType.MATCH_START, message_handler) api_bp.message_bus.subscribe_global(MessageType.MATCH_START, message_handler)
api_bp.message_bus.subscribe_global(MessageType.GAME_STATUS, message_handler) api_bp.message_bus.subscribe_global(MessageType.GAME_STATUS, message_handler)
api_bp.message_bus.subscribe_global(MessageType.CUSTOM, message_handler)
# Wait for notification or timeout # Wait for notification or timeout
notification_received.wait(timeout=timeout) notification_received.wait(timeout=timeout)
# Unsubscribe from messages # Unsubscribe from messages safely
if api_bp.message_bus: if api_bp.message_bus:
api_bp.message_bus._global_handlers[MessageType.START_GAME].remove(message_handler) try:
api_bp.message_bus._global_handlers[MessageType.MATCH_START].remove(message_handler) # Use proper unsubscribe methods instead of direct removal
api_bp.message_bus._global_handlers[MessageType.GAME_STATUS].remove(message_handler) for msg_type in [MessageType.START_GAME, MessageType.MATCH_START, MessageType.GAME_STATUS, MessageType.CUSTOM]:
try:
if hasattr(api_bp.message_bus, '_global_handlers') and msg_type in api_bp.message_bus._global_handlers:
handlers = api_bp.message_bus._global_handlers[msg_type]
if message_handler in handlers:
handlers.remove(message_handler)
except (AttributeError, KeyError, ValueError) as e:
logger.debug(f"Handler cleanup warning for {msg_type}: {e}")
except Exception as e:
logger.warning(f"Error during notification cleanup: {e}")
if notification_queue: if notification_queue:
# Return the first notification received # Return the first notification received
......
...@@ -365,12 +365,20 @@ ...@@ -365,12 +365,20 @@
case 'GAME_STATUS': case 'GAME_STATUS':
handleGameStatus(data, timestamp); handleGameStatus(data, timestamp);
break; break;
case 'TIMER_UPDATE':
handleTimerUpdate(data, timestamp);
break;
case 'FIXTURE_STATUS_UPDATE':
handleFixtureStatusUpdate(data, timestamp);
break;
default: default:
console.log('Unknown notification type:', type); console.log('Unknown notification type:', type);
} }
// Show notification to user // Show notification to user (except for timer and fixture status updates)
showNotificationToast(type, data); if (type !== 'TIMER_UPDATE' && type !== 'FIXTURE_STATUS_UPDATE') {
showNotificationToast(type, data);
}
} }
function handleStartGame(data, timestamp) { function handleStartGame(data, timestamp) {
...@@ -383,6 +391,11 @@ ...@@ -383,6 +391,11 @@
matchTimerElement.className = 'badge bg-success'; matchTimerElement.className = 'badge bg-success';
} }
// Trigger timer initialization in Dashboard.js if available
if (typeof Dashboard !== 'undefined' && Dashboard.onStartGameMessage) {
Dashboard.onStartGameMessage();
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('startGame', { const event = new CustomEvent('startGame', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -400,6 +413,11 @@ ...@@ -400,6 +413,11 @@
matchTimerElement.className = 'badge bg-primary'; matchTimerElement.className = 'badge bg-primary';
} }
// Force sync with match timer component
if (typeof Dashboard !== 'undefined' && Dashboard.syncWithServerTimer) {
Dashboard.syncWithServerTimer();
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('matchStart', { const event = new CustomEvent('matchStart', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -421,6 +439,13 @@ ...@@ -421,6 +439,13 @@
systemStatusElement.className = `badge bg-${getStatusColor(status)}`; systemStatusElement.className = `badge bg-${getStatusColor(status)}`;
} }
// If this is a timer-related status, sync with timer
if (status === 'started' && data.action === 'refresh') {
if (typeof Dashboard !== 'undefined' && Dashboard.syncWithServerTimer) {
Dashboard.syncWithServerTimer();
}
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('gameStatus', { const event = new CustomEvent('gameStatus', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -428,6 +453,112 @@ ...@@ -428,6 +453,112 @@
document.dispatchEvent(event); document.dispatchEvent(event);
} }
function handleTimerUpdate(data, timestamp) {
console.log('Handling TIMER_UPDATE notification:', data);
// Update timer displays directly from server data ONLY
if (data.running !== undefined && data.remaining_seconds !== undefined) {
// Clear any existing client-side timers
if (window.clientTimerInterval) {
clearInterval(window.clientTimerInterval);
window.clientTimerInterval = null;
}
// Update all timer elements with server data
updateTimerElements(data.remaining_seconds, data.running);
// Store server data for potential interpolation
window.lastServerUpdate = {
remaining_seconds: data.remaining_seconds,
timestamp: Date.now(),
running: data.running
};
// Update timer status text
const timerStatus = document.getElementById('timer-status');
if (timerStatus) {
if (data.running) {
timerStatus.textContent = 'Timer running';
timerStatus.className = 'text-success';
} else {
timerStatus.textContent = 'Waiting for games to start...';
timerStatus.className = 'text-muted';
}
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('timerUpdate', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
}
function handleFixtureStatusUpdate(data, timestamp) {
console.log('Handling FIXTURE_STATUS_UPDATE notification:', data);
// Trigger automatic refresh if on fixtures page
const currentPage = window.location.pathname;
if (currentPage === '/fixtures' || currentPage.startsWith('/fixtures/')) {
// Check if loadFixtures function exists (fixtures page)
if (typeof loadFixtures === 'function') {
console.log('Auto-refreshing fixtures due to status update');
loadFixtures();
}
// Check if loadFixtureDetails function exists (fixture details page)
if (typeof loadFixtureDetails === 'function') {
console.log('Auto-refreshing fixture details due to status update');
loadFixtureDetails();
}
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('fixtureStatusUpdate', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
function updateTimerElements(seconds, isRunning) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const timeString = minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
console.log(`Updating timer elements: ${timeString} (${seconds}s, running: ${isRunning})`);
// Update status bar timer
const statusTimer = document.getElementById('match-timer');
if (statusTimer) {
statusTimer.textContent = timeString;
if (!isRunning) {
statusTimer.className = 'badge bg-warning text-dark';
statusTimer.style.animation = '';
} else if (seconds <= 60) { // Last minute
statusTimer.className = 'badge bg-danger text-white';
statusTimer.style.animation = 'timerPulse 0.5s infinite';
} else if (seconds <= 300) { // Last 5 minutes
statusTimer.className = 'badge bg-warning text-dark';
statusTimer.style.animation = '';
} else {
statusTimer.className = 'badge bg-success text-white';
statusTimer.style.animation = '';
}
}
// Update admin dashboard timer if present
const adminTimer = document.getElementById('admin-match-timer');
if (adminTimer) {
adminTimer.textContent = timeString;
if (isRunning) {
adminTimer.className = seconds <= 60 ? 'h3 mb-2 text-danger fw-bold' : 'h3 mb-2 text-success fw-bold';
} else {
adminTimer.className = 'h3 mb-2 text-muted';
}
}
}
function showNotificationToast(type, data) { function showNotificationToast(type, data) {
// Create and show a toast notification // Create and show a toast notification
const toastHtml = ` const toastHtml = `
......
...@@ -215,6 +215,27 @@ ...@@ -215,6 +215,27 @@
</div> </div>
</div> </div>
<!-- Betting Mode Settings -->
<div class="card mb-4">
<div class="card-header">
<h5>Betting Mode Settings</h5>
</div>
<div class="card-body">
<form id="betting-mode-config-form">
<div class="mb-3">
<label for="betting-mode" class="form-label">Betting Mode</label>
<select class="form-select" id="betting-mode">
<option value="all_bets_on_start">All Bets on Start - Place all bets when games begin</option>
<option value="one_bet_at_a_time">One Bet at a Time - Place bets individually</option>
</select>
<div class="form-text">Choose how bets are placed during games</div>
</div>
<button type="submit" class="btn btn-primary">Save Betting Mode</button>
</form>
<div id="betting-mode-status" class="mt-3"></div>
</div>
</div>
<!-- API Client Debug Section --> <!-- API Client Debug Section -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
...@@ -456,5 +477,74 @@ ...@@ -456,5 +477,74 @@
this.textContent = 'Trigger Manual Request'; this.textContent = 'Trigger Manual Request';
}); });
}); });
// Load current betting mode on page load
document.addEventListener('DOMContentLoaded', function() {
loadBettingMode();
});
// Load betting mode configuration
function loadBettingMode() {
fetch('/api/betting-mode')
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('betting-mode').value = data.betting_mode || 'all_bets_on_start';
if (data.is_default) {
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-info"><small>Using default betting mode</small></div>';
}
} else {
console.error('Failed to load betting mode:', data.error);
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-warning"><small>Could not load current betting mode</small></div>';
}
})
.catch(error => {
console.error('Error loading betting mode:', error);
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-warning"><small>Error loading betting mode</small></div>';
});
}
// Save betting mode configuration
document.getElementById('betting-mode-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const bettingMode = document.getElementById('betting-mode').value;
const statusDiv = document.getElementById('betting-mode-status');
// Clear previous status
statusDiv.innerHTML = '';
// Save betting mode
fetch('/api/betting-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
betting_mode: bettingMode
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusDiv.innerHTML = '<div class="alert alert-success">Betting mode saved successfully: ' + data.message + '</div>';
// Auto-hide success message after 3 seconds
setTimeout(() => {
statusDiv.innerHTML = '';
}, 3000);
} else {
statusDiv.innerHTML = '<div class="alert alert-danger">Failed to save betting mode: ' + data.error + '</div>';
}
})
.catch(error => {
console.error('Error saving betting mode:', error);
statusDiv.innerHTML = '<div class="alert alert-danger">Error saving betting mode: ' + error.message + '</div>';
});
});
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Test script for --dev-message functionality
"""
import sys
import os
from pathlib import Path
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.core.message_bus import MessageBus, Message, MessageType
from mbetterclient.config.settings import AppSettings
def test_dev_message_flag():
"""Test the --dev-message flag functionality"""
print("Testing --dev-message flag functionality")
print("=" * 50)
# Test 1: Normal mode (dev_message=False)
print("\nTest 1: Normal mode (dev_message=False)")
message_bus_normal = MessageBus(dev_message=False)
# Create a test message
test_message = Message(
type=MessageType.START_GAME,
sender="test_component",
data={"fixture_id": "test_123"}
)
print("Publishing message in normal mode...")
message_bus_normal.publish(test_message)
print("Normal mode test completed")
# Test 2: Dev message mode (dev_message=True)
print("\nTest 2: Dev message mode (dev_message=True)")
message_bus_dev = MessageBus(dev_message=True)
print("Publishing message in dev message mode...")
message_bus_dev.publish(test_message)
print("Dev message mode test completed")
# Test 3: Settings integration
print("\nTest 3: Settings integration")
settings = AppSettings()
settings.dev_message = True
message_bus_from_settings = MessageBus(dev_message=settings.dev_message)
print(f"MessageBus created with dev_message={message_bus_from_settings.dev_message}")
print("Settings integration test completed")
print("\nAll tests completed successfully!")
print("\nUsage:")
print(" python main.py --dev-message # Enable dev message mode")
print(" python main.py # Normal mode (default)")
if __name__ == "__main__":
test_dev_message_flag()
\ No newline at end of file
import re
with open('mbetterclient/web_dashboard/templates/dashboard/fixtures.html', 'r') as f:
content = f.read()
# Check for basic HTML structure
if '<!DOCTYPE' in content or content.strip().startswith('<'):
print('HTML template appears to have valid structure')
else:
print('HTML template may have issues')
# Check for JavaScript syntax errors
js_matches = re.findall(r'<script[^>]*>(.*?)</script>', content, re.DOTALL)
if js_matches:
print('Found ' + str(len(js_matches)) + ' script tags')
for i, js in enumerate(js_matches):
try:
compile(js, 'script_' + str(i), 'exec')
print('Script ' + str(i+1) + ': OK')
except SyntaxError as e:
print('Script ' + str(i+1) + ': Syntax error - ' + str(e))
else:
print('No script tags found')
\ No newline at end of file
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment