mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 13:20:38 -05:00
016fe5ead0
- Update global layout and styles: `app/templates/base.html`, `app/static/base.css` - Modernize analytics dashboards (web + mobile) - Revamp auth pages: login, profile, edit profile - Refresh error pages: 400/403/404/500 and generic - Polish main dashboard and search - Enhance tasks views: create/edit/view, kanban, my/overdue - Update clients, projects, invoices, and reports pages - Refine timer pages (timer/edit/manual_entry) - Tweak admin routes and templates - Update license server util and integration docs - Refresh README and help/about content Notes: - UI-focused changes; no database migrations included.
564 lines
24 KiB
Python
564 lines
24 KiB
Python
import os
|
|
import json
|
|
import uuid
|
|
import time
|
|
import threading
|
|
import logging
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Any
|
|
import platform
|
|
import socket
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class LicenseServerClient:
|
|
"""Client for communicating with the Metrics Server"""
|
|
|
|
def __init__(self, app_identifier: str = "timetracker", app_version: str = "1.0.0"):
|
|
# Server configuration (env-overridable)
|
|
# Default targets the public metrics endpoint; override via environment if needed.
|
|
default_server_url = "http://metrics.drytrix.com:58082"
|
|
configured_server_url = os.getenv("METRICS_SERVER_URL", os.getenv("LICENSE_SERVER_BASE_URL", default_server_url))
|
|
self.server_url = self._normalize_base_url(configured_server_url)
|
|
self.api_key = os.getenv("METRICS_SERVER_API_KEY", os.getenv("LICENSE_SERVER_API_KEY", "no-license-required"))
|
|
self.app_identifier = app_identifier
|
|
self.app_version = app_version
|
|
|
|
# Instance management
|
|
self.instance_id = None
|
|
self.registration_token = None
|
|
self.is_registered = False
|
|
self.heartbeat_thread = None
|
|
# Timing configuration
|
|
self.heartbeat_interval = int(os.getenv("METRICS_HEARTBEAT_SECONDS", os.getenv("LICENSE_HEARTBEAT_SECONDS", "3600"))) # default: 1 hour
|
|
self.request_timeout = int(os.getenv("METRICS_SERVER_TIMEOUT_SECONDS", os.getenv("LICENSE_SERVER_TIMEOUT_SECONDS", "30"))) # default: 30s per docs
|
|
self.running = False
|
|
|
|
# System information
|
|
self.system_info = self._collect_system_info()
|
|
|
|
logger.info(f"Metrics server configured: base='{self.server_url}', app='{self.app_identifier}', version='{self.app_version}'")
|
|
if not self.api_key:
|
|
logger.warning("X-API-Key is empty; server may reject requests. Set LICENSE_SERVER_API_KEY.")
|
|
|
|
# Registration synchronization and persistence
|
|
self._registration_lock = threading.Lock()
|
|
self._registration_in_progress = False
|
|
self._state_file_path = self._compute_state_file_path()
|
|
self._load_persisted_state()
|
|
|
|
# Offline storage for failed requests
|
|
self.offline_data = []
|
|
self.max_offline_data = 100
|
|
|
|
def _collect_system_info(self) -> Dict[str, Any]:
|
|
"""Collect system information for registration"""
|
|
try:
|
|
# Check if analytics are allowed
|
|
from app.models import Settings
|
|
try:
|
|
settings = Settings.get_settings()
|
|
if not settings.allow_analytics:
|
|
# Return minimal info if analytics are disabled
|
|
return {
|
|
"os": "Unknown",
|
|
"version": "Unknown",
|
|
"architecture": "Unknown",
|
|
"machine": "Unknown",
|
|
"processor": "Unknown",
|
|
"hostname": "Unknown",
|
|
"local_ip": "Unknown",
|
|
"python_version": "Unknown",
|
|
"analytics_disabled": True
|
|
}
|
|
except Exception:
|
|
# If we can't get settings, assume analytics are allowed (fallback)
|
|
pass
|
|
|
|
# Get local IP address
|
|
hostname = socket.gethostname()
|
|
local_ip = socket.gethostbyname(hostname)
|
|
|
|
return {
|
|
"os": platform.system(),
|
|
"version": platform.version(),
|
|
"architecture": platform.architecture()[0],
|
|
"machine": platform.machine(),
|
|
"processor": platform.processor(),
|
|
"hostname": hostname,
|
|
"local_ip": local_ip,
|
|
"python_version": platform.python_version(),
|
|
"analytics_disabled": False
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Could not collect complete system info: {e}")
|
|
return {
|
|
"os": platform.system(),
|
|
"version": "unknown",
|
|
"architecture": "unknown",
|
|
"analytics_disabled": False
|
|
}
|
|
|
|
def _normalize_base_url(self, base_url: str) -> str:
|
|
"""Normalize base URL to avoid duplicate '/api/v1' when building endpoints.
|
|
|
|
Accepts values with or without trailing slash and with or without '/api/v1'.
|
|
Always returns a URL WITHOUT trailing slash and WITHOUT '/api/v1'.
|
|
"""
|
|
try:
|
|
if not base_url:
|
|
return ""
|
|
url = base_url.strip().rstrip("/")
|
|
# If caller provided a base that already includes '/api/v1', strip it.
|
|
if url.endswith("/api/v1"):
|
|
url = url[: -len("/api/v1")]
|
|
url = url.rstrip("/")
|
|
return url
|
|
except Exception:
|
|
# Fallback to provided value if normalization fails
|
|
return base_url
|
|
|
|
def _compute_state_file_path(self) -> str:
|
|
"""Compute a per-user path to persist license client state (instance id, token)."""
|
|
try:
|
|
if os.name == "nt":
|
|
base_dir = os.getenv("APPDATA") or os.path.expanduser("~")
|
|
app_dir = os.path.join(base_dir, "TimeTracker")
|
|
else:
|
|
app_dir = os.path.join(os.path.expanduser("~"), ".timetracker")
|
|
os.makedirs(app_dir, exist_ok=True)
|
|
return os.path.join(app_dir, "license_client_state.json")
|
|
except Exception:
|
|
# Fallback to current directory
|
|
return os.path.join(os.getcwd(), "license_client_state.json")
|
|
|
|
def _load_persisted_state(self):
|
|
"""Load previously persisted state if available (instance id, token)."""
|
|
try:
|
|
if self._state_file_path and os.path.exists(self._state_file_path):
|
|
with open(self._state_file_path, "r", encoding="utf-8") as f:
|
|
state = json.load(f)
|
|
loaded_instance_id = state.get("instance_id")
|
|
loaded_token = state.get("registration_token")
|
|
if loaded_instance_id and not self.instance_id:
|
|
self.instance_id = loaded_instance_id
|
|
if loaded_token:
|
|
self.registration_token = loaded_token
|
|
logger.info(f"Loaded persisted license client state from '{self._state_file_path}'")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load persisted license client state: {e}")
|
|
|
|
def _persist_state(self):
|
|
"""Persist current state (instance id, token) to disk."""
|
|
try:
|
|
if not self._state_file_path:
|
|
return
|
|
state = {
|
|
"instance_id": self.instance_id,
|
|
"registration_token": self.registration_token,
|
|
"app_identifier": self.app_identifier,
|
|
"app_version": self.app_version,
|
|
"updated_at": datetime.now().isoformat()
|
|
}
|
|
with open(self._state_file_path, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2)
|
|
logger.debug(f"Persisted license client state to '{self._state_file_path}'")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to persist license client state: {e}")
|
|
|
|
def get_detailed_error_info(self, response) -> Dict[str, Any]:
|
|
"""Extract detailed error information from a failed response"""
|
|
error_info = {
|
|
"status_code": response.status_code,
|
|
"reason": response.reason,
|
|
"headers": dict(response.headers),
|
|
"text": response.text,
|
|
"url": response.url,
|
|
"elapsed": str(response.elapsed) if hasattr(response, 'elapsed') else "unknown"
|
|
}
|
|
|
|
# Try to parse JSON error response
|
|
try:
|
|
error_json = response.json()
|
|
error_info["json_error"] = error_json
|
|
if "error" in error_json:
|
|
error_info["error_message"] = error_json["error"]
|
|
if "details" in error_json:
|
|
error_info["error_details"] = error_json["details"]
|
|
if "traceback" in error_json:
|
|
error_info["server_traceback"] = error_json["traceback"]
|
|
except json.JSONDecodeError:
|
|
error_info["json_error"] = None
|
|
error_info["parse_error"] = "Response is not valid JSON"
|
|
|
|
return error_info
|
|
|
|
def _make_request(self, endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Dict]:
|
|
"""Make HTTP request to phone home endpoint with error handling"""
|
|
url = f"{self.server_url}{endpoint}"
|
|
headers = {
|
|
"X-API-Key": self.api_key,
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
# Log request details
|
|
logger.info(f"Making {method} request to phone home endpoint: {url}")
|
|
if data:
|
|
logger.debug(f"Request data: {json.dumps(data, indent=2)}")
|
|
logger.debug(f"Request headers: {headers}")
|
|
|
|
try:
|
|
if method.upper() == "GET":
|
|
response = requests.get(url, headers=headers, timeout=self.request_timeout)
|
|
elif method.upper() == "POST":
|
|
response = requests.post(url, headers=headers, json=data, timeout=self.request_timeout)
|
|
else:
|
|
logger.error(f"Unsupported HTTP method: {method}")
|
|
return None
|
|
|
|
# Log response details
|
|
logger.info(f"Phone home response: {response.status_code} - {response.reason}")
|
|
logger.debug(f"Response headers: {dict(response.headers)}")
|
|
|
|
if response.status_code in [200, 201]:
|
|
try:
|
|
response_json = response.json()
|
|
logger.debug(f"Response body: {json.dumps(response_json, indent=2)}")
|
|
return response_json
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Failed to parse JSON response: {e}")
|
|
logger.error(f"Raw response text: {response.text}")
|
|
return None
|
|
else:
|
|
# Enhanced error logging with detailed error info
|
|
error_info = self.get_detailed_error_info(response)
|
|
logger.error(f"Phone home request failed with status {response.status_code}")
|
|
logger.error(f"Detailed error info: {json.dumps(error_info, indent=2)}")
|
|
|
|
# Log specific error details
|
|
if "error_message" in error_info:
|
|
logger.error(f"Server error message: {error_info['error_message']}")
|
|
if "error_details" in error_info:
|
|
logger.error(f"Server error details: {error_info['error_details']}")
|
|
if "server_traceback" in error_info:
|
|
logger.error(f"Server traceback: {error_info['server_traceback']}")
|
|
|
|
return None
|
|
|
|
except requests.exceptions.Timeout as e:
|
|
logger.error(f"Phone home request timed out after {self.request_timeout} seconds: {e}")
|
|
return None
|
|
except requests.exceptions.ConnectionError as e:
|
|
logger.error(f"Phone home connection error: {e}")
|
|
logger.error(f"URL attempted: {url}")
|
|
return None
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error(f"Phone home request exception: {e}")
|
|
logger.error(f"Request type: {type(e).__name__}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in phone home request: {e}")
|
|
logger.error(f"Error type: {type(e).__name__}")
|
|
logger.error(f"Error details: {str(e)}")
|
|
import traceback
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
return None
|
|
|
|
def register_instance(self) -> bool:
|
|
"""Register this instance with the phone home service"""
|
|
with self._registration_lock:
|
|
if self.is_registered:
|
|
logger.info("Instance already registered")
|
|
return True
|
|
|
|
# Generate instance ID if not exists (prefer persisted one)
|
|
if not self.instance_id:
|
|
self.instance_id = str(uuid.uuid4())
|
|
|
|
registration_data = {
|
|
"app_identifier": self.app_identifier,
|
|
"version": self.app_version,
|
|
"instance_id": self.instance_id,
|
|
"system_metadata": self.system_info,
|
|
"country": "Unknown", # Could be enhanced with IP geolocation
|
|
"city": "Unknown",
|
|
"license_id": "NO-LICENSE-REQUIRED"
|
|
}
|
|
|
|
logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}")
|
|
logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}")
|
|
logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}")
|
|
logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}")
|
|
|
|
response = self._make_request("/api/v1/register", "POST", registration_data)
|
|
|
|
if response and "instance_id" in response:
|
|
self.instance_id = response["instance_id"]
|
|
self.registration_token = response.get("token")
|
|
self.is_registered = True
|
|
self._persist_state()
|
|
logger.info(f"Successfully registered instance {self.instance_id}")
|
|
if self.registration_token:
|
|
logger.debug(f"Received registration token: {self.registration_token[:10]}...")
|
|
return True
|
|
else:
|
|
logger.error(f"Registration failed - no valid response from phone home service")
|
|
logger.error(f"Expected 'instance_id' in response, but got: {response}")
|
|
logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration")
|
|
return False
|
|
|
|
def validate_license(self) -> bool:
|
|
"""Validate license (always returns True since no license required)"""
|
|
if not self.is_registered:
|
|
logger.warning("Cannot validate license: instance not registered")
|
|
return False
|
|
|
|
validation_data = {
|
|
"app_identifier": self.app_identifier,
|
|
"license_id": "NO-LICENSE-REQUIRED",
|
|
"instance_id": self.instance_id
|
|
}
|
|
|
|
# Log the complete license validation request (URL, method, headers, body)
|
|
validation_url = f"{self.server_url}/api/v1/validate"
|
|
validation_headers = {
|
|
"X-API-Key": self.api_key,
|
|
"Content-Type": "application/json"
|
|
}
|
|
try:
|
|
logger.info("Complete license validation request:")
|
|
logger.info(json.dumps({
|
|
"url": validation_url,
|
|
"method": "POST",
|
|
"headers": validation_headers,
|
|
"body": validation_data
|
|
}, indent=2))
|
|
except Exception:
|
|
# Fallback logging if JSON serialization fails for any reason
|
|
logger.info(f"License validation URL: {validation_url}")
|
|
logger.info(f"License validation headers: {validation_headers}")
|
|
logger.info(f"License validation body: {validation_data}")
|
|
|
|
logger.info("Validating metrics token (no license required)")
|
|
response = self._make_request("/api/v1/validate", "POST", validation_data)
|
|
|
|
if response and response.get("valid", False):
|
|
logger.info("Phone home token validation successful")
|
|
return True
|
|
else:
|
|
logger.warning("Phone home token validation failed")
|
|
return False
|
|
|
|
def send_heartbeat(self) -> bool:
|
|
"""Send heartbeat to phone home service"""
|
|
if not self.is_registered:
|
|
logger.warning("Cannot send heartbeat: instance not registered")
|
|
return False
|
|
|
|
heartbeat_data = {
|
|
"instance_id": self.instance_id
|
|
}
|
|
|
|
logger.debug("Sending heartbeat to phone home service")
|
|
response = self._make_request("/api/v1/heartbeat", "POST", heartbeat_data)
|
|
|
|
if response:
|
|
logger.debug("Heartbeat successful")
|
|
return True
|
|
else:
|
|
logger.warning("Heartbeat failed")
|
|
return False
|
|
|
|
def send_usage_data(self, data_points: List[Dict[str, Any]]) -> bool:
|
|
"""Send usage data to phone home service"""
|
|
if not self.is_registered:
|
|
# Store data offline for later transmission
|
|
self._store_offline_data(data_points)
|
|
return False
|
|
|
|
# Check if analytics are allowed
|
|
try:
|
|
from app.models import Settings
|
|
settings = Settings.get_settings()
|
|
if not settings.allow_analytics:
|
|
logger.info("Analytics disabled by user setting - skipping usage data transmission")
|
|
return True # Return True to indicate "success" (data was handled appropriately)
|
|
except Exception:
|
|
# If we can't get settings, assume analytics are allowed (fallback)
|
|
pass
|
|
|
|
usage_data = {
|
|
"app_identifier": self.app_identifier,
|
|
"instance_id": self.instance_id,
|
|
"data": data_points
|
|
}
|
|
|
|
logger.debug(f"Sending {len(data_points)} usage data points")
|
|
response = self._make_request("/api/v1/data", "POST", usage_data)
|
|
|
|
if response:
|
|
logger.debug("Usage data sent successfully")
|
|
# Try to send any stored offline data
|
|
self._send_offline_data()
|
|
return True
|
|
else:
|
|
logger.warning("Failed to send usage data")
|
|
self._store_offline_data(data_points)
|
|
return False
|
|
|
|
def _store_offline_data(self, data_points: List[Dict[str, Any]]):
|
|
"""Store data points for offline transmission"""
|
|
for point in data_points:
|
|
# Use local time per project preference
|
|
point["timestamp"] = datetime.now().isoformat()
|
|
self.offline_data.append(point)
|
|
|
|
# Keep only the most recent data points
|
|
if len(self.offline_data) > self.max_offline_data:
|
|
self.offline_data = self.offline_data[-self.max_offline_data:]
|
|
|
|
logger.debug(f"Stored {len(data_points)} data points offline")
|
|
|
|
def _send_offline_data(self):
|
|
"""Attempt to send stored offline data"""
|
|
if not self.offline_data:
|
|
return
|
|
|
|
data_to_send = self.offline_data.copy()
|
|
self.offline_data.clear()
|
|
|
|
logger.info(f"Attempting to send {len(data_to_send)} offline data points to phone home service")
|
|
if self.send_usage_data(data_to_send):
|
|
logger.info("Successfully sent offline data")
|
|
else:
|
|
# Put the data back if sending failed
|
|
self.offline_data.extend(data_to_send)
|
|
logger.warning("Failed to send offline data, will retry later")
|
|
|
|
def _heartbeat_worker(self):
|
|
"""Background worker for sending heartbeats"""
|
|
while self.running:
|
|
try:
|
|
if self.is_registered:
|
|
self.send_heartbeat()
|
|
else:
|
|
# If not registered, try to register again every hour
|
|
logger.debug("Attempting to register with phone home service...")
|
|
self.register_instance()
|
|
time.sleep(self.heartbeat_interval)
|
|
except Exception as e:
|
|
logger.error(f"Error in heartbeat worker: {e}")
|
|
time.sleep(60) # Wait a minute before retrying
|
|
|
|
def start(self) -> bool:
|
|
"""Start the phone home client"""
|
|
if self.running:
|
|
logger.warning("Phone home client already running")
|
|
return True
|
|
|
|
logger.info(f"Starting phone home client (instance: {id(self)})")
|
|
|
|
# Try to register instance (but don't fail if it doesn't work)
|
|
registration_success = self.register_instance()
|
|
if not registration_success:
|
|
logger.info("Phone home service not available - client will run in offline mode")
|
|
|
|
# Start heartbeat thread
|
|
self.running = True
|
|
self.heartbeat_thread = threading.Thread(target=self._heartbeat_worker, daemon=True)
|
|
self.heartbeat_thread.start()
|
|
|
|
logger.info(f"Phone home client started successfully (instance: {id(self)})")
|
|
return True
|
|
|
|
def stop(self):
|
|
"""Stop the phone home client"""
|
|
logger.info("Stopping phone home client")
|
|
self.running = False
|
|
|
|
if self.heartbeat_thread and self.heartbeat_thread.is_alive():
|
|
self.heartbeat_thread.join(timeout=5)
|
|
|
|
logger.info("Phone home client stopped")
|
|
|
|
def check_server_health(self) -> bool:
|
|
"""Check if the phone home service is healthy"""
|
|
response = self._make_request("/api/v1/status")
|
|
return response is not None
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""Get current status of the metrics client"""
|
|
return {
|
|
"is_registered": self.is_registered,
|
|
"instance_id": self.instance_id,
|
|
"is_running": self.running,
|
|
"server_healthy": self.check_server_health(),
|
|
"offline_data_count": len(self.offline_data),
|
|
"app_identifier": self.app_identifier,
|
|
"app_version": self.app_version,
|
|
"server_url": self.server_url,
|
|
"heartbeat_interval": self.heartbeat_interval,
|
|
"analytics_enabled": not bool(self.system_info.get("analytics_disabled", False)),
|
|
"system_info": self.system_info
|
|
}
|
|
|
|
# Global instance
|
|
license_client = None
|
|
_initialization_lock = threading.Lock()
|
|
|
|
def init_license_client(app_identifier: str = "timetracker", app_version: str = "1.0.0") -> LicenseServerClient:
|
|
"""Initialize the global license client instance"""
|
|
global license_client
|
|
|
|
with _initialization_lock:
|
|
if license_client is None:
|
|
logger.info(f"Creating new license client instance (app: {app_identifier}, version: {app_version})")
|
|
license_client = LicenseServerClient(app_identifier, app_version)
|
|
logger.info(f"License client initialized (instance: {id(license_client)})")
|
|
else:
|
|
logger.info(f"License client already initialized, reusing existing instance (instance: {id(license_client)})")
|
|
|
|
return license_client
|
|
|
|
def get_license_client() -> Optional[LicenseServerClient]:
|
|
"""Get the global license client instance"""
|
|
return license_client
|
|
|
|
def start_license_client() -> bool:
|
|
"""Start the global license client"""
|
|
global license_client
|
|
|
|
if license_client is None:
|
|
logger.error("License client not initialized")
|
|
return False
|
|
|
|
# Check if already running
|
|
if license_client.running:
|
|
logger.info("License client already running")
|
|
return True
|
|
|
|
return license_client.start()
|
|
|
|
def stop_license_client():
|
|
"""Stop the global license client"""
|
|
global license_client
|
|
|
|
if license_client:
|
|
license_client.stop()
|
|
|
|
def send_usage_event(event_type: str, event_data: Dict[str, Any] = None):
|
|
"""Send a usage event to the metrics server"""
|
|
if not license_client:
|
|
return False
|
|
|
|
data_point = {
|
|
"key": "usage_event",
|
|
"value": event_type,
|
|
"type": "string",
|
|
"metadata": event_data or {}
|
|
}
|
|
|
|
return license_client.send_usage_data([data_point])
|
|
|