Files
agessaman 2a1c2f86d8 feat(web_viewer): rebuild Real-time as one tape of what the radio heard
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened.

A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet.

Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds.

Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them.

The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
2026-10-17 19:40:23 -07:00

321 lines
14 KiB
Python

"""Live updates for the web viewer: SocketIO broadcasts to subscribed clients,
the bot log tail, and the database poll that feeds the live command, packet and
message streams."""
from __future__ import annotations
import os
import re
from contextlib import closing
from pathlib import Path
from typing import Any
from modules.utils import resolve_path
# colorlog and other handlers write ANSI SGR sequences; strip for web /logs display
_ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def _strip_ansi_codes(text: str) -> str:
"""Remove ANSI color and reset codes from log lines for SocketIO web clients."""
return _ANSI_ESCAPE_RE.sub("", text)
class LiveStreamMixin:
"""Mixed into BotDataViewer."""
_clients_lock: Any
_config_base: Any
config: Any
connected_clients: Any
db_path: Any
logger: Any
socketio: Any
def _subscribed_clients(self, flag: str) -> list:
"""Sids of connected clients with ``flag`` set, read under the clients lock."""
with self._clients_lock:
return [
client_id for client_id, client_info in self.connected_clients.items()
if client_info.get(flag, False)
]
def _handle_command_data(self, command_data):
"""Handle incoming command data from bot"""
try:
# Broadcast to subscribed clients
subscribed_clients = self._subscribed_clients('subscribed_commands')
if subscribed_clients:
self.socketio.emit('command_data', command_data, room=None)
self.logger.debug(f"Broadcasted command data to {len(subscribed_clients)} clients")
except Exception as e:
self.logger.error(f"Error handling command data: {e}")
def _handle_packet_data(self, packet_data):
"""Handle incoming packet data from bot"""
try:
# Broadcast to subscribed clients
subscribed_clients = self._subscribed_clients('subscribed_packets')
if subscribed_clients:
self.socketio.emit('packet_data', packet_data, room=None)
self.logger.debug(f"Broadcasted packet data to {len(subscribed_clients)} clients")
except Exception as e:
self.logger.error(f"Error handling packet data: {e}")
def _handle_mesh_edge_data(self, edge_data):
"""Handle incoming mesh edge data from bot"""
try:
# Broadcast to subscribed clients
subscribed_clients = self._subscribed_clients('subscribed_mesh')
if subscribed_clients:
event_type = 'mesh_edge_added' if edge_data.get('is_new', False) else 'mesh_edge_updated'
self.socketio.emit(event_type, edge_data, room=None)
except Exception as e:
self.logger.error(f"Error handling mesh edge data: {e}", exc_info=True)
def _handle_mesh_node_data(self, node_data):
"""Handle incoming mesh node data from bot"""
try:
# Broadcast to subscribed clients
subscribed_clients = self._subscribed_clients('subscribed_mesh')
if subscribed_clients:
self.socketio.emit('mesh_node_added', node_data, room=None)
except Exception as e:
self.logger.error(f"Error handling mesh node data: {e}", exc_info=True)
def _handle_message_data(self, msg_data):
"""Broadcast a captured channel message to subscribed clients."""
try:
subscribed_clients = self._subscribed_clients('subscribed_messages')
if subscribed_clients:
self.socketio.emit('message_data', msg_data, room=None)
except Exception as e:
self.logger.error(f"Error handling message data: {e}")
def _handle_log_line(self, line: str) -> None:
"""Broadcast a log line to clients subscribed to the log stream."""
try:
subscribed = self._subscribed_clients('subscribed_logs')
if subscribed:
self.socketio.emit(
'log_line', {'line': _strip_ansi_codes(line.rstrip())}, room=None
)
except Exception as e:
self.logger.error(f"Error broadcasting log line: {e}")
def _start_log_tailing(self) -> None:
"""Start a background thread that tails the bot log file and emits SocketIO events."""
import os
import threading
log_file = ''
try:
log_file = self.config.get('Logging', 'log_file', fallback='').strip()
if log_file:
log_file = str(resolve_path(log_file, self._config_base))
except Exception:
pass
if not log_file:
self.logger.info("Log tailing disabled: no log_file configured")
return
def tail_log():
import time as _time
self.logger.info(f"Log tail thread started: {log_file}")
pos = 0
# Start at end of file so we only stream new lines
try:
pos = os.path.getsize(log_file)
except OSError:
pass
while True:
try:
if not os.path.exists(log_file):
_time.sleep(2)
continue
current_size = os.path.getsize(log_file)
if current_size < pos:
# File rotated — start from beginning
pos = 0
if current_size > pos:
with open(log_file, encoding='utf-8', errors='replace') as fh:
fh.seek(pos)
for line in fh:
self._handle_log_line(line)
pos = fh.tell()
except Exception as e:
self.logger.debug(f"Log tail error: {e}")
_time.sleep(1)
tail_thread = threading.Thread(target=tail_log, daemon=True)
tail_thread.start()
self.logger.info("Log tailing started")
def _start_database_polling(self):
"""Start background thread to poll database for new data"""
import threading
def poll_database():
import time as _time
last_timestamp = _time.time() - 300 # start 5 min back; subscribe handlers replay full history
consecutive_errors = 0
max_consecutive_errors = 10
while True:
try:
import json
import sqlite3
import time
# Subscription handlers replay recent history themselves.
# With no live command/packet/message subscribers there is
# nothing to broadcast, so avoid opening SQLite and decoding
# every packet-stream row merely to discard it.
if not self._has_live_stream_subscribers():
last_timestamp = time.time()
# An idle period is not a failure. Without this, an
# error burst before the last subscriber left would
# still be counted against the first poll after the
# next one arrives, mis-escalating its log level and
# backoff.
consecutive_errors = 0
time.sleep(2.0)
continue
# Check if database file exists and is accessible
db_file = Path(self.db_path)
if not db_file.exists():
consecutive_errors += 1
if consecutive_errors == 1 or consecutive_errors % 10 == 0:
self.logger.warning(f"Database file does not exist: {self.db_path}")
time.sleep(5)
continue
if not os.access(self.db_path, os.R_OK):
consecutive_errors += 1
if consecutive_errors == 1 or consecutive_errors % 10 == 0:
self.logger.warning(f"Database file is not readable: {self.db_path}")
time.sleep(5)
continue
# Connect to database with timeout to prevent hanging
try:
with closing(sqlite3.connect(self.db_path, timeout=60, check_same_thread=False)) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get new data since last poll
cursor.execute('''
SELECT timestamp, data, type FROM packet_stream
WHERE timestamp > ?
ORDER BY timestamp ASC
''', (last_timestamp,))
rows = cursor.fetchall()
# Process new data
for row in rows:
try:
data_json = row[1]
data_type = row[2]
data = json.loads(data_json)
if isinstance(data, dict):
data['stream_ts'] = row[0]
# Broadcast based on type
if data_type == 'command':
self._handle_command_data(data)
elif data_type == 'packet':
self._handle_packet_data(data)
elif data_type == 'routing':
self._handle_packet_data(data) # Treat routing as packet data
elif data_type == 'message':
self._handle_message_data(data)
except Exception as e:
self.logger.warning(f"Error processing database data: {e}")
# Update last timestamp
if rows:
last_timestamp = rows[-1][0]
# Reset error counter on success
consecutive_errors = 0
except sqlite3.OperationalError as conn_error:
error_msg = str(conn_error)
if "locked" in error_msg.lower() or "database is locked" in error_msg.lower():
consecutive_errors += 1
if consecutive_errors == 1 or consecutive_errors % 10 == 0:
self.logger.warning(f"Database is locked, waiting: {self.db_path}")
time.sleep(2)
continue
raise # Re-raise non-locked OperationalErrors for outer handler to log/backoff
# Sleep before next poll (back off to reduce lock contention with bot writes)
time.sleep(2.0) # Poll every 2s
except sqlite3.OperationalError as e:
consecutive_errors += 1
error_msg = str(e)
# Provide more diagnostic information on first error or periodic errors
if consecutive_errors == 1 or consecutive_errors % 10 == 0:
db_file = Path(self.db_path)
exists = db_file.exists()
readable = os.access(self.db_path, os.R_OK) if exists else False
writable = os.access(self.db_path, os.W_OK) if exists else False
self.logger.error(
f"Database polling error (attempt {consecutive_errors}): {error_msg}\n"
f" Path: {self.db_path}\n"
f" Exists: {exists}\n"
f" Readable: {readable}\n"
f" Writable: {writable}"
)
# Log at appropriate level based on error frequency
if consecutive_errors >= max_consecutive_errors:
if consecutive_errors == max_consecutive_errors:
self.logger.error(f"Database polling persistent error (attempt {consecutive_errors}): {error_msg}")
# Exponential backoff for persistent errors
time.sleep(min(60, 2 ** min(consecutive_errors - max_consecutive_errors, 5)))
elif consecutive_errors > 3:
self.logger.warning(f"Database polling error (attempt {consecutive_errors}): {error_msg}")
time.sleep(5) # Wait longer on repeated errors
else:
self.logger.debug(f"Database polling error (attempt {consecutive_errors}): {error_msg}")
time.sleep(1) # Wait longer on error
except Exception as e:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
if consecutive_errors == max_consecutive_errors:
self.logger.error(f"Database polling unexpected error (attempt {consecutive_errors}): {e}", exc_info=True)
time.sleep(min(60, 2 ** min(consecutive_errors - max_consecutive_errors, 5)))
else:
self.logger.warning(f"Database polling unexpected error (attempt {consecutive_errors}): {e}")
time.sleep(2)
# Start polling thread
polling_thread = threading.Thread(target=poll_database, daemon=True)
polling_thread.start()
self.logger.info("Database polling started")
def _has_live_stream_subscribers(self) -> bool:
"""Return whether any client consumes a DB-backed live stream."""
subscription_keys = (
'subscribed_commands',
'subscribed_packets',
'subscribed_messages',
)
with self._clients_lock:
return any(
any(client.get(key, False) for key in subscription_keys)
for client in self.connected_clients.values()
)