From 71d157a97474f755ba7176c2f034b94802afea96 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 30 Nov 2025 20:04:20 -0800 Subject: [PATCH] Enhance command execution and analytics features. Update CommandManager to allow DM-only commands in public channels to silently ignore errors if the channel is not configured. Refactor BaseCommand to improve execution checks based on channel permissions. In the web viewer, add time window parameters for analytics in the database stats API, and implement Chart.js for visualizing activity trends over the last 30 days. Update dashboard templates to include time window selectors for top users, commands, paths, and channels, improving user experience and data accessibility. --- modules/command_manager.py | 10 +- modules/commands/base_command.py | 16 +- modules/web_viewer/app.py | 202 +++++++++++++-- modules/web_viewer/templates/base.html | 3 + modules/web_viewer/templates/contacts.html | 232 ++++++++++++++---- modules/web_viewer/templates/index.html | 270 ++++++++++++++------- 6 files changed, 572 insertions(+), 161 deletions(-) diff --git a/modules/command_manager.py b/modules/command_manager.py index 2392485..96594c0 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -513,9 +513,15 @@ class CommandManager: # Check if command can execute (cooldown, DM requirements, etc.) if not command.can_execute_now(message): + # For DM-only commands in public channels, only show error if channel is allowed + # (i.e., channel is in monitor_channels or command's allowed_channels) + # This prevents prompting users in channels where the command shouldn't work at all if command.requires_dm and not message.is_dm: - error_msg = command.translate('errors.dm_only', command=command_name) - await self.send_response(message, error_msg) + # Only prompt if channel is allowed (configured channels) + if command.is_channel_allowed(message): + error_msg = command.translate('errors.dm_only', command=command_name) + await self.send_response(message, error_msg) + # Otherwise, silently ignore (channel not configured for this command) elif command.requires_admin_access(): error_msg = command.translate('errors.access_denied', command=command_name) await self.send_response(message, error_msg) diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index d9efb1b..5e58e1a 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -310,7 +310,21 @@ class BaseCommand(ABC): def should_execute(self, message: MeshMessage) -> bool: """Check if this command should execute for the given message""" - return (self.matches_keyword(message) or self.matches_custom_syntax(message)) + # First check if keyword matches + if not (self.matches_keyword(message) or self.matches_custom_syntax(message)): + return False + + # For DM-only commands, only consider them if: + # 1. Message is a DM, OR + # 2. Channel is in allowed_channels (or monitor_channels if no override) + # This prevents DM-only commands from being processed in public channels + if self.requires_dm and not message.is_dm: + # Check if channel is allowed for this command + if not self.is_channel_allowed(message): + # Channel not allowed - don't even consider this command + return False + + return True def can_execute_now(self, message: MeshMessage) -> bool: """Check if this command can execute right now (permissions, cooldown, etc.)""" diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 737f69f..f565fda 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -250,7 +250,17 @@ class BotDataViewer: def api_stats(): """Get comprehensive database statistics for dashboard""" try: - stats = self._get_database_stats() + # Get optional time window parameters for analytics + top_users_window = request.args.get('top_users_window', 'all') + top_commands_window = request.args.get('top_commands_window', 'all') + top_paths_window = request.args.get('top_paths_window', 'all') + top_channels_window = request.args.get('top_channels_window', 'all') + stats = self._get_database_stats( + top_users_window=top_users_window, + top_commands_window=top_commands_window, + top_paths_window=top_paths_window, + top_channels_window=top_channels_window + ) return jsonify(stats) except Exception as e: self.logger.error(f"Error getting stats: {e}") @@ -795,7 +805,8 @@ class BotDataViewer: except Exception as e: self.logger.error(f"Error cleaning up old packet stream data: {e}") - def _get_database_stats(self): + def _get_database_stats(self, top_users_window='all', top_commands_window='all', + top_paths_window='all', top_channels_window='all'): """Get comprehensive database statistics for dashboard""" conn = None try: @@ -971,14 +982,25 @@ class BotDataViewer: cursor.execute("SELECT COUNT(DISTINCT channel) FROM message_stats WHERE channel IS NOT NULL") stats['unique_channels_total'] = cursor.fetchone()[0] - # Top users (most frequent message senders) - cursor.execute(""" + # Top users (most frequent message senders) - filter by time window + if top_users_window == '24h': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')" + elif top_users_window == '7d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')" + elif top_users_window == '30d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')" + else: # 'all' + time_filter = "" + + query = f""" SELECT sender_id, COUNT(*) as count FROM message_stats + {time_filter} GROUP BY sender_id ORDER BY count DESC LIMIT 15 - """) + """ + cursor.execute(query) stats['top_users'] = [{'user': row[0], 'count': row[1]} for row in cursor.fetchall()] if 'command_stats' in tables: @@ -991,14 +1013,25 @@ class BotDataViewer: """) stats['commands_24h'] = cursor.fetchone()[0] - # Top commands - cursor.execute(""" + # Top commands - filter by time window + if top_commands_window == '24h': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')" + elif top_commands_window == '7d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')" + elif top_commands_window == '30d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')" + else: # 'all' + time_filter = "" + + query = f""" SELECT command_name, COUNT(*) as count FROM command_stats + {time_filter} GROUP BY command_name ORDER BY count DESC LIMIT 15 - """) + """ + cursor.execute(query) stats['top_commands'] = [{'command': row[0], 'count': row[1]} for row in cursor.fetchall()] # Bot reply rate (commands that got responses) @@ -1012,15 +1045,25 @@ class BotDataViewer: else: stats['bot_reply_rate'] = 0 - # Top channels by message count - cursor.execute(""" + # Top channels by message count - filter by time window + if top_channels_window == '24h': + time_filter = "AND timestamp > strftime('%s', 'now', '-24 hours')" + elif top_channels_window == '7d': + time_filter = "AND timestamp > strftime('%s', 'now', '-7 days')" + elif top_channels_window == '30d': + time_filter = "AND timestamp > strftime('%s', 'now', '-30 days')" + else: # 'all' + time_filter = "" + + query = f""" SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users FROM message_stats - WHERE channel IS NOT NULL + WHERE channel IS NOT NULL {time_filter} GROUP BY channel ORDER BY message_count DESC LIMIT 10 - """) + """ + cursor.execute(query) stats['top_channels'] = [ {'channel': row[0], 'messages': row[1], 'users': row[2]} for row in cursor.fetchall() @@ -1043,13 +1086,24 @@ class BotDataViewer: 'timestamp': longest_path[3] } - # Top paths (longest paths) - cursor.execute(""" + # Top paths (longest paths) - filter by time window + if top_paths_window == '24h': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')" + elif top_paths_window == '7d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')" + elif top_paths_window == '30d': + time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')" + else: # 'all' + time_filter = "" + + query = f""" SELECT sender_id, path_length, path_string, timestamp FROM path_stats + {time_filter} ORDER BY path_length DESC LIMIT 5 - """) + """ + cursor.execute(query) stats['top_paths'] = [ { 'user': row[0], @@ -1373,23 +1427,137 @@ class BotDataViewer: server_stats['total_advertisements'] = cursor.fetchone()[0] or 0 # Nodes per day statistics + # Calculate today's unique nodes from complete_contact_tracking + # (last_heard in last 24 hours) since daily_stats might not have today's data yet cursor.execute(""" - SELECT COUNT(DISTINCT public_key) FROM daily_stats - WHERE date = date('now') + SELECT COUNT(DISTINCT public_key) FROM complete_contact_tracking + WHERE last_heard >= datetime('now', '-24 hours') """) server_stats['nodes_24h'] = cursor.fetchone()[0] or 0 + # Get today's unique nodes by role for the stacked chart + cursor.execute(""" + SELECT role, COUNT(DISTINCT public_key) as count + FROM complete_contact_tracking + WHERE last_heard >= datetime('now', '-24 hours') + AND role IS NOT NULL AND role != '' + GROUP BY role + """) + today_by_role = {} + for row in cursor.fetchall(): + role = row[0].lower() if row[0] else 'unknown' + count = row[1] + today_by_role[role] = count + + server_stats['nodes_24h_by_role'] = { + 'companion': today_by_role.get('companion', 0), + 'repeater': today_by_role.get('repeater', 0), + 'roomserver': today_by_role.get('roomserver', 0), + 'sensor': today_by_role.get('sensor', 0), + 'other': sum(v for k, v in today_by_role.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor']) + } + cursor.execute(""" SELECT COUNT(DISTINCT public_key) FROM daily_stats WHERE date >= date('now', '-7 days') AND date < date('now') """) server_stats['nodes_7d'] = cursor.fetchone()[0] or 0 + # Calculate day-over-day and period-over-period comparisons + # Today vs 7 days ago (single day comparison) + cursor.execute(""" + SELECT COUNT(DISTINCT public_key) FROM daily_stats + WHERE date = date('now', '-7 days') + """) + result = cursor.fetchone() + server_stats['nodes_7d_ago'] = result[0] if result and result[0] else 0 + + # Last 7 days vs previous 7 days (days 8-14 ago) + cursor.execute(""" + SELECT COUNT(DISTINCT public_key) FROM daily_stats + WHERE date >= date('now', '-14 days') AND date < date('now', '-7 days') + """) + result = cursor.fetchone() + server_stats['nodes_prev_7d'] = result[0] if result and result[0] else 0 + + # Last 30 days vs previous 30 days (days 31-60 ago) + cursor.execute(""" + SELECT COUNT(DISTINCT public_key) FROM daily_stats + WHERE date >= date('now', '-60 days') AND date < date('now', '-30 days') + """) + result = cursor.fetchone() + server_stats['nodes_prev_30d'] = result[0] if result and result[0] else 0 + + # Also get current period totals for comparison + cursor.execute(""" + SELECT COUNT(DISTINCT public_key) FROM daily_stats + WHERE date >= date('now', '-7 days') + """) + server_stats['nodes_7d'] = cursor.fetchone()[0] or 0 + + cursor.execute(""" + SELECT COUNT(DISTINCT public_key) FROM daily_stats + WHERE date >= date('now', '-30 days') + """) + server_stats['nodes_30d'] = cursor.fetchone()[0] or 0 + cursor.execute(""" SELECT COUNT(DISTINCT public_key) FROM daily_stats """) server_stats['nodes_all'] = cursor.fetchone()[0] or 0 + # Get daily unique node counts by role for the last 30 days for the stacked graph + # Join daily_stats with complete_contact_tracking to get role information + # This gives us accurate historical daily counts by role + cursor.execute(""" + SELECT ds.date, c.role, COUNT(DISTINCT ds.public_key) as daily_count + FROM daily_stats ds + LEFT JOIN complete_contact_tracking c ON ds.public_key = c.public_key + WHERE ds.date >= date('now', '-30 days') AND ds.date <= date('now') + AND (c.role IS NOT NULL AND c.role != '') + GROUP BY ds.date, c.role + ORDER BY ds.date ASC, c.role ASC + """) + daily_data_by_role = cursor.fetchall() + + # Organize data by date and role + daily_by_role = {} + for row in daily_data_by_role: + date_str = row[0] + role = (row[1] or 'unknown').lower() + count = row[2] + + if date_str not in daily_by_role: + daily_by_role[date_str] = {} + daily_by_role[date_str][role] = count + + # Convert to array format with all roles for each date + server_stats['daily_nodes_30d_by_role'] = [] + for date_str in sorted(daily_by_role.keys()): + roles_data = daily_by_role[date_str] + server_stats['daily_nodes_30d_by_role'].append({ + 'date': date_str, + 'companion': roles_data.get('companion', 0), + 'repeater': roles_data.get('repeater', 0), + 'roomserver': roles_data.get('roomserver', 0), + 'sensor': roles_data.get('sensor', 0), + 'other': sum(v for k, v in roles_data.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor']) + }) + + # Also keep the total count for backward compatibility + cursor.execute(""" + SELECT date, COUNT(DISTINCT public_key) as daily_count + FROM daily_stats + WHERE date >= date('now', '-30 days') AND date <= date('now') + GROUP BY date + ORDER BY date ASC + """) + daily_data = cursor.fetchall() + server_stats['daily_nodes_30d'] = [ + {'date': row[0], 'count': row[1]} + for row in daily_data + ] + except Exception as e: self.logger.debug(f"Could not get server stats: {e}") diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html index c9bf9b7..27f200f 100644 --- a/modules/web_viewer/templates/base.html +++ b/modules/web_viewer/templates/base.html @@ -193,6 +193,9 @@ + + +