diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index 53ab929..511f720 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -2924,6 +2924,15 @@ class RepeaterManager: async def cleanup_database(self, days_to_keep_logs: int = 90): """Clean up old purging log entries""" try: + # Guard: purging_log table may not exist on older installs + with self.db_manager.connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='purging_log'" + ) + if not cursor.fetchone(): + return + cutoff_date = datetime.now() - timedelta(days=days_to_keep_logs) deleted_count = self.db_manager.execute_update( @@ -2935,7 +2944,7 @@ class RepeaterManager: self.logger.info(f"Cleaned up {deleted_count} old purging log entries") except Exception as e: - self.logger.error(f"Error cleaning up database: {e}") + self.logger.error(f"Error cleaning up database: {type(e).__name__}: {e}") def cleanup_repeater_retention( self, diff --git a/modules/scheduler.py b/modules/scheduler.py index 3435621..ce16647 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -516,6 +516,88 @@ class MessageScheduler: self.logger.info("Scheduler thread stopped") + def _run_data_retention(self): + """Run data retention cleanup: packet_stream, repeater tables, stats, caches, mesh_connections.""" + import asyncio + + def get_retention_days(section: str, key: str, default: int) -> int: + try: + if self.bot.config.has_section(section) and self.bot.config.has_option(section, key): + return self.bot.config.getint(section, key) + except Exception: + pass + return default + + packet_stream_days = get_retention_days('Data_Retention', 'packet_stream_retention_days', 3) + purging_log_days = get_retention_days('Data_Retention', 'purging_log_retention_days', 90) + daily_stats_days = get_retention_days('Data_Retention', 'daily_stats_retention_days', 90) + observed_paths_days = get_retention_days('Data_Retention', 'observed_paths_retention_days', 90) + mesh_connections_days = get_retention_days('Data_Retention', 'mesh_connections_retention_days', 7) + stats_days = get_retention_days('Stats_Command', 'data_retention_days', 7) + + try: + # Packet stream (web viewer integration) + if hasattr(self.bot, 'web_viewer_integration') and self.bot.web_viewer_integration: + bi = getattr(self.bot.web_viewer_integration, 'bot_integration', None) + if bi and hasattr(bi, 'cleanup_old_data'): + bi.cleanup_old_data(packet_stream_days) + + # Repeater manager: purging_log and optional daily_stats / unique_advert / observed_paths + if hasattr(self.bot, 'repeater_manager') and self.bot.repeater_manager: + if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running(): + future = asyncio.run_coroutine_threadsafe( + self.bot.repeater_manager.cleanup_database(purging_log_days), + self.bot.main_event_loop + ) + try: + future.result(timeout=60) + except Exception as e: + self.logger.error(f"Error in repeater_manager.cleanup_database: {type(e).__name__}: {e}") + else: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.bot.repeater_manager.cleanup_database(purging_log_days)) + if hasattr(self.bot.repeater_manager, 'cleanup_repeater_retention'): + self.bot.repeater_manager.cleanup_repeater_retention( + daily_stats_days=daily_stats_days, + observed_paths_days=observed_paths_days + ) + + # Stats tables (message_stats, command_stats, path_stats) + if hasattr(self.bot, 'command_manager') and self.bot.command_manager: + stats_cmd = self.bot.command_manager.commands.get('stats') if getattr(self.bot.command_manager, 'commands', None) else None + if stats_cmd and hasattr(stats_cmd, 'cleanup_old_stats'): + stats_cmd.cleanup_old_stats(stats_days) + + # Expired caches (geocoding_cache, generic_cache) + if hasattr(self.bot, 'db_manager') and self.bot.db_manager and hasattr(self.bot.db_manager, 'cleanup_expired_cache'): + self.bot.db_manager.cleanup_expired_cache() + + # Mesh connections (DB prune to match in-memory expiration) + if hasattr(self.bot, 'mesh_graph') and self.bot.mesh_graph and hasattr(self.bot.mesh_graph, 'delete_expired_edges_from_db'): + self.bot.mesh_graph.delete_expired_edges_from_db(mesh_connections_days) + + ran_at = datetime.datetime.utcnow().isoformat() + self._last_retention_stats['ran_at'] = ran_at + try: + self.bot.db_manager.set_metadata('maint.status.data_retention_ran_at', ran_at) + self.bot.db_manager.set_metadata('maint.status.data_retention_outcome', 'ok') + except Exception: + pass + + except Exception as e: + self.logger.exception(f"Error during data retention cleanup: {e}") + self._last_retention_stats['error'] = str(e) + try: + ran_at = datetime.datetime.utcnow().isoformat() + self.bot.db_manager.set_metadata('maint.status.data_retention_ran_at', ran_at) + self.bot.db_manager.set_metadata('maint.status.data_retention_outcome', f'error: {e}') + except Exception: + pass + def check_interval_advertising(self): """Check if it's time to send an interval-based advert""" try: diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html index 94157d3..cf60767 100644 --- a/modules/web_viewer/templates/base.html +++ b/modules/web_viewer/templates/base.html @@ -795,13 +795,14 @@ } } - // Initialize connection manager when page loads + // Create connection manager synchronously so page scripts in the extra_js + // block can share the same socket without calling io() a second time. + window.connectionManager = new ModernConnectionManager(); + document.addEventListener('DOMContentLoaded', () => { // Initialize dark mode manager first window.darkModeManager = new DarkModeManager(); - - window.connectionManager = new ModernConnectionManager(); - + // Update timestamp immediately and every second updateTimestamp(); setInterval(updateTimestamp, 1000); diff --git a/modules/web_viewer/templates/index.html b/modules/web_viewer/templates/index.html index 8d38b6e..4a64e12 100644 --- a/modules/web_viewer/templates/index.html +++ b/modules/web_viewer/templates/index.html @@ -1325,7 +1325,7 @@ window.addEventListener('beforeunload', () => { feed.scrollTop = dir === 'top' ? 0 : feed.scrollHeight; }; - const socket = io({ transports: ['websocket', 'polling'] }); + const socket = window.connectionManager.socket; socket.on('connect', () => { dot.className = 'status-indicator status-connected ms-2'; diff --git a/modules/web_viewer/templates/logs.html b/modules/web_viewer/templates/logs.html index b560e75..eb7775f 100644 --- a/modules/web_viewer/templates/logs.html +++ b/modules/web_viewer/templates/logs.html @@ -226,7 +226,7 @@ }); } - const socket = io({ transports: ['websocket', 'polling'] }); + const socket = window.connectionManager.socket; socket.on('connect', function () { updateStatus('log-status', 'Connected', 'success'); diff --git a/modules/web_viewer/templates/mesh.html b/modules/web_viewer/templates/mesh.html index eeda31e..aeadde0 100644 --- a/modules/web_viewer/templates/mesh.html +++ b/modules/web_viewer/templates/mesh.html @@ -2411,7 +2411,7 @@ // re-stabilization layout (nodes clumping, edges tangling) that occurs when vis-network // runs physics again. User can refresh or switch to map and back to see updated graph. function setupSocketIO() { - const socket = io(); + const socket = window.connectionManager.socket; socket.emit('subscribe_mesh'); diff --git a/modules/web_viewer/templates/realtime.html b/modules/web_viewer/templates/realtime.html index 53ee317..e534a44 100644 --- a/modules/web_viewer/templates/realtime.html +++ b/modules/web_viewer/templates/realtime.html @@ -818,8 +818,8 @@ console.warn('MeshCore decoder unavailable:', e); }); - // Initialize Socket.IO connection (plain io() with same defaults as base.html) - const socket = io(); + // Reuse the shared socket from base.html's ModernConnectionManager + const socket = window.connectionManager.socket; // Connection status let commandConnected = false;