Add client list

parent 4f9f12bc
...@@ -571,6 +571,7 @@ def api_get_updates(): ...@@ -571,6 +571,7 @@ def api_get_updates():
user = None user = None
auth_method = None auth_method = None
api_token = None
# Try JWT authentication first (short-lived session tokens) # Try JWT authentication first (short-lived session tokens)
try: try:
...@@ -584,6 +585,7 @@ def api_get_updates(): ...@@ -584,6 +585,7 @@ def api_get_updates():
# If JWT fails, try API token authentication (long-lived tokens) # If JWT fails, try API token authentication (long-lived tokens)
try: try:
from app.auth.jwt_utils import validate_api_token, extract_token_from_request
token = extract_token_from_request() token = extract_token_from_request()
if not token: if not token:
return jsonify({ return jsonify({
...@@ -606,6 +608,12 @@ def api_get_updates(): ...@@ -606,6 +608,12 @@ def api_get_updates():
logger.info(f"API updates accessed via {auth_method} by user {user.username} (ID: {user.id})") logger.info(f"API updates accessed via {auth_method} by user {user.username} (ID: {user.id})")
# Track client activity if using API token
if api_token:
data = request.get_json() or {}
rustdesk_id = data.get('rustdesk_id')
track_client_activity(api_token, rustdesk_id)
# Get 'from' parameter (unix timestamp) - now optional # Get 'from' parameter (unix timestamp) - now optional
# Support both GET (query params) and POST (JSON body) # Support both GET (query params) and POST (JSON body)
if request.method == 'GET': if request.method == 'GET':
...@@ -792,4 +800,78 @@ def api_download_zip(match_id): ...@@ -792,4 +800,78 @@ def api_download_zip(match_id):
except Exception as e: except Exception as e:
logger.error(f"API ZIP download error: {str(e)}") logger.error(f"API ZIP download error: {str(e)}")
return jsonify({'error': 'ZIP download failed'}), 500 return jsonify({'error': 'ZIP download failed'}), 500
\ No newline at end of file
def track_client_activity(api_token, rustdesk_id=None):
"""Track client activity for online status"""
try:
if not api_token or not api_token.is_valid():
return
from app.models import ClientActivity
from flask import request
# Get client info
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
user_agent = request.headers.get('User-Agent')
# Check if client already exists
client = ClientActivity.query.filter_by(
api_token_id=api_token.id,
rustdesk_id=rustdesk_id or 'unknown'
).first()
if client:
# Update existing client
client.last_seen = datetime.utcnow()
client.ip_address = ip_address
client.user_agent = user_agent
else:
# Create new client
client = ClientActivity(
api_token_id=api_token.id,
rustdesk_id=rustdesk_id or 'unknown',
ip_address=ip_address,
user_agent=user_agent
)
db.session.add(client)
db.session.commit()
except Exception as e:
logger.error(f"Failed to track client activity: {str(e)}")
@bp.route('/track', methods=['POST'])
@csrf.exempt
def api_track_client():
"""Track client activity with rustdesk_id"""
try:
from app.auth.jwt_utils import validate_api_token, extract_token_from_request
token = extract_token_from_request()
if not token:
return jsonify({'error': 'API token required'}), 401
user, api_token = validate_api_token(token)
if not user or not user.is_active:
return jsonify({'error': 'User not found or inactive'}), 404
# Get rustdesk_id from request
data = request.get_json() or {}
rustdesk_id = data.get('rustdesk_id')
if not rustdesk_id:
return jsonify({'error': 'rustdesk_id is required'}), 400
# Track client activity
track_client_activity(api_token, rustdesk_id)
return jsonify({
'message': 'Client activity tracked successfully',
'rustdesk_id': rustdesk_id,
'last_seen': datetime.utcnow().isoformat()
}), 200
except Exception as e:
logger.error(f"API track client error: {str(e)}")
return jsonify({'error': 'Failed to track client'}), 500
\ No newline at end of file
...@@ -457,6 +457,114 @@ class Migration_007_AddDoneToStatusEnum(Migration): ...@@ -457,6 +457,114 @@ class Migration_007_AddDoneToStatusEnum(Migration):
def can_rollback(self) -> bool: def can_rollback(self) -> bool:
return True return True
class Migration_008_AddRemoteDomainSetting(Migration):
"""Add remote_domain setting to system_settings table"""
def __init__(self):
super().__init__("008", "Add remote_domain setting for client remote connections")
def up(self):
"""Add remote_domain setting"""
try:
# Check if setting already exists
from app.models import SystemSettings
existing = SystemSettings.query.filter_by(key='remote_domain').first()
if existing:
logger.info("remote_domain setting already exists, skipping creation")
return True
# Add the setting
setting = SystemSettings(
key='remote_domain',
value='townshipscombatleague.com',
value_type='string',
description='Domain for remote client connections'
)
db.session.add(setting)
db.session.commit()
logger.info("Added remote_domain setting successfully")
return True
except Exception as e:
logger.error(f"Migration 008 failed: {str(e)}")
raise
def down(self):
"""Remove remote_domain setting"""
try:
from app.models import SystemSettings
setting = SystemSettings.query.filter_by(key='remote_domain').first()
if setting:
db.session.delete(setting)
db.session.commit()
logger.info("Removed remote_domain setting")
return True
except Exception as e:
logger.error(f"Rollback of migration 008 failed: {str(e)}")
raise
def can_rollback(self) -> bool:
return True
class Migration_009_CreateClientActivityTable(Migration):
"""Create client activity table for tracking online clients"""
def __init__(self):
super().__init__("009", "Create client activity table for tracking online clients")
def up(self):
"""Create client_activity table"""
try:
# Check if table already exists
inspector = inspect(db.engine)
if 'client_activity' in inspector.get_table_names():
logger.info("client_activity table already exists, skipping creation")
return True
# Create the table using raw SQL to ensure compatibility
create_table_sql = '''
CREATE TABLE client_activity (
id INT AUTO_INCREMENT PRIMARY KEY,
api_token_id INT NOT NULL,
rustdesk_id VARCHAR(255) NOT NULL,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45),
user_agent TEXT,
INDEX idx_client_activity_api_token_id (api_token_id),
INDEX idx_client_activity_rustdesk_id (rustdesk_id),
INDEX idx_client_activity_last_seen (last_seen),
FOREIGN KEY (api_token_id) REFERENCES api_tokens(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
'''
with db.engine.connect() as conn:
conn.execute(text(create_table_sql))
conn.commit()
logger.info("Created client_activity table successfully")
return True
except Exception as e:
logger.error(f"Migration 009 failed: {str(e)}")
raise
def down(self):
"""Drop client_activity table"""
try:
with db.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS client_activity"))
conn.commit()
logger.info("Dropped client_activity table")
return True
except Exception as e:
logger.error(f"Rollback of migration 009 failed: {str(e)}")
raise
def can_rollback(self) -> bool:
return True
class MigrationManager: class MigrationManager:
"""Manages database migrations and versioning""" """Manages database migrations and versioning"""
...@@ -469,6 +577,8 @@ class MigrationManager: ...@@ -469,6 +577,8 @@ class MigrationManager:
Migration_005_AddFixtureActiveTime(), Migration_005_AddFixtureActiveTime(),
Migration_006_AddStatusColumn(), Migration_006_AddStatusColumn(),
Migration_007_AddDoneToStatusEnum(), Migration_007_AddDoneToStatusEnum(),
Migration_008_AddRemoteDomainSetting(),
Migration_009_CreateClientActivityTable(),
] ]
def ensure_version_table(self): def ensure_version_table(self):
......
...@@ -1412,4 +1412,78 @@ def download_zip(match_id): ...@@ -1412,4 +1412,78 @@ def download_zip(match_id):
except Exception as e: except Exception as e:
logger.error(f"ZIP download error: {str(e)}") logger.error(f"ZIP download error: {str(e)}")
flash('Error downloading ZIP file', 'error') flash('Error downloading ZIP file', 'error')
abort(500) abort(500)
\ No newline at end of file
@csrf.exempt
@bp.route('/clients')
@login_required
@require_active_user
def clients():
"""Clients page showing connected clients"""
try:
from app.models import ClientActivity, SystemSettings, APIToken
from datetime import datetime, timedelta
# Get remote domain setting
remote_domain = SystemSettings.get_setting('remote_domain', 'townshipscombatleague.com')
# Get clients with their associated token and user info
clients_query = db.session.query(
ClientActivity,
APIToken.name.label('token_name'),
APIToken.user_id,
APIToken.created_at.label('token_created_at')
).join(
APIToken, ClientActivity.api_token_id == APIToken.id
).filter(
APIToken.is_active == True
).order_by(
ClientActivity.last_seen.desc()
)
clients_data = []
for client_activity, token_name, user_id, token_created_at in clients_query.all():
# Get user info
from app.models import User
user = User.query.get(user_id)
# Calculate if client is online (last seen within 30 minutes)
now = datetime.utcnow()
time_diff = now - client_activity.last_seen
is_online = time_diff.total_seconds() <= 1800 # 30 minutes = 1800 seconds
# Format last seen time
last_seen_formatted = client_activity.last_seen.strftime('%Y-%m-%d %H:%M:%S')
# Calculate time ago
if time_diff.total_seconds() < 60:
last_seen_ago = f"{int(time_diff.total_seconds())} seconds ago"
elif time_diff.total_seconds() < 3600:
last_seen_ago = f"{int(time_diff.total_seconds() / 60)} minutes ago"
elif time_diff.total_seconds() < 86400:
last_seen_ago = f"{int(time_diff.total_seconds() / 3600)} hours ago"
else:
last_seen_ago = f"{int(time_diff.total_seconds() / 86400)} days ago"
clients_data.append({
'rustdesk_id': client_activity.rustdesk_id,
'token_name': token_name,
'username': user.username if user else 'Unknown',
'is_online': is_online,
'last_seen': client_activity.last_seen,
'last_seen_formatted': last_seen_formatted,
'last_seen_ago': last_seen_ago,
'ip_address': client_activity.ip_address,
'user_agent': client_activity.user_agent,
'remote_domain': remote_domain
})
# Sort: online clients first, then offline clients by last seen
clients_data.sort(key=lambda x: (not x['is_online'], x['last_seen']), reverse=True)
return render_template('main/clients.html', clients=clients_data)
except Exception as e:
logger.error(f"Clients page error: {str(e)}")
flash('Error loading clients', 'error')
return render_template('main/clients.html', clients=[])
\ No newline at end of file
...@@ -794,6 +794,7 @@ class SystemSettings(db.Model): ...@@ -794,6 +794,7 @@ class SystemSettings(db.Model):
('session_timeout_hours', 24, 'integer', 'User session timeout in hours'), ('session_timeout_hours', 24, 'integer', 'User session timeout in hours'),
('api_rate_limit_per_minute', 60, 'integer', 'API rate limit per minute per IP'), ('api_rate_limit_per_minute', 60, 'integer', 'API rate limit per minute per IP'),
('api_updates_default_count', 10, 'integer', 'Default number of fixtures returned by /api/updates when no from parameter is provided'), ('api_updates_default_count', 10, 'integer', 'Default number of fixtures returned by /api/updates when no from parameter is provided'),
('remote_domain', 'townshipscombatleague.com', 'string', 'Domain for remote client connections'),
] ]
for key, default_value, value_type, description in defaults: for key, default_value, value_type, description in defaults:
...@@ -818,4 +819,21 @@ class SystemSettings(db.Model): ...@@ -818,4 +819,21 @@ class SystemSettings(db.Model):
return SystemSettings.get_setting(self.key) return SystemSettings.get_setting(self.key)
def __repr__(self): def __repr__(self):
return f'<SystemSettings {self.key}: {self.value}>' return f'<SystemSettings {self.key}: {self.value}>'
\ No newline at end of file
class ClientActivity(db.Model):
"""Track client activity for online/offline status"""
__tablename__ = 'client_activity'
id = db.Column(db.Integer, primary_key=True)
api_token_id = db.Column(db.Integer, db.ForeignKey('api_tokens.id'), nullable=False, index=True)
rustdesk_id = db.Column(db.String(255), nullable=False, index=True)
last_seen = db.Column(db.DateTime, default=datetime.utcnow, index=True)
ip_address = db.Column(db.String(45))
user_agent = db.Column(db.Text)
# Relationships
api_token = db.relationship('APIToken', backref='client_activity', lazy='select')
def __repr__(self):
return f'<ClientActivity {self.rustdesk_id} via token {self.api_token_id}>'
\ No newline at end of file
...@@ -178,6 +178,7 @@ ...@@ -178,6 +178,7 @@
<div class="nav"> <div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a> <a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a> <a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.clients') }}">Clients</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a> <a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a> <a href="{{ url_for('main.statistics') }}">Statistics</a>
<a href="{{ url_for('main.user_tokens') }}">API Tokens</a> <a href="{{ url_for('main.user_tokens') }}">API Tokens</a>
......
{% extends "base.html" %}
{% block title %}Clients - Fixture Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-2">
<h1>Connected Clients</h1>
<div class="d-flex gap-1">
<span class="badge badge-success" style="background-color: #28a745; color: white;">Online</span>
<span class="badge badge-secondary" style="background-color: #6c757d; color: white;">Offline</span>
</div>
</div>
<div class="alert alert-info">
<strong>Client Status:</strong> Clients are considered online if they've sent a request to the API in the last 30 minutes.
The list shows all clients first (online), followed by offline clients.
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Client Name</th>
<th>RustDesk ID</th>
<th>Status</th>
<th>Last Seen</th>
<th>Remote Link</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
{% if clients %}
{% for client in clients %}
<tr class="{% if client.is_online %}table-success{% else %}table-secondary{% endif %}">
<td>
<strong>{{ client.token_name }}</strong>
<br><small class="text-muted">{{ client.username }}</small>
</td>
<td>
<code>{{ client.rustdesk_id }}</code>
</td>
<td>
{% if client.is_online %}
<span class="badge badge-success" style="background-color: #28a745; color: white;">Online</span>
{% else %}
<span class="badge badge-secondary" style="background-color: #6c757d; color: white;">Offline</span>
{% endif %}
</td>
<td>
{{ client.last_seen_formatted }}
<br><small class="text-muted">{{ client.last_seen_ago }}</small>
</td>
<td>
{% if client.is_online %}
<a href="https://{{ client.rustdesk_id }}.remote.{{ client.remote_domain }}"
target="_blank"
class="btn btn-sm btn-primary">
Connect
</a>
{% else %}
<span class="text-muted">Not available</span>
{% endif %}
</td>
<td>
{% if client.ip_address %}
<code>{{ client.ip_address }}</code>
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
<td>
{% if client.user_agent %}
<small class="text-muted">{{ client.user_agent[:50] }}{% if client.user_agent|length > 50 %}...{% endif %}</small>
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="text-center text-muted">
No clients found. Clients will appear here when they connect to the API.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="mt-2 text-muted">
<small>
<strong>Note:</strong> The remote link format is: https://{rustdesk_id}.remote.{remote_domain}
<br>Default remote domain: townshipscombatleague.com (configurable in admin settings)
</small>
</div>
{% endblock %}
{% block extra_css %}
<style>
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
}
.table-success {
background-color: rgba(40, 167, 69, 0.1) !important;
}
.table-secondary {
background-color: rgba(108, 117, 125, 0.1) !important;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-size: 0.9em;
}
</style>
{% endblock %}
\ No newline at end of file
This diff is collapsed.
...@@ -11,20 +11,24 @@ import shutil ...@@ -11,20 +11,24 @@ import shutil
import platform import platform
from pathlib import Path from pathlib import Path
def run_command(cmd, cwd=None): def run_command(cmd, cwd=None, capture_output=True):
"""Run a command and return the result""" """Run a command and return the result"""
print(f"Running: {' '.join(cmd)}") print(f"Running: {' '.join(cmd)}")
try: try:
result = subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True) if capture_output:
if result.stdout: result = subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True)
print(result.stdout) if result.stdout:
print(result.stdout)
else:
result = subprocess.run(cmd, cwd=cwd, check=True)
return True return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"Error: {e}") print(f"Error: {e}")
if e.stdout: if capture_output:
print(f"STDOUT: {e.stdout}") if e.stdout:
if e.stderr: print(f"STDOUT: {e.stdout}")
print(f"STDERR: {e.stderr}") if e.stderr:
print(f"STDERR: {e.stderr}")
return False return False
def check_python_version(): def check_python_version():
...@@ -35,20 +39,50 @@ def check_python_version(): ...@@ -35,20 +39,50 @@ def check_python_version():
print(f"Python version: {sys.version}") print(f"Python version: {sys.version}")
return True return True
def check_dependencies_installed(requirements_file):
"""Check if dependencies from requirements file are installed"""
try:
with open(requirements_file, 'r') as f:
requirements = [line.strip().split('==')[0].split('>=')[0].split('<')[0].split('>')[0]
for line in f if line.strip() and not line.startswith('#')]
except FileNotFoundError:
return False
for req in requirements:
try:
__import__(req.replace('-', '_'))
except ImportError:
return False
return True
def install_build_dependencies(): def install_build_dependencies():
"""Install PyInstaller and other build dependencies""" """Install PyInstaller and other build dependencies"""
print("Installing build dependencies...") print("Checking build dependencies...")
# Install build requirements # Check if build dependencies are already installed
if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements-build.txt", "--break-system-packages"]): if check_dependencies_installed("requirements-build.txt"):
print("Failed to install build dependencies") print("Build dependencies already installed, skipping...")
return False else:
print("Installing build dependencies...")
# Install runtime requirements # Try without --break-system-packages first
if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "--break-system-packages"]): if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements-build.txt"], capture_output=False):
print("Failed to install runtime dependencies") print("Trying with --break-system-packages...")
return False if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements-build.txt", "--break-system-packages"], capture_output=False):
print("Failed to install build dependencies")
return False
# Check if runtime dependencies are already installed
if check_dependencies_installed("requirements.txt"):
print("Runtime dependencies already installed, skipping...")
else:
print("Installing runtime dependencies...")
# Try without --break-system-packages first
if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], capture_output=False):
print("Trying with --break-system-packages...")
if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "--break-system-packages"], capture_output=False):
print("Failed to install runtime dependencies")
return False
return True return True
def clean_build_directories(): def clean_build_directories():
......
# Database Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=secure_password_here
MYSQL_DATABASE=fixture_manager
# Security Configuration
SECRET_KEY=your-secret-key-here-change-in-production
JWT_SECRET_KEY=your-jwt-secret-key-here
BCRYPT_LOG_ROUNDS=12
# File Upload Configuration
UPLOAD_FOLDER=/var/lib/fixture-daemon/uploads
MAX_CONTENT_LENGTH=524288000
CHUNK_SIZE=8192
MAX_CONCURRENT_UPLOADS=5
# Daemon Configuration
DAEMON_PID_FILE=/var/run/fixture-daemon.pid
DAEMON_LOG_FILE=/var/log/fixture-daemon.log
DAEMON_WORKING_DIR=/var/lib/fixture-daemon
# Web Server Configuration
HOST=0.0.0.0
PORT=5000
DEBUG=false
# Logging Configuration
LOG_LEVEL=INFO
# JWT Configuration
JWT_ACCESS_TOKEN_EXPIRES=3600
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
#!/bin/bash
# Fixture Manager Daemon Runner
# Set executable permissions
chmod +x ./fixture-manager
# Run the daemon
./fixture-manager "$@"
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