mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 05:14:12 +00:00
feat: web viewer — auth, contact management, live streaming, config, maintenance, and backup
Auth (BUG-001): - Optional password via web_viewer_password in [Web_Viewer]; /login and /logout; Flask session guard on all routes and SocketIO handlers Contact management and export: - Star contacts of any type; purge-preview + purge inactive contacts - GET /api/export/contacts and /api/export/paths: CSV/JSON with time-range Config tab and maintenance: - /config page: SMTP, log rotation, DB backup settings in bot_metadata - Nightly email digest (uptime, contacts, DB size, log errors); SMTP timeout=30s; pre-rotation log attachment hook - GET /api/maintenance/status: Maintenance Status card DB backup, restore, and purge: - POST /api/maintenance/backup_now; GET /api/maintenance/list_backups; POST /api/maintenance/restore (SQLite magic-byte validation) - POST /api/maintenance/purge: remove rows older than threshold - Scheduled backups: daily/weekly/manual with retention pruning - Config save validates db_backup_dir exists; 400 on missing path Live streaming and realtime monitoring: - Live Activity panel: colour-coded SocketIO feed with pause/clear - capture_channel_message() feeds packet_stream; message_data event - /realtime page: three independent stream panels; [#channel] prefix - /logs page: subscribe_logs/log_line; log-tail thread; level colouring - History replay: last 50/50/200 items on connect - Werkzeug 3.1 WebSocket fix: _apply_werkzeug_websocket_fix() - BUG-029: db_path resolved via config_base = Path(config_path).parent; stored as self._config_base; dead _get_db_path() removed Scroll/filter controls and connected agents: - Scroll-to-top/bottom on Live Activity and all realtime panels - Type-filter checkboxes (Packets/Commands/Messages) with applyFilters() - GET /api/connected_clients: agent count clickable; Bootstrap modal
This commit is contained in:
@@ -6,6 +6,6 @@ allowing users to visualize bot databases and monitor real-time data.
|
||||
"""
|
||||
|
||||
from .app import BotDataViewer
|
||||
from .integration import WebViewerIntegration, BotIntegration
|
||||
from .integration import BotIntegration, WebViewerIntegration
|
||||
|
||||
__all__ = ['BotDataViewer', 'WebViewerIntegration', 'BotIntegration']
|
||||
|
||||
+1695
-930
File diff suppressed because it is too large
Load Diff
+153
-145
@@ -4,21 +4,22 @@ Web Viewer Integration for MeshCore Bot
|
||||
Provides integration between the main bot and the web viewer
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from contextlib import closing
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing, suppress
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..utils import resolve_path
|
||||
|
||||
|
||||
class BotIntegration:
|
||||
"""Simple bot integration for web viewer compatibility"""
|
||||
|
||||
|
||||
# After this many consecutive connection failures, stop sending until cooldown expires
|
||||
CIRCUIT_BREAKER_THRESHOLD = 3
|
||||
CIRCUIT_BREAKER_COOLDOWN_SEC = 60
|
||||
@@ -33,33 +34,34 @@ class BotIntegration:
|
||||
self._init_http_session()
|
||||
# Initialize the packet_stream table
|
||||
self._init_packet_stream_table()
|
||||
|
||||
|
||||
def _init_http_session(self):
|
||||
"""Initialize a requests.Session with connection pooling and keep-alive"""
|
||||
try:
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
import urllib3
|
||||
import logging
|
||||
|
||||
|
||||
# Suppress urllib3 connection pool messages when web viewer is unreachable
|
||||
# Connection refused / Retrying WARNINGs would flood logs during routing bursts
|
||||
urllib3_logger = logging.getLogger('urllib3.connectionpool')
|
||||
urllib3_logger.setLevel(logging.ERROR)
|
||||
|
||||
|
||||
# Also disable other urllib3 warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.NotOpenSSLWarning)
|
||||
|
||||
|
||||
self.http_session = requests.Session()
|
||||
|
||||
|
||||
# Configure retry strategy
|
||||
retry_strategy = Retry(
|
||||
total=2,
|
||||
backoff_factor=0.1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
|
||||
|
||||
# Mount adapter with connection pooling
|
||||
# pool_block=False allows non-blocking behavior if pool is full
|
||||
adapter = HTTPAdapter(
|
||||
@@ -70,7 +72,7 @@ class BotIntegration:
|
||||
)
|
||||
self.http_session.mount("http://", adapter)
|
||||
self.http_session.mount("https://", adapter)
|
||||
|
||||
|
||||
# Set default headers for keep-alive (though urllib3 handles this automatically)
|
||||
self.http_session.headers.update({
|
||||
'Connection': 'keep-alive',
|
||||
@@ -81,7 +83,7 @@ class BotIntegration:
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error initializing HTTP session: {e}")
|
||||
self.http_session = None
|
||||
|
||||
|
||||
def reset_circuit_breaker(self):
|
||||
"""Reset the circuit breaker"""
|
||||
self.circuit_breaker_open = False
|
||||
@@ -110,7 +112,7 @@ class BotIntegration:
|
||||
self.circuit_breaker_failures,
|
||||
self.CIRCUIT_BREAKER_COOLDOWN_SEC,
|
||||
)
|
||||
|
||||
|
||||
def _get_web_viewer_db_path(self):
|
||||
"""Return resolved database path for web viewer. Uses [Bot] db_path when [Web_Viewer] db_path is unset."""
|
||||
base_dir = self.bot.bot_root if hasattr(self.bot, 'bot_root') else '.'
|
||||
@@ -119,17 +121,17 @@ class BotIntegration:
|
||||
if raw:
|
||||
return resolve_path(raw, base_dir)
|
||||
return str(Path(self.bot.db_manager.db_path).resolve())
|
||||
|
||||
|
||||
def _init_packet_stream_table(self):
|
||||
"""Initialize the packet_stream table in the web viewer database (same as [Bot] db_path by default)."""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
|
||||
db_path = self._get_web_viewer_db_path()
|
||||
|
||||
|
||||
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
# Create packet_stream table with schema matching the INSERT statements
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS packet_stream (
|
||||
@@ -139,34 +141,34 @@ class BotIntegration:
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
# Create index on timestamp for faster queries
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp
|
||||
ON packet_stream(timestamp)
|
||||
''')
|
||||
|
||||
|
||||
# Create index on type for filtering by type
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_type
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_stream_type
|
||||
ON packet_stream(type)
|
||||
''')
|
||||
|
||||
|
||||
# Enable WAL for better concurrent access (bot + web viewer use same DB)
|
||||
try:
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Ignore if locked; WAL may already be set
|
||||
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
self.bot.logger.info(f"Initialized packet_stream table in {db_path}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.error(f"Failed to initialize packet_stream table: {e}")
|
||||
# Don't raise - allow bot to continue even if table init fails
|
||||
# The error will be caught when trying to insert data
|
||||
|
||||
|
||||
def _insert_packet_stream_row(self, data_json: str, row_type: str, log_prefix: str = "packet data"):
|
||||
"""Insert one row into packet_stream. Retries on database is locked. Logs and returns on failure."""
|
||||
import sqlite3
|
||||
@@ -192,20 +194,20 @@ class BotIntegration:
|
||||
except Exception as e:
|
||||
self.bot.logger.warning(f"Error storing {log_prefix} for web viewer: {e}")
|
||||
return
|
||||
|
||||
|
||||
def capture_full_packet_data(self, packet_data):
|
||||
"""Capture full packet data and store in database for web viewer"""
|
||||
try:
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Ensure packet_data is a dict (might be passed as dict already)
|
||||
if not isinstance(packet_data, dict):
|
||||
packet_data = self._make_json_serializable(packet_data)
|
||||
if not isinstance(packet_data, dict):
|
||||
# If still not a dict, wrap it
|
||||
packet_data = {'data': packet_data}
|
||||
|
||||
|
||||
# Add hops field from path_len if not already present
|
||||
# path_len represents the number of hops (each byte = 1 hop)
|
||||
if 'hops' not in packet_data and 'path_len' in packet_data:
|
||||
@@ -213,43 +215,43 @@ class BotIntegration:
|
||||
elif 'hops' not in packet_data:
|
||||
# If no path_len either, default to 0 hops
|
||||
packet_data['hops'] = 0
|
||||
|
||||
|
||||
# Add datetime for frontend display
|
||||
if 'datetime' not in packet_data:
|
||||
packet_data['datetime'] = datetime.now().isoformat()
|
||||
|
||||
|
||||
# Convert non-serializable objects to strings
|
||||
serializable_data = self._make_json_serializable(packet_data)
|
||||
|
||||
|
||||
# Store in database for web viewer to read (retries on database is locked)
|
||||
self._insert_packet_stream_row(json.dumps(serializable_data), 'packet', "packet data")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.warning(f"Error storing packet data for web viewer: {e}")
|
||||
|
||||
|
||||
def capture_command(self, message, command_name, response, success, command_id=None):
|
||||
"""Capture command data and store in database for web viewer"""
|
||||
try:
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
# Extract data from message object
|
||||
user = getattr(message, 'sender_id', 'Unknown')
|
||||
channel = getattr(message, 'channel', 'Unknown')
|
||||
user_input = getattr(message, 'content', f'/{command_name}')
|
||||
|
||||
|
||||
# Get repeat information if transmission tracker is available
|
||||
repeat_count = 0
|
||||
repeater_prefixes = []
|
||||
repeater_counts = {}
|
||||
if (hasattr(self.bot, 'transmission_tracker') and
|
||||
self.bot.transmission_tracker and
|
||||
if (hasattr(self.bot, 'transmission_tracker') and
|
||||
self.bot.transmission_tracker and
|
||||
command_id):
|
||||
repeat_info = self.bot.transmission_tracker.get_repeat_info(command_id=command_id)
|
||||
repeat_count = repeat_info.get('repeat_count', 0)
|
||||
repeater_prefixes = repeat_info.get('repeater_prefixes', [])
|
||||
repeater_counts = repeat_info.get('repeater_counts', {})
|
||||
|
||||
|
||||
# Construct command data structure
|
||||
command_data = {
|
||||
'user': user,
|
||||
@@ -264,30 +266,50 @@ class BotIntegration:
|
||||
'repeater_counts': repeater_counts, # Count per repeater prefix
|
||||
'command_id': command_id # Store command_id for later updates
|
||||
}
|
||||
|
||||
|
||||
# Convert non-serializable objects to strings
|
||||
serializable_data = self._make_json_serializable(command_data)
|
||||
|
||||
|
||||
# Store in database for web viewer to read (retries on database is locked)
|
||||
self._insert_packet_stream_row(json.dumps(serializable_data), 'command', "command data")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error storing command data: {e}")
|
||||
|
||||
|
||||
def capture_channel_message(self, message) -> None:
|
||||
"""Capture an incoming channel or DM message for the web viewer live monitor."""
|
||||
try:
|
||||
import json
|
||||
import time
|
||||
data = {
|
||||
'type': 'message',
|
||||
'timestamp': time.time(),
|
||||
'sender': getattr(message, 'sender_id', ''),
|
||||
'channel': getattr(message, 'channel', ''),
|
||||
'content': getattr(message, 'content', ''),
|
||||
'snr': str(getattr(message, 'snr', '')),
|
||||
'hops': getattr(message, 'hops', None),
|
||||
'path': getattr(message, 'path', ''),
|
||||
'is_dm': bool(getattr(message, 'is_dm', False)),
|
||||
}
|
||||
self._insert_packet_stream_row(json.dumps(data), 'message', "channel message")
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error storing channel message for web viewer: {e}")
|
||||
|
||||
def capture_packet_routing(self, routing_data):
|
||||
"""Capture packet routing data and store in database for web viewer"""
|
||||
try:
|
||||
import json
|
||||
|
||||
|
||||
# Convert non-serializable objects to strings
|
||||
serializable_data = self._make_json_serializable(routing_data)
|
||||
|
||||
|
||||
# Store in database for web viewer to read (retries on database is locked)
|
||||
self._insert_packet_stream_row(json.dumps(serializable_data), 'routing', "routing data")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error storing routing data: {e}")
|
||||
|
||||
|
||||
def cleanup_old_data(self, days_to_keep: Optional[int] = None):
|
||||
"""Clean up old packet stream data to prevent database bloat.
|
||||
Uses [Data_Retention] packet_stream_retention_days when days_to_keep is not provided."""
|
||||
@@ -298,34 +320,32 @@ class BotIntegration:
|
||||
if days_to_keep is None:
|
||||
days_to_keep = 3
|
||||
if self.bot.config.has_section('Data_Retention') and self.bot.config.has_option('Data_Retention', 'packet_stream_retention_days'):
|
||||
try:
|
||||
with suppress(ValueError, TypeError):
|
||||
days_to_keep = self.bot.config.getint('Data_Retention', 'packet_stream_retention_days')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
cutoff_time = time.time() - (days_to_keep * 24 * 60 * 60)
|
||||
|
||||
|
||||
db_path = self._get_web_viewer_db_path()
|
||||
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
# Clean up old packet stream data
|
||||
cursor.execute('DELETE FROM packet_stream WHERE timestamp < ?', (cutoff_time,))
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
if deleted_count > 0:
|
||||
self.bot.logger.info(f"Cleaned up {deleted_count} old packet stream entries (older than {days_to_keep} days)")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.bot.logger.error(f"Error cleaning up old packet stream data: {e}")
|
||||
|
||||
|
||||
def _make_json_serializable(self, obj, depth=0, max_depth=3):
|
||||
"""Convert non-JSON-serializable objects to strings with depth limiting"""
|
||||
if depth > max_depth:
|
||||
return str(obj)
|
||||
|
||||
|
||||
# Handle basic types first
|
||||
if obj is None or isinstance(obj, (str, int, float, bool)):
|
||||
return obj
|
||||
@@ -345,7 +365,7 @@ class BotIntegration:
|
||||
return str(obj)
|
||||
else:
|
||||
return str(obj)
|
||||
|
||||
|
||||
def send_mesh_edge_update(self, edge_data):
|
||||
"""Send mesh edge update to web viewer via HTTP API"""
|
||||
try:
|
||||
@@ -355,12 +375,12 @@ class BotIntegration:
|
||||
host = self.bot.config.get('Web_Viewer', 'host', fallback='127.0.0.1')
|
||||
port = self.bot.config.getint('Web_Viewer', 'port', fallback=8080)
|
||||
url = f"http://{host}:{port}/api/stream_data"
|
||||
|
||||
|
||||
payload = {
|
||||
'type': 'mesh_edge',
|
||||
'data': edge_data
|
||||
}
|
||||
|
||||
|
||||
# Use session with connection pooling if available, otherwise fallback to requests.post
|
||||
if self.http_session:
|
||||
try:
|
||||
@@ -377,7 +397,7 @@ class BotIntegration:
|
||||
self._record_web_viewer_result(False)
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error sending mesh edge update to web viewer: {e}")
|
||||
|
||||
|
||||
def send_mesh_node_update(self, node_data):
|
||||
"""Send mesh node update to web viewer via HTTP API"""
|
||||
try:
|
||||
@@ -388,12 +408,12 @@ class BotIntegration:
|
||||
host = self.bot.config.get('Web_Viewer', 'host', fallback='127.0.0.1')
|
||||
port = self.bot.config.getint('Web_Viewer', 'port', fallback=8080)
|
||||
url = f"http://{host}:{port}/api/stream_data"
|
||||
|
||||
|
||||
payload = {
|
||||
'type': 'mesh_node',
|
||||
'data': node_data
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=0.5)
|
||||
self._record_web_viewer_result(True)
|
||||
@@ -401,55 +421,53 @@ class BotIntegration:
|
||||
self._record_web_viewer_result(False)
|
||||
except Exception as e:
|
||||
self.bot.logger.debug(f"Error sending mesh node update to web viewer: {e}")
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Mark as shutting down and close HTTP session"""
|
||||
self.is_shutting_down = True
|
||||
# Close HTTP session to clean up connections
|
||||
if hasattr(self, 'http_session') and self.http_session:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
self.http_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class WebViewerIntegration:
|
||||
"""Integration class for starting/stopping the web viewer with the bot"""
|
||||
|
||||
|
||||
# Whitelist of allowed host bindings for security
|
||||
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0']
|
||||
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.logger = bot.logger
|
||||
self.viewer_process = None
|
||||
self.viewer_thread = None
|
||||
self.running = False
|
||||
|
||||
|
||||
# File handles for subprocess stdout/stderr (for proper cleanup)
|
||||
self._viewer_stdout_file = None
|
||||
self._viewer_stderr_file = None
|
||||
|
||||
|
||||
# Get web viewer settings from config
|
||||
self.enabled = bot.config.getboolean('Web_Viewer', 'enabled', fallback=False)
|
||||
self.host = bot.config.get('Web_Viewer', 'host', fallback='127.0.0.1')
|
||||
self.port = bot.config.getint('Web_Viewer', 'port', fallback=8080) # Web viewer uses 8080
|
||||
self.debug = bot.config.getboolean('Web_Viewer', 'debug', fallback=False)
|
||||
self.auto_start = bot.config.getboolean('Web_Viewer', 'auto_start', fallback=False)
|
||||
|
||||
|
||||
# Validate configuration for security
|
||||
self._validate_config()
|
||||
|
||||
|
||||
# Process monitoring
|
||||
self.restart_count = 0
|
||||
self.max_restarts = 5
|
||||
self.last_restart = 0
|
||||
|
||||
|
||||
# Initialize bot integration for compatibility
|
||||
self.bot_integration = BotIntegration(bot)
|
||||
|
||||
|
||||
if self.enabled and self.auto_start:
|
||||
self.start_viewer()
|
||||
|
||||
|
||||
def _validate_config(self):
|
||||
"""Validate web viewer configuration for security"""
|
||||
# Validate host against whitelist
|
||||
@@ -458,13 +476,13 @@ class WebViewerIntegration:
|
||||
f"Invalid host configuration: {self.host}. "
|
||||
f"Allowed hosts: {', '.join(self.ALLOWED_HOSTS)}"
|
||||
)
|
||||
|
||||
|
||||
# Validate port range (avoid privileged ports)
|
||||
if not isinstance(self.port, int) or not (1024 <= self.port <= 65535):
|
||||
raise ValueError(
|
||||
f"Port must be between 1024-65535 (non-privileged), got: {self.port}"
|
||||
)
|
||||
|
||||
|
||||
# Security warning for network exposure
|
||||
if self.host == '0.0.0.0':
|
||||
self.logger.warning(
|
||||
@@ -475,31 +493,31 @@ class WebViewerIntegration:
|
||||
"For local-only access, use host=127.0.0.1 in config.\n"
|
||||
+ "="*70
|
||||
)
|
||||
|
||||
|
||||
def start_viewer(self):
|
||||
"""Start the web viewer in a separate thread"""
|
||||
if self.running:
|
||||
self.logger.warning("Web viewer is already running")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Start the web viewer
|
||||
self.viewer_thread = threading.Thread(target=self._run_viewer, daemon=True)
|
||||
self.viewer_thread.start()
|
||||
self.running = True
|
||||
self.logger.info(f"Web viewer started on http://{self.host}:{self.port}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start web viewer: {e}")
|
||||
|
||||
|
||||
def stop_viewer(self):
|
||||
"""Stop the web viewer"""
|
||||
if not self.running and not self.viewer_process:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
self.running = False
|
||||
|
||||
|
||||
if self.viewer_process and self.viewer_process.poll() is None:
|
||||
self.logger.info("Stopping web viewer...")
|
||||
try:
|
||||
@@ -520,7 +538,7 @@ class WebViewerIntegration:
|
||||
self.logger.warning(f"Error during web viewer shutdown: {e}")
|
||||
finally:
|
||||
self.viewer_process = None
|
||||
|
||||
|
||||
# Close log file handles
|
||||
if self._viewer_stdout_file:
|
||||
try:
|
||||
@@ -529,7 +547,7 @@ class WebViewerIntegration:
|
||||
self.logger.debug(f"Error closing stdout file: {e}")
|
||||
finally:
|
||||
self._viewer_stdout_file = None
|
||||
|
||||
|
||||
if self._viewer_stderr_file:
|
||||
try:
|
||||
self._viewer_stderr_file.close()
|
||||
@@ -537,14 +555,13 @@ class WebViewerIntegration:
|
||||
self.logger.debug(f"Error closing stderr file: {e}")
|
||||
finally:
|
||||
self._viewer_stderr_file = None
|
||||
|
||||
|
||||
if not self.viewer_process:
|
||||
self.logger.info("Web viewer already stopped")
|
||||
|
||||
|
||||
# Additional cleanup: kill any remaining processes on the port
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(['lsof', '-ti', f':{self.port}'],
|
||||
result = subprocess.run(['lsof', '-ti', f':{self.port}'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
@@ -552,41 +569,41 @@ class WebViewerIntegration:
|
||||
pid = pid.strip()
|
||||
if not pid:
|
||||
continue
|
||||
|
||||
|
||||
# Validate PID is numeric only (prevent injection)
|
||||
if not re.match(r'^\d+$', pid):
|
||||
self.logger.warning(f"Invalid PID format: {pid}, skipping")
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
# Safety check: never kill system PIDs
|
||||
if pid_int < 2:
|
||||
self.logger.warning(f"Refusing to kill system PID: {pid}")
|
||||
continue
|
||||
|
||||
|
||||
subprocess.run(['kill', '-9', str(pid_int)], timeout=2)
|
||||
self.logger.info(f"Killed remaining process {pid} on port {self.port}")
|
||||
except (ValueError, subprocess.TimeoutExpired) as e:
|
||||
self.logger.warning(f"Failed to kill process {pid}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Port cleanup check failed: {e}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping web viewer: {e}")
|
||||
|
||||
|
||||
def _run_viewer(self):
|
||||
"""Run the web viewer in a separate process"""
|
||||
stdout_file = None
|
||||
stderr_file = None
|
||||
|
||||
|
||||
try:
|
||||
# Get the path to the web viewer script
|
||||
viewer_script = Path(__file__).parent / "app.py"
|
||||
# Use same config as bot so viewer finds db_path, Greeter_Command, etc.
|
||||
config_path = getattr(self.bot, 'config_file', 'config.ini')
|
||||
config_path = str(Path(config_path).resolve()) if config_path else 'config.ini'
|
||||
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
@@ -595,13 +612,13 @@ class WebViewerIntegration:
|
||||
"--host", self.host,
|
||||
"--port", str(self.port)
|
||||
]
|
||||
|
||||
|
||||
if self.debug:
|
||||
cmd.append("--debug")
|
||||
|
||||
|
||||
# Ensure logs directory exists
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
|
||||
|
||||
# Open log files in write mode to prevent buffer blocking
|
||||
# This fixes the issue where subprocess.PIPE buffers (~64KB) fill up
|
||||
# after ~5 minutes and cause the subprocess to hang.
|
||||
@@ -611,11 +628,11 @@ class WebViewerIntegration:
|
||||
# - Prevents unbounded log file growth
|
||||
stdout_file = open('logs/web_viewer_stdout.log', 'w')
|
||||
stderr_file = open('logs/web_viewer_stderr.log', 'w')
|
||||
|
||||
|
||||
# Store file handles for proper cleanup
|
||||
self._viewer_stdout_file = stdout_file
|
||||
self._viewer_stderr_file = stderr_file
|
||||
|
||||
|
||||
# Start the viewer process with log file redirection
|
||||
self.viewer_process = subprocess.Popen(
|
||||
cmd,
|
||||
@@ -623,99 +640,93 @@ class WebViewerIntegration:
|
||||
stderr=stderr_file,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
# Give it a moment to start up
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
# Check if it started successfully
|
||||
if self.viewer_process and self.viewer_process.poll() is not None:
|
||||
# Process failed immediately - read from log files for error reporting
|
||||
stdout_file.flush()
|
||||
stderr_file.flush()
|
||||
|
||||
|
||||
# Read last few lines from stderr for error reporting
|
||||
try:
|
||||
stderr_file.close()
|
||||
with open('logs/web_viewer_stderr.log', 'r') as f:
|
||||
with open('logs/web_viewer_stderr.log') as f:
|
||||
stderr_lines = f.readlines()[-20:] # Last 20 lines
|
||||
stderr = ''.join(stderr_lines)
|
||||
except Exception:
|
||||
stderr = "Could not read stderr log"
|
||||
|
||||
|
||||
# Read last few lines from stdout for error reporting
|
||||
try:
|
||||
stdout_file.close()
|
||||
with open('logs/web_viewer_stdout.log', 'r') as f:
|
||||
with open('logs/web_viewer_stdout.log') as f:
|
||||
stdout_lines = f.readlines()[-20:] # Last 20 lines
|
||||
stdout = ''.join(stdout_lines)
|
||||
except Exception:
|
||||
stdout = "Could not read stdout log"
|
||||
|
||||
|
||||
self.logger.error(f"Web viewer failed to start. Return code: {self.viewer_process.returncode}")
|
||||
if stderr and stderr.strip():
|
||||
self.logger.error(f"Web viewer startup error: {stderr}")
|
||||
if stdout and stdout.strip():
|
||||
self.logger.error(f"Web viewer startup output: {stdout}")
|
||||
|
||||
|
||||
self.viewer_process = None
|
||||
self._viewer_stdout_file = None
|
||||
self._viewer_stderr_file = None
|
||||
return
|
||||
|
||||
|
||||
# Web viewer is ready
|
||||
self.logger.info("Web viewer integration ready for data streaming")
|
||||
|
||||
|
||||
# Monitor the process
|
||||
while self.running and self.viewer_process and self.viewer_process.poll() is None:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# Process exited - read from log files for error reporting if needed
|
||||
if self.viewer_process and self.viewer_process.returncode != 0:
|
||||
stdout_file.flush()
|
||||
stderr_file.flush()
|
||||
|
||||
|
||||
# Read last few lines from stderr for error reporting
|
||||
try:
|
||||
stderr_file.close()
|
||||
with open('logs/web_viewer_stderr.log', 'r') as f:
|
||||
with open('logs/web_viewer_stderr.log') as f:
|
||||
stderr_lines = f.readlines()[-20:] # Last 20 lines
|
||||
stderr = ''.join(stderr_lines)
|
||||
except Exception:
|
||||
stderr = "Could not read stderr log"
|
||||
|
||||
|
||||
# Close stdout file as well
|
||||
try:
|
||||
with suppress(Exception):
|
||||
stdout_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
self.logger.error(f"Web viewer process exited with code {self.viewer_process.returncode}")
|
||||
if stderr and stderr.strip():
|
||||
self.logger.error(f"Web viewer stderr: {stderr}")
|
||||
|
||||
|
||||
self._viewer_stdout_file = None
|
||||
self._viewer_stderr_file = None
|
||||
elif self.viewer_process and self.viewer_process.returncode == 0:
|
||||
self.logger.info("Web viewer process exited normally")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error running web viewer: {e}")
|
||||
# Close file handles on error
|
||||
if stdout_file:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
stdout_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_file:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
stderr_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._viewer_stdout_file = None
|
||||
self._viewer_stderr_file = None
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
|
||||
def get_status(self):
|
||||
"""Get the current status of the web viewer"""
|
||||
return {
|
||||
@@ -727,37 +738,34 @@ class WebViewerIntegration:
|
||||
'auto_start': self.auto_start,
|
||||
'url': f"http://{self.host}:{self.port}" if self.running else None
|
||||
}
|
||||
|
||||
|
||||
def restart_viewer(self):
|
||||
"""Restart the web viewer with rate limiting"""
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# Rate limit restarts to prevent restart loops
|
||||
if current_time - self.last_restart < 30: # 30 seconds between restarts
|
||||
self.logger.warning("Restart rate limited - too soon since last restart")
|
||||
return
|
||||
|
||||
|
||||
if self.restart_count >= self.max_restarts:
|
||||
self.logger.error(f"Maximum restart limit reached ({self.max_restarts}). Web viewer disabled.")
|
||||
self.enabled = False
|
||||
return
|
||||
|
||||
|
||||
self.restart_count += 1
|
||||
self.last_restart = current_time
|
||||
|
||||
|
||||
self.logger.info(f"Restarting web viewer (attempt {self.restart_count}/{self.max_restarts})...")
|
||||
self.stop_viewer()
|
||||
time.sleep(3) # Give it more time to stop
|
||||
|
||||
|
||||
self.start_viewer()
|
||||
|
||||
|
||||
def is_viewer_healthy(self):
|
||||
"""Check if the web viewer process is healthy"""
|
||||
if not self.viewer_process:
|
||||
return False
|
||||
|
||||
|
||||
# Check if process is still running
|
||||
if self.viewer_process.poll() is not None:
|
||||
return False
|
||||
|
||||
return True
|
||||
return self.viewer_process.poll() is None
|
||||
|
||||
@@ -399,6 +399,16 @@
|
||||
<i class="fas fa-broadcast-tower"></i> Radio
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/config">
|
||||
<i class="fas fa-cog"></i> Config
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/logs">
|
||||
<i class="fas fa-file-alt"></i> Logs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Dark Mode Toggle and Connection Status -->
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Configuration - MeshCore Bot{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">
|
||||
<i class="fas fa-cog"></i> Configuration
|
||||
</h1>
|
||||
|
||||
<!-- Email / Notifications -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-envelope me-2"></i>Email & Notifications</h5>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="nightly-enabled-toggle" role="switch">
|
||||
<label class="form-check-label" for="nightly-enabled-toggle">
|
||||
Nightly maintenance email
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-4">
|
||||
When enabled, a nightly digest is sent summarising maintenance activity
|
||||
(log rotation, database backup, data retention, error counts).
|
||||
All fields are stored in the bot database — no config.ini edit required.
|
||||
</p>
|
||||
|
||||
<form id="notifications-form" novalidate>
|
||||
|
||||
<!-- SMTP server -->
|
||||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||||
SMTP Server
|
||||
</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="smtp-host">Server hostname</label>
|
||||
<input type="text" class="form-control" id="smtp-host"
|
||||
placeholder="smtp.example.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="smtp-port">Port</label>
|
||||
<input type="number" class="form-control" id="smtp-port"
|
||||
placeholder="587" min="1" max="65535">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="smtp-security">Security</label>
|
||||
<select class="form-select" id="smtp-security">
|
||||
<option value="starttls">STARTTLS (port 587)</option>
|
||||
<option value="ssl">SSL / TLS (port 465)</option>
|
||||
<option value="none">None / plain (port 25)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credentials -->
|
||||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||||
Credentials
|
||||
</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="smtp-user">Username / email</label>
|
||||
<input type="text" class="form-control" id="smtp-user"
|
||||
placeholder="user@example.com" autocomplete="username">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="smtp-password">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp-password"
|
||||
placeholder="••••••••" autocomplete="current-password">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
id="toggle-password" title="Show / hide password">
|
||||
<i class="fas fa-eye" id="toggle-password-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Stored in the bot database. Use an app-specific password where supported.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sender -->
|
||||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||||
Sender
|
||||
</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="from-name">Sender display name</label>
|
||||
<input type="text" class="form-control" id="from-name"
|
||||
placeholder="MeshCore Bot">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="from-email">Sender email address</label>
|
||||
<input type="email" class="form-control" id="from-email"
|
||||
placeholder="bot@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipients -->
|
||||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||||
Recipients
|
||||
</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="recipients">
|
||||
Recipient email address(es)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="recipients"
|
||||
placeholder="admin@example.com, ops@example.com">
|
||||
<div class="form-text">Separate multiple addresses with commas.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn btn-primary" id="save-notifications-btn">
|
||||
<i class="fas fa-save me-1"></i>Save settings
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="test-email-btn">
|
||||
<i class="fas fa-paper-plane me-1"></i>Send test email
|
||||
</button>
|
||||
<span id="save-status" class="ms-2 small" style="display:none;"></span>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- /.card -->
|
||||
|
||||
<!-- Log Rotation -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-sync-alt me-2"></i>Log Rotation</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-4">
|
||||
Controls when the log file is rotated and how many backup files are kept.
|
||||
Changes apply within 60 seconds without a restart.
|
||||
</p>
|
||||
<form id="log-rotation-form" novalidate>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="log-max-bytes">Max file size (bytes)</label>
|
||||
<input type="number" class="form-control" id="log-max-bytes"
|
||||
placeholder="5242880" min="102400">
|
||||
<div class="form-text">Default: 5 242 880 (5 MB). Minimum: 100 KB.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="log-backup-count">Backup file count</label>
|
||||
<input type="number" class="form-control" id="log-backup-count"
|
||||
placeholder="3" min="1" max="20">
|
||||
<div class="form-text">Number of rotated backups to keep (e.g. .log.1, .log.2…).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn btn-primary" id="save-log-rotation-btn">
|
||||
<i class="fas fa-save me-1"></i>Save
|
||||
</button>
|
||||
<span id="log-rotation-status" class="ms-2 small" style="display:none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- /.card -->
|
||||
|
||||
<!-- Database Backup -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-database me-2"></i>Database Backup</h5>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="db-backup-enabled-toggle" role="switch">
|
||||
<label class="form-check-label" for="db-backup-enabled-toggle">
|
||||
Automatic backup enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-4">
|
||||
Creates a consistent SQLite backup using the native backup API.
|
||||
Old backups beyond the retention count are automatically pruned.
|
||||
</p>
|
||||
<form id="db-backup-form" novalidate>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="db-backup-schedule">Schedule</label>
|
||||
<select class="form-select" id="db-backup-schedule">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly (Monday)</option>
|
||||
<option value="manual">Manual only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="db-backup-time">Backup time (HH:MM)</label>
|
||||
<input type="time" class="form-control" id="db-backup-time" value="02:00">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="db-backup-retention-count">Backups to keep</label>
|
||||
<input type="number" class="form-control" id="db-backup-retention-count"
|
||||
placeholder="7" min="1" max="90">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="db-backup-dir">Backup directory</label>
|
||||
<input type="text" class="form-control" id="db-backup-dir"
|
||||
placeholder="/data/backups">
|
||||
<div class="form-text">Absolute path. The directory will be created if it does not exist.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="email-attach-log">
|
||||
<label class="form-check-label" for="email-attach-log">
|
||||
Attach current log file to nightly email
|
||||
<span class="text-muted small">(only if file is ≤ 5 MB)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn btn-primary" id="save-db-backup-btn">
|
||||
<i class="fas fa-save me-1"></i>Save
|
||||
</button>
|
||||
<span id="db-backup-status" class="ms-2 small" style="display:none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- /.card -->
|
||||
|
||||
<!-- Maintenance Status -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>Maintenance Status</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="refresh-status-btn">
|
||||
<i class="fas fa-redo me-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0" id="maintenance-status-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Last ran (UTC)</th>
|
||||
<th>Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="maintenance-status-body">
|
||||
<tr><td colspan="3" class="text-center text-muted py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- /.card -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.fields = {
|
||||
smtp_host: document.getElementById('smtp-host'),
|
||||
smtp_port: document.getElementById('smtp-port'),
|
||||
smtp_security: document.getElementById('smtp-security'),
|
||||
smtp_user: document.getElementById('smtp-user'),
|
||||
smtp_password: document.getElementById('smtp-password'),
|
||||
from_name: document.getElementById('from-name'),
|
||||
from_email: document.getElementById('from-email'),
|
||||
recipients: document.getElementById('recipients'),
|
||||
};
|
||||
this.nightlyToggle = document.getElementById('nightly-enabled-toggle');
|
||||
this.saveBtn = document.getElementById('save-notifications-btn');
|
||||
this.testBtn = document.getElementById('test-email-btn');
|
||||
this.saveStatus = document.getElementById('save-status');
|
||||
this.togglePwdBtn = document.getElementById('toggle-password');
|
||||
this.togglePwdIcon = document.getElementById('toggle-password-icon');
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.loadSettings();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const resp = await fetch('/api/config/notifications');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.smtp_host) this.fields.smtp_host.value = data.smtp_host;
|
||||
if (data.smtp_port) this.fields.smtp_port.value = data.smtp_port;
|
||||
if (data.smtp_security) this.fields.smtp_security.value = data.smtp_security;
|
||||
if (data.smtp_user) this.fields.smtp_user.value = data.smtp_user;
|
||||
// Never pre-fill password from server; server returns it but we show placeholder
|
||||
if (data.smtp_password && data.smtp_password !== '') {
|
||||
this.fields.smtp_password.placeholder = '(saved — enter new value to change)';
|
||||
}
|
||||
if (data.from_name) this.fields.from_name.value = data.from_name;
|
||||
if (data.from_email) this.fields.from_email.value = data.from_email;
|
||||
if (data.recipients) this.fields.recipients.value = data.recipients;
|
||||
|
||||
this.nightlyToggle.checked = data.nightly_enabled === 'true';
|
||||
} catch (err) {
|
||||
this.showStatus('Failed to load settings: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventHandlers() {
|
||||
this.saveBtn.addEventListener('click', () => this.saveSettings());
|
||||
|
||||
this.testBtn.addEventListener('click', () => this.sendTestEmail());
|
||||
|
||||
// Security preset — update port suggestion when security changes
|
||||
this.fields.smtp_security.addEventListener('change', () => {
|
||||
const portField = this.fields.smtp_port;
|
||||
const sec = this.fields.smtp_security.value;
|
||||
if (!portField.value || ['25','465','587'].includes(portField.value)) {
|
||||
if (sec === 'ssl') portField.value = '465';
|
||||
else if (sec === 'none') portField.value = '25';
|
||||
else portField.value = '587';
|
||||
}
|
||||
});
|
||||
|
||||
// Password reveal toggle
|
||||
this.togglePwdBtn.addEventListener('click', () => {
|
||||
const pwdField = this.fields.smtp_password;
|
||||
if (pwdField.type === 'password') {
|
||||
pwdField.type = 'text';
|
||||
this.togglePwdIcon.classList.replace('fa-eye', 'fa-eye-slash');
|
||||
} else {
|
||||
pwdField.type = 'password';
|
||||
this.togglePwdIcon.classList.replace('fa-eye-slash', 'fa-eye');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildPayload() {
|
||||
const payload = {
|
||||
nightly_enabled: this.nightlyToggle.checked ? 'true' : 'false',
|
||||
};
|
||||
for (const [key, el] of Object.entries(this.fields)) {
|
||||
// Only send password if the user actually typed something
|
||||
if (key === 'smtp_password' && !el.value) continue;
|
||||
payload[key] = el.value.trim();
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving...';
|
||||
this.hideStatus();
|
||||
try {
|
||||
const resp = await fetch('/api/config/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.buildPayload()),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Save failed');
|
||||
this.showStatus('Settings saved.', 'success');
|
||||
// Clear password field after save so it shows placeholder again
|
||||
if (this.fields.smtp_password.value) {
|
||||
this.fields.smtp_password.value = '';
|
||||
this.fields.smtp_password.placeholder = '(saved — enter new value to change)';
|
||||
}
|
||||
} catch (err) {
|
||||
this.showStatus('Save failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
this.saveBtn.disabled = false;
|
||||
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save settings';
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail() {
|
||||
this.testBtn.disabled = true;
|
||||
this.testBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Sending...';
|
||||
this.hideStatus();
|
||||
try {
|
||||
const resp = await fetch('/api/config/notifications/test', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Send failed');
|
||||
this.showStatus('Test email sent successfully.', 'success');
|
||||
} catch (err) {
|
||||
this.showStatus('Test failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
this.testBtn.disabled = false;
|
||||
this.testBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Send test email';
|
||||
}
|
||||
}
|
||||
|
||||
showStatus(msg, type) {
|
||||
this.saveStatus.textContent = msg;
|
||||
this.saveStatus.className = `ms-2 small text-${type}`;
|
||||
this.saveStatus.style.display = 'inline';
|
||||
if (type === 'success') {
|
||||
setTimeout(() => this.hideStatus(), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
hideStatus() {
|
||||
this.saveStatus.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new ConfigManager();
|
||||
});
|
||||
|
||||
// ── Log Rotation ─────────────────────────────────────────────────────────────
|
||||
class LogRotationManager {
|
||||
constructor() {
|
||||
this.maxBytesEl = document.getElementById('log-max-bytes');
|
||||
this.backupCountEl = document.getElementById('log-backup-count');
|
||||
this.saveBtn = document.getElementById('save-log-rotation-btn');
|
||||
this.statusEl = document.getElementById('log-rotation-status');
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
const resp = await fetch('/api/config/logging');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (data.log_max_bytes) this.maxBytesEl.value = data.log_max_bytes;
|
||||
if (data.log_backup_count) this.backupCountEl.value = data.log_backup_count;
|
||||
} catch (err) {
|
||||
this.show('Failed to load: ' + err.message, 'danger');
|
||||
}
|
||||
this.saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving…';
|
||||
this.hide();
|
||||
try {
|
||||
const resp = await fetch('/api/config/logging', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
log_max_bytes: this.maxBytesEl.value,
|
||||
log_backup_count: this.backupCountEl.value,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Save failed');
|
||||
this.show('Saved. Changes apply within 60 seconds.', 'success');
|
||||
} catch (err) {
|
||||
this.show('Save failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
this.saveBtn.disabled = false;
|
||||
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
|
||||
}
|
||||
}
|
||||
|
||||
show(msg, type) {
|
||||
this.statusEl.textContent = msg;
|
||||
this.statusEl.className = `ms-2 small text-${type}`;
|
||||
this.statusEl.style.display = 'inline';
|
||||
if (type === 'success') setTimeout(() => this.hide(), 4000);
|
||||
}
|
||||
hide() { this.statusEl.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// ── DB Backup ─────────────────────────────────────────────────────────────────
|
||||
class DbBackupManager {
|
||||
constructor() {
|
||||
this.enabledToggle = document.getElementById('db-backup-enabled-toggle');
|
||||
this.scheduleEl = document.getElementById('db-backup-schedule');
|
||||
this.timeEl = document.getElementById('db-backup-time');
|
||||
this.retentionEl = document.getElementById('db-backup-retention-count');
|
||||
this.dirEl = document.getElementById('db-backup-dir');
|
||||
this.attachLogEl = document.getElementById('email-attach-log');
|
||||
this.saveBtn = document.getElementById('save-db-backup-btn');
|
||||
this.statusEl = document.getElementById('db-backup-status');
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
const resp = await fetch('/api/config/maintenance');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this.enabledToggle.checked = data.db_backup_enabled === 'true';
|
||||
if (data.db_backup_schedule) this.scheduleEl.value = data.db_backup_schedule;
|
||||
if (data.db_backup_time) this.timeEl.value = data.db_backup_time;
|
||||
if (data.db_backup_retention_count) this.retentionEl.value = data.db_backup_retention_count;
|
||||
if (data.db_backup_dir) this.dirEl.value = data.db_backup_dir;
|
||||
this.attachLogEl.checked = data.email_attach_log === 'true';
|
||||
} catch (err) {
|
||||
this.show('Failed to load: ' + err.message, 'danger');
|
||||
}
|
||||
this.saveBtn.addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Saving…';
|
||||
this.hide();
|
||||
try {
|
||||
const resp = await fetch('/api/config/maintenance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
db_backup_enabled: this.enabledToggle.checked ? 'true' : 'false',
|
||||
db_backup_schedule: this.scheduleEl.value,
|
||||
db_backup_time: this.timeEl.value,
|
||||
db_backup_retention_count: this.retentionEl.value,
|
||||
db_backup_dir: this.dirEl.value.trim(),
|
||||
email_attach_log: this.attachLogEl.checked ? 'true' : 'false',
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Save failed');
|
||||
this.show('Settings saved.', 'success');
|
||||
} catch (err) {
|
||||
this.show('Save failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
this.saveBtn.disabled = false;
|
||||
this.saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
|
||||
}
|
||||
}
|
||||
|
||||
show(msg, type) {
|
||||
this.statusEl.textContent = msg;
|
||||
this.statusEl.className = `ms-2 small text-${type}`;
|
||||
this.statusEl.style.display = 'inline';
|
||||
if (type === 'success') setTimeout(() => this.hide(), 4000);
|
||||
}
|
||||
hide() { this.statusEl.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// ── Maintenance Status ────────────────────────────────────────────────────────
|
||||
class MaintenanceStatusManager {
|
||||
constructor() {
|
||||
this.tbody = document.getElementById('maintenance-status-body');
|
||||
this.refreshBtn = document.getElementById('refresh-status-btn');
|
||||
this.refreshBtn.addEventListener('click', () => this.load());
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.refreshBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/maintenance/status');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const d = await resp.json();
|
||||
|
||||
const jobs = [
|
||||
{ label: 'Data retention', ran: d.data_retention_ran_at, outcome: d.data_retention_outcome },
|
||||
{ label: 'Nightly email', ran: d.nightly_email_ran_at, outcome: d.nightly_email_outcome },
|
||||
{ label: 'Database backup', ran: d.db_backup_ran_at, outcome: d.db_backup_outcome },
|
||||
{ label: 'Log rotation (applied)', ran: d.log_rotation_applied_at, outcome: '' },
|
||||
];
|
||||
|
||||
this.tbody.innerHTML = jobs.map(j => {
|
||||
const badge = !j.ran ? '<span class="text-muted">never</span>'
|
||||
: `<span class="text-muted">${j.ran.replace('T', ' ').split('.')[0]}</span>`;
|
||||
const outBadge = !j.outcome ? ''
|
||||
: j.outcome === 'ok'
|
||||
? '<span class="badge bg-success">ok</span>'
|
||||
: `<span class="badge bg-danger" title="${j.outcome}">error</span>`;
|
||||
return `<tr><td>${j.label}</td><td>${badge}</td><td>${outBadge}</td></tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
this.tbody.innerHTML = `<tr><td colspan="3" class="text-danger text-center">Failed to load: ${err.message}</td></tr>`;
|
||||
} finally {
|
||||
this.refreshBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new LogRotationManager();
|
||||
new DbBackupManager();
|
||||
new MaintenanceStatusManager();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -135,6 +135,38 @@
|
||||
<button class="btn btn-sm btn-primary" id="refresh-contacts">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" id="purge-contacts-btn"
|
||||
data-bs-toggle="modal" data-bs-target="#purgeContactsModal"
|
||||
title="Delete contacts not heard recently">
|
||||
<i class="fas fa-broom"></i> Purge Inactive
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" title="Export data">
|
||||
<i class="fas fa-download me-1"></i>Export
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><h6 class="dropdown-header">Contacts</h6></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportData('contacts','csv');return false;"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportData('contacts','json');return false;"><i class="fas fa-file-code me-2 text-warning"></i>JSON</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Paths</h6></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportData('paths','csv');return false;"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportData('paths','json');return false;"><i class="fas fa-file-code me-2 text-warning"></i>JSON</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Time range</h6></li>
|
||||
<li>
|
||||
<div class="px-3 py-1">
|
||||
<select class="form-select form-select-sm" id="export-since">
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d" selected>Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
@@ -187,6 +219,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge Inactive Contacts Modal -->
|
||||
<div class="modal fade" id="purgeContactsModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-broom text-danger me-2"></i>Purge Inactive Contacts</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete contacts that have not been heard within a threshold. This removes them from the database entirely.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Remove contacts not heard in:</label>
|
||||
<select class="form-select" id="purge-days-select">
|
||||
<option value="7">7 days</option>
|
||||
<option value="14">14 days</option>
|
||||
<option value="30" selected>30 days</option>
|
||||
<option value="60">60 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="purge-preview-area" class="alert alert-secondary py-2 mb-0" style="min-height:2.5rem;">
|
||||
<span id="purge-preview-text"><i class="fas fa-spinner fa-spin me-1"></i>Loading preview...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-purge-btn" disabled>
|
||||
<i class="fas fa-broom me-1"></i><span id="confirm-purge-label">Purge</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -480,12 +546,9 @@ class ModernContactsManager {
|
||||
<td><span class="badge bg-success">${contact.advert_count || 0}</span></td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
${contact.role && (contact.role.toLowerCase() === 'repeater' || contact.role.toLowerCase() === 'roomserver') ?
|
||||
`<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" onclick="contactsManager.toggleStar('${contact.user_id.replace(/'/g, "\\'")}', this)" title="${contact.is_starred ? 'Unstar contact (removes path bias)' : 'Star contact (adds strong path bias)'}">
|
||||
<i class="fas ${contact.is_starred ? 'fa-star' : 'fa-star'}"></i>
|
||||
</button>` :
|
||||
''
|
||||
}
|
||||
<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" onclick="contactsManager.toggleStar('${contact.user_id.replace(/'/g, "\\'")}', this)" title="${contact.is_starred ? 'Unstar contact' : 'Star contact'}">
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="contactsManager.viewAdvertData('${contact.user_id}')" title="View Advertisement Data">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</button>
|
||||
@@ -1930,11 +1993,93 @@ class ModernContactsManager {
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Purge Inactive Contacts ──────────────────────────────────────────────
|
||||
|
||||
async loadPurgePreview(days) {
|
||||
const previewText = document.getElementById('purge-preview-text');
|
||||
const confirmBtn = document.getElementById('confirm-purge-btn');
|
||||
const confirmLabel = document.getElementById('confirm-purge-label');
|
||||
if (!previewText) return;
|
||||
previewText.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Loading...';
|
||||
confirmBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/contacts/purge-preview?days=${days}`);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to load preview');
|
||||
if (data.count === 0) {
|
||||
previewText.innerHTML = `<i class="fas fa-check-circle text-success me-1"></i>No contacts found that are older than <strong>${days} days</strong>.`;
|
||||
confirmBtn.disabled = true;
|
||||
} else {
|
||||
const sampleNames = (data.samples || []).map(s => `<em>${s.name}</em>`).join(', ');
|
||||
const more = data.count > 5 ? ` and ${data.count - 5} more` : '';
|
||||
previewText.innerHTML = `<i class="fas fa-exclamation-triangle text-warning me-1"></i><strong>${data.count}</strong> contact(s) will be deleted: ${sampleNames}${more}.`;
|
||||
confirmLabel.textContent = `Purge ${data.count} contact(s)`;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
previewText.innerHTML = `<i class="fas fa-times-circle text-danger me-1"></i>Error: ${err.message}`;
|
||||
confirmBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async executePurge(days) {
|
||||
const confirmBtn = document.getElementById('confirm-purge-btn');
|
||||
const confirmLabel = document.getElementById('confirm-purge-label');
|
||||
const originalLabel = confirmLabel.textContent;
|
||||
confirmBtn.disabled = true;
|
||||
confirmLabel.textContent = 'Purging...';
|
||||
try {
|
||||
const resp = await fetch('/api/contacts/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Purge failed');
|
||||
bootstrap.Modal.getInstance(document.getElementById('purgeContactsModal')).hide();
|
||||
this.showSuccess(data.message || `Purged ${data.deleted} contact(s)`);
|
||||
await this.loadContactsData();
|
||||
} catch (err) {
|
||||
this.showError('Purge failed: ' + err.message);
|
||||
confirmLabel.textContent = originalLabel;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
setupPurgeModal() {
|
||||
const modal = document.getElementById('purgeContactsModal');
|
||||
const select = document.getElementById('purge-days-select');
|
||||
const confirmBtn = document.getElementById('confirm-purge-btn');
|
||||
if (!modal) return;
|
||||
|
||||
modal.addEventListener('show.bs.modal', () => {
|
||||
this.loadPurgePreview(parseInt(select.value));
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
this.loadPurgePreview(parseInt(select.value));
|
||||
});
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
this.executePurge(parseInt(select.value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function exportData(dataset, fmt) {
|
||||
const since = document.getElementById('export-since').value || '30d';
|
||||
const url = `/api/export/${dataset}?format=${fmt}&since=${since}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// Initialize contacts manager when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.contactsManager = new ModernContactsManager();
|
||||
window.contactsManager.setupPurgeModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -312,6 +312,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Activity Feed -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-satellite-dish me-2"></i>Live Activity
|
||||
<span id="live-dot" class="status-indicator status-disconnected ms-2" title="SocketIO connection"></span>
|
||||
</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span id="live-count" class="badge bg-secondary">0</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="live-pause-btn" onclick="toggleLivePause()">
|
||||
<i class="fas fa-pause" id="live-pause-icon"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="clearLiveFeed()">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
<a href="/realtime" class="btn btn-sm btn-outline-primary">
|
||||
Full Monitor <i class="fas fa-external-link-alt ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="live-feed" style="height:260px;overflow-y:auto;background:var(--bg-secondary,#f8f9fa);padding:0.5rem;">
|
||||
<div class="text-muted text-center py-3" id="live-placeholder">
|
||||
<i class="fas fa-hourglass-half"></i> Connecting…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -742,6 +774,81 @@ window.addEventListener('beforeunload', () => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Live Activity Feed (SocketIO) ─────────────────────────────────────────────
|
||||
(function () {
|
||||
const feed = document.getElementById('live-feed');
|
||||
const dot = document.getElementById('live-dot');
|
||||
const countBadge = document.getElementById('live-count');
|
||||
const placeholder = document.getElementById('live-placeholder');
|
||||
let paused = false;
|
||||
let total = 0;
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
const TYPE_COLORS = {
|
||||
packet: '#fd7e14',
|
||||
command: '#198754',
|
||||
message: '#0dcaf0',
|
||||
};
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function addEntry(label, text, type) {
|
||||
if (paused) return;
|
||||
if (placeholder && placeholder.parentNode) placeholder.remove();
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
const color = TYPE_COLORS[type] || '#6c757d';
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `border-left:3px solid ${color};padding:3px 8px;margin-bottom:3px;font-size:0.82rem;border-radius:2px;`;
|
||||
el.innerHTML = `<span class="text-muted me-2">${ts}</span><strong>${escHtml(label)}</strong> <span class="text-muted">${escHtml(text)}</span>`;
|
||||
feed.insertBefore(el, feed.firstChild);
|
||||
total++;
|
||||
countBadge.textContent = total;
|
||||
// Trim
|
||||
const items = feed.children;
|
||||
while (items.length > MAX_ENTRIES) feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
window.toggleLivePause = function () {
|
||||
paused = !paused;
|
||||
const icon = document.getElementById('live-pause-icon');
|
||||
icon.className = paused ? 'fas fa-play' : 'fas fa-pause';
|
||||
};
|
||||
window.clearLiveFeed = function () {
|
||||
feed.innerHTML = '';
|
||||
total = 0;
|
||||
countBadge.textContent = '0';
|
||||
};
|
||||
|
||||
const socket = io({ transports: ['websocket', 'polling'] });
|
||||
|
||||
socket.on('connect', () => {
|
||||
dot.className = 'status-indicator status-connected ms-2';
|
||||
socket.emit('subscribe_packets');
|
||||
socket.emit('subscribe_commands');
|
||||
socket.emit('subscribe_messages');
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
dot.className = 'status-indicator status-disconnected ms-2';
|
||||
});
|
||||
|
||||
socket.on('packet_data', d => {
|
||||
const ptype = d.payload_type_name || d.type_name || 'Packet';
|
||||
const src = d.from_name || d.pubkey_prefix || '';
|
||||
addEntry(ptype, src, 'packet');
|
||||
});
|
||||
socket.on('command_data', d => {
|
||||
addEntry('Cmd: ' + (d.command || '?'), (d.user || '') + ' → ' + (d.channel || ''), 'command');
|
||||
});
|
||||
socket.on('message_data', d => {
|
||||
const ch = d.channel ? `[${d.channel}] ` : (d.is_dm ? '[DM] ' : '');
|
||||
addEntry(d.sender || '?', ch + (d.content || ''), 'message');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Status indicators moved to enhanced section below */
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — MeshCore Bot Data Viewer</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f8f9fa; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-card { width: 100%; max-width: 360px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header text-center fw-bold">MeshCore Bot Data Viewer</div>
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title text-center mb-3">Sign in</h5>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autofocus autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Live Log Viewer - MeshCore Bot Data Viewer{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.log-container {
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 400px;
|
||||
overflow-y: auto;
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
[data-theme="light"] .log-container {
|
||||
background-color: #f8f9fa;
|
||||
border-color: var(--border-color);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 1px 0;
|
||||
color: #c9d1d9;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="light"] .log-line {
|
||||
color: #212529;
|
||||
border-bottom-color: rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Level-based coloring */
|
||||
.log-line.level-debug { color: #6e7681; }
|
||||
.log-line.level-info { color: #58a6ff; }
|
||||
.log-line.level-warning { color: #d29922; }
|
||||
.log-line.level-error { color: #f85149; }
|
||||
.log-line.level-critical{ color: #ff79c6; font-weight: bold; }
|
||||
|
||||
[data-theme="light"] .log-line.level-debug { color: #6c757d; }
|
||||
[data-theme="light"] .log-line.level-info { color: #0d6efd; }
|
||||
[data-theme="light"] .log-line.level-warning { color: #fd7e14; }
|
||||
[data-theme="light"] .log-line.level-error { color: #dc3545; }
|
||||
[data-theme="light"] .log-line.level-critical{ color: #6610f2; font-weight: bold; }
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#line-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
max-width: 140px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="mb-0">
|
||||
<i class="fas fa-file-alt"></i> Live Log Viewer
|
||||
</h1>
|
||||
<a href="/realtime" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-broadcast-tower"></i> Real-time Monitor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="fas fa-terminal"></i> Bot Log Stream</h6>
|
||||
<span class="badge bg-success" id="log-status">Connecting…</span>
|
||||
</div>
|
||||
<div class="log-controls">
|
||||
<span id="line-count">0 lines</span>
|
||||
<select class="form-select form-select-sm filter-select" id="level-filter" onchange="applyFilter()">
|
||||
<option value="">All levels</option>
|
||||
<option value="DEBUG">DEBUG+</option>
|
||||
<option value="INFO">INFO+</option>
|
||||
<option value="WARNING">WARNING+</option>
|
||||
<option value="ERROR">ERROR+</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="pause-btn" onclick="togglePause()">
|
||||
<i class="fas fa-pause" id="pause-icon"></i> Pause
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="clearLog()">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="scrollToBottom()">
|
||||
<i class="fas fa-arrow-down"></i> Bottom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="log-container" class="log-container">
|
||||
<div class="text-muted py-3 text-center" id="log-placeholder">
|
||||
<i class="fas fa-hourglass-half"></i> Waiting for log data…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
const MAX_LINES = 2000;
|
||||
const LEVEL_ORDER = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3, CRITICAL: 4 };
|
||||
|
||||
let paused = false;
|
||||
let autoScroll = true;
|
||||
let lineCount = 0;
|
||||
let filterLevel = '';
|
||||
const container = document.getElementById('log-container');
|
||||
const placeholder = document.getElementById('log-placeholder');
|
||||
|
||||
const socket = io({ transports: ['websocket', 'polling'] });
|
||||
|
||||
socket.on('connect', function () {
|
||||
updateStatus('log-status', 'Connected', 'success');
|
||||
socket.emit('subscribe_logs');
|
||||
});
|
||||
|
||||
socket.on('disconnect', function () {
|
||||
updateStatus('log-status', 'Disconnected', 'danger');
|
||||
});
|
||||
|
||||
socket.on('log_line', function (data) {
|
||||
if (paused) return;
|
||||
addLogLine(data.line || '');
|
||||
});
|
||||
|
||||
function detectLevel(line) {
|
||||
const upper = line.toUpperCase();
|
||||
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
|
||||
if (upper.includes(lvl)) return lvl;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function levelPasses(line) {
|
||||
if (!filterLevel) return true;
|
||||
const lineLevel = detectLevel(line);
|
||||
if (!lineLevel) return true;
|
||||
return (LEVEL_ORDER[lineLevel] ?? 0) >= (LEVEL_ORDER[filterLevel] ?? 0);
|
||||
}
|
||||
|
||||
function addLogLine(text) {
|
||||
if (!levelPasses(text)) return;
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
const lvl = detectLevel(text);
|
||||
if (lvl) div.classList.add('level-' + lvl.toLowerCase());
|
||||
div.textContent = text;
|
||||
container.appendChild(div);
|
||||
|
||||
lineCount++;
|
||||
document.getElementById('line-count').textContent = lineCount + ' lines';
|
||||
|
||||
// Trim old lines
|
||||
while (container.children.length > MAX_LINES) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
if (autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
const icon = document.getElementById('pause-icon');
|
||||
const btn = document.getElementById('pause-btn');
|
||||
if (paused) {
|
||||
icon.className = 'fas fa-play';
|
||||
btn.innerHTML = '<i class="fas fa-play" id="pause-icon"></i> Resume';
|
||||
autoScroll = false;
|
||||
} else {
|
||||
icon.className = 'fas fa-pause';
|
||||
btn.innerHTML = '<i class="fas fa-pause" id="pause-icon"></i> Pause';
|
||||
autoScroll = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
container.innerHTML = '';
|
||||
lineCount = 0;
|
||||
document.getElementById('line-count').textContent = '0 lines';
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
autoScroll = true;
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
filterLevel = document.getElementById('level-filter').value;
|
||||
}
|
||||
|
||||
function updateStatus(id, text, cls) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.className = 'badge bg-' + cls;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
// Pause auto-scroll when user scrolls up
|
||||
container.addEventListener('scroll', function () {
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
autoScroll = atBottom;
|
||||
});
|
||||
|
||||
// Expose for inline handlers
|
||||
window.togglePause = togglePause;
|
||||
window.clearLog = clearLog;
|
||||
window.scrollToBottom = scrollToBottom;
|
||||
window.applyFilter = applyFilter;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,9 +6,18 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">
|
||||
<i class="fas fa-broadcast-tower"></i> Radio Settings
|
||||
</h1>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="mb-0">
|
||||
<i class="fas fa-broadcast-tower"></i> Radio Settings
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-secondary" id="connectToggleBtn" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-1"
|
||||
id="connectBtnSpinner" style="display:none" role="status"></span>
|
||||
<span id="connectBtnText">Loading...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
@@ -147,17 +156,20 @@ class RadioManager {
|
||||
constructor() {
|
||||
this.channels = [];
|
||||
this.maxChannels = 40; // Default, will be updated from API
|
||||
this.radioConnected = null;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
|
||||
async initialize() {
|
||||
await this.loadChannels();
|
||||
await this.loadStatistics();
|
||||
await this.loadRadioStatus();
|
||||
this.setupEventHandlers();
|
||||
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => this.loadChannels(), 30000);
|
||||
setInterval(() => this.loadStatistics(), 60000);
|
||||
setInterval(() => this.loadRadioStatus(), 15000);
|
||||
}
|
||||
|
||||
async loadChannels() {
|
||||
@@ -331,6 +343,114 @@ class RadioManager {
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => this.saveChannel());
|
||||
}
|
||||
|
||||
// Radio control buttons
|
||||
const connectToggleBtn = document.getElementById('connectToggleBtn');
|
||||
if (connectToggleBtn) {
|
||||
connectToggleBtn.addEventListener('click', () => this.handleConnectToggle());
|
||||
}
|
||||
}
|
||||
|
||||
async loadRadioStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/radio/status');
|
||||
const data = await response.json();
|
||||
this.radioConnected = data.connected;
|
||||
this.updateConnectButton();
|
||||
} catch (error) {
|
||||
console.error('Error loading radio status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectButton() {
|
||||
const btn = document.getElementById('connectToggleBtn');
|
||||
const txt = document.getElementById('connectBtnText');
|
||||
if (!btn || !txt) return;
|
||||
|
||||
btn.disabled = false;
|
||||
if (this.radioConnected === null) {
|
||||
btn.className = 'btn btn-secondary';
|
||||
txt.textContent = 'Status Unknown';
|
||||
} else if (this.radioConnected) {
|
||||
btn.className = 'btn btn-danger';
|
||||
txt.textContent = 'Disconnect';
|
||||
} else {
|
||||
btn.className = 'btn btn-success';
|
||||
txt.textContent = 'Connect';
|
||||
}
|
||||
}
|
||||
|
||||
async handleConnectToggle() {
|
||||
const action = this.radioConnected ? 'disconnect' : 'connect';
|
||||
const spinner = document.getElementById('connectBtnSpinner');
|
||||
const btn = document.getElementById('connectToggleBtn');
|
||||
const txt = document.getElementById('connectBtnText');
|
||||
|
||||
btn.disabled = true;
|
||||
spinner.style.display = 'inline-block';
|
||||
txt.textContent = action === 'connect' ? 'Connecting...' : 'Disconnecting...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/radio/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.operation_id) {
|
||||
await this.pollConnectOperation(data.operation_id, action);
|
||||
} else {
|
||||
this.showError(data.error || 'Failed to queue operation');
|
||||
spinner.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
this.updateConnectButton();
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError('Error: ' + error.message);
|
||||
spinner.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
this.updateConnectButton();
|
||||
}
|
||||
}
|
||||
|
||||
async pollConnectOperation(operationId, action, maxWait = 60) {
|
||||
const startTime = Date.now();
|
||||
const spinner = document.getElementById('connectBtnSpinner');
|
||||
const txt = document.getElementById('connectBtnText');
|
||||
const btn = document.getElementById('connectToggleBtn');
|
||||
|
||||
for (let attempts = 0; attempts < maxWait; attempts++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
txt.textContent = `${action === 'connect' ? 'Connecting' : 'Disconnecting'}... (${elapsed}s)`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channel-operations/${operationId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'completed') {
|
||||
spinner.style.display = 'none';
|
||||
await this.loadRadioStatus();
|
||||
this.showSuccess(`Radio ${action}ed successfully`);
|
||||
return;
|
||||
} else if (result.status === 'failed') {
|
||||
spinner.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
this.updateConnectButton();
|
||||
this.showError(result.error_message || `Failed to ${action} radio`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling operation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
spinner.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
await this.loadRadioStatus();
|
||||
this.showError('Operation timed out — check radio status.');
|
||||
}
|
||||
|
||||
handleChannelNameChange(channelName) {
|
||||
|
||||
@@ -433,10 +433,13 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="mb-0">
|
||||
<i class="fas fa-broadcast-tower"></i> Real-time Monitoring
|
||||
</h1>
|
||||
<a href="/logs" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-file-alt"></i> Live Log Viewer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -481,6 +484,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Monitor -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6><i class="fas fa-comment-dots"></i> Live Channel Messages</h6>
|
||||
<div>
|
||||
<span class="badge bg-success" id="message-status">Active</span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="clearMessages()">Clear</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" id="msg-pause-btn" onclick="toggleMessagePause()">
|
||||
<i class="fas fa-pause" id="msg-pause-icon"></i> Pause
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="message-stream" class="stream-container" style="height:320px;">
|
||||
<div class="text-muted text-center py-3">
|
||||
<i class="fas fa-hourglass-half"></i> Waiting for channel messages…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Detail Modal -->
|
||||
<div class="modal fade" id="packetDetailModal" tabindex="-1" aria-labelledby="packetDetailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
@@ -897,7 +925,8 @@
|
||||
console.log('Connected to server');
|
||||
socket.emit('subscribe_commands');
|
||||
socket.emit('subscribe_packets');
|
||||
|
||||
socket.emit('subscribe_messages');
|
||||
|
||||
// Load recent commands when connected
|
||||
loadRecentCommands();
|
||||
|
||||
@@ -912,6 +941,7 @@
|
||||
console.log('Disconnected from server');
|
||||
updateStatus('command-status', 'Disconnected', 'danger');
|
||||
updateStatus('packet-status', 'Disconnected', 'danger');
|
||||
updateStatus('message-status', 'Disconnected', 'danger');
|
||||
|
||||
// Stop ping interval on disconnect
|
||||
stopPingInterval();
|
||||
@@ -942,7 +972,82 @@
|
||||
socket.on('packet_data', function(data) {
|
||||
addPacketEntry(data);
|
||||
});
|
||||
|
||||
|
||||
socket.on('message_data', function(data) {
|
||||
addMessageEntry(data);
|
||||
});
|
||||
|
||||
// ── Live Channel Messages ──────────────────────────────────────────────
|
||||
|
||||
let messagePaused = false;
|
||||
const MAX_MESSAGES = 200;
|
||||
|
||||
function toggleMessagePause() {
|
||||
messagePaused = !messagePaused;
|
||||
const icon = document.getElementById('msg-pause-icon');
|
||||
const btn = document.getElementById('msg-pause-btn');
|
||||
if (messagePaused) {
|
||||
icon.className = 'fas fa-play';
|
||||
btn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
updateStatus('message-status', 'Paused', 'warning');
|
||||
} else {
|
||||
icon.className = 'fas fa-pause';
|
||||
btn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
updateStatus('message-status', 'Active', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
document.getElementById('message-stream').innerHTML =
|
||||
'<div class="text-muted text-center py-3"><i class="fas fa-hourglass-half"></i> Waiting for channel messages…</div>';
|
||||
}
|
||||
|
||||
function addMessageEntry(data) {
|
||||
if (messagePaused) return;
|
||||
const container = document.getElementById('message-stream');
|
||||
|
||||
// Remove placeholder
|
||||
const placeholder = container.querySelector('.text-muted.text-center');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const ts = new Date(data.timestamp * 1000).toLocaleTimeString();
|
||||
const ch = data.channel ? `<span class="badge bg-info text-dark me-1">${escapeHtml(data.channel)}</span>` : '';
|
||||
const dm = data.is_dm ? '<span class="badge bg-secondary me-1">DM</span>' : '';
|
||||
const snr = data.snr && data.snr !== 'unknown' ? `<small class="text-muted ms-1">SNR ${data.snr}</small>` : '';
|
||||
const hops = (data.hops != null && data.hops !== '' && data.hops !== 255)
|
||||
? `<small class="text-muted ms-1">${data.hops} hop${data.hops !== 1 ? 's' : ''}</small>` : '';
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'stream-entry p-2 mb-1 rounded';
|
||||
entry.style.borderLeftColor = data.is_dm ? '#6f42c1' : '#0dcaf0';
|
||||
entry.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span>${ch}${dm}<strong>${escapeHtml(data.sender || '?')}</strong>${snr}${hops}</span>
|
||||
<small class="text-muted">${ts}</small>
|
||||
</div>
|
||||
<div class="text-break">${escapeHtml(data.content || '')}</div>
|
||||
`;
|
||||
|
||||
container.insertBefore(entry, container.firstChild);
|
||||
|
||||
// Trim old entries
|
||||
const entries = container.querySelectorAll('.stream-entry');
|
||||
if (entries.length > MAX_MESSAGES) {
|
||||
entries[entries.length - 1].remove();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
window.clearMessages = clearMessages;
|
||||
window.toggleMessagePause = toggleMessagePause;
|
||||
|
||||
// Ping mechanism to keep connection alive
|
||||
let pingInterval = null;
|
||||
|
||||
@@ -1156,8 +1261,10 @@
|
||||
// Format timestamp
|
||||
const timestamp = data.datetime ? new Date(data.datetime).toLocaleTimeString() : new Date().toLocaleTimeString();
|
||||
|
||||
// Format path display as comma-separated list
|
||||
const pathDisplay = data.path ? data.path.join(',') : 'No path';
|
||||
// Format path display as comma-separated list (path may be array or string)
|
||||
const pathDisplay = data.path
|
||||
? (Array.isArray(data.path) ? data.path.join(',') : String(data.path))
|
||||
: 'No path';
|
||||
|
||||
// Format header with resolved components (human-readable with names and numbers)
|
||||
let headerInfo = '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user