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:
Stacy Olivas
2026-03-17 18:07:18 -07:00
parent 2a3a78711c
commit 93f73a15a2
12 changed files with 4833 additions and 1093 deletions
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+153 -145
View File
@@ -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
+10
View File
@@ -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 -->
+585
View File
@@ -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 &amp; 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&nbsp;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&nbsp;242&nbsp;880 (5&nbsp;MB). Minimum: 100&nbsp;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&nbsp;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 %}
+151 -6
View File
@@ -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>
+107
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 */
+34
View File
@@ -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>
+238
View File
@@ -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 %}
+125 -5
View File
@@ -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) {
+113 -6
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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