diff --git a/meshcore_gui/core/models.py b/meshcore_gui/core/models.py index ca3ada8..034e44b 100644 --- a/meshcore_gui/core/models.py +++ b/meshcore_gui/core/models.py @@ -51,6 +51,29 @@ class Message: path_hashes: List[str] = field(default_factory=list) message_hash: str = "" + @staticmethod + def from_dict(d: dict) -> "Message": + """Create a Message from an archive dictionary. + + Args: + d: Dictionary as stored by MessageArchive. + + Returns: + Message dataclass instance. + """ + return Message( + time=d.get("time", ""), + sender=d.get("sender", ""), + text=d.get("text", ""), + channel=d.get("channel"), + direction=d.get("direction", "in"), + snr=d.get("snr"), + path_len=d.get("path_len", 0), + sender_pubkey=d.get("sender_pubkey", ""), + path_hashes=d.get("path_hashes", []), + message_hash=d.get("message_hash", ""), + ) + # --------------------------------------------------------------------------- # Contact diff --git a/meshcore_gui/gui/archive_page.py b/meshcore_gui/gui/archive_page.py index 0612df9..09d7541 100644 --- a/meshcore_gui/gui/archive_page.py +++ b/meshcore_gui/gui/archive_page.py @@ -9,7 +9,10 @@ from typing import Optional from nicegui import ui +from meshcore_gui.core.models import Message from meshcore_gui.core.protocols import SharedDataReadAndLookup +from meshcore_gui.gui.constants import TYPE_LABELS +from meshcore_gui.services.route_builder import RouteBuilder class ArchivePage: @@ -28,6 +31,7 @@ class ArchivePage: """ self._shared = shared self._page_size = page_size + self._builder = RouteBuilder(shared) # Current page state (stored in app.storage.user) self._current_page = 0 @@ -225,7 +229,7 @@ class ArchivePage: ) def _render_message_card(self, msg_dict: dict, snapshot: dict): - """Render a single message card with reply option. + """Render a single message card with route table and reply option. Args: msg_dict: Message dictionary from archive. @@ -240,7 +244,6 @@ class ArchivePage: snr = msg_dict.get('snr', 0.0) path_len = msg_dict.get('path_len', 0) path_hashes = msg_dict.get('path_hashes', []) - message_hash = msg_dict.get('message_hash', '') # Channel name - lookup from snapshot channel_name = f'Ch {channel}' # Default @@ -255,10 +258,10 @@ class ArchivePage: dir_color = 'text-blue-600' if direction == 'out' else 'text-green-600' # Card styling (same as messages_panel) - with ui.card().classes('w-full hover:bg-gray-50') as card: + with ui.card().classes('w-full hover:bg-gray-50'): with ui.column().classes('w-full gap-2'): - # Main message content (clickable for route) - with ui.row().classes('w-full items-start gap-2 cursor-pointer') as main_row: + # Main message content + with ui.row().classes('w-full items-start gap-2'): # Time + direction with ui.column().classes('flex-none w-20'): ui.label(time).classes('text-xs text-gray-600') @@ -274,13 +277,16 @@ class ArchivePage: if path_len > 0: ui.label(f'↔ {path_len} hops').classes('text-xs text-gray-500') - if snr > 0: + if snr and snr > 0: snr_color = 'text-green-600' if snr >= 5 else 'text-orange-600' if snr >= 0 else 'text-red-600' ui.label(f'SNR: {snr:.1f}').classes(f'text-xs {snr_color}') # Message text ui.label(text).classes('text-sm whitespace-pre-wrap') + # Route table (expandable) + self._render_archive_route(msg_dict, snapshot) + # Reply panel (expandable) with ui.expansion('πŸ’¬ Reply', icon='reply').classes('w-full') as expansion: expansion.classes('bg-gray-50') @@ -290,13 +296,13 @@ class ArchivePage: # Channel selector ch_options = {} - default_ch = 0 + default_ch = None for ch in snapshot.get('channels', []): ch_idx = ch.get('idx', ch.get('index', 0)) ch_name = ch.get('name', f'Ch {ch_idx}') ch_options[ch_idx] = f"[{ch_idx}] {ch_name}" - if default_ch == 0: # Use first channel as default + if default_ch is None: default_ch = ch_idx with ui.row().classes('w-full items-center gap-2'): @@ -323,27 +329,82 @@ class ArchivePage: expansion.open = False # Close expansion ui.button('Send', on_click=send_reply).props('color=primary') - - # Click handler for main row - open route visualization - def open_route(): - # Find message in current snapshot to get its index - current_messages = snapshot.get('messages', []) - - # Try to find this message by hash in current messages - msg_index = -1 - for idx, msg in enumerate(current_messages): - if hasattr(msg, 'message_hash') and msg.message_hash == message_hash: - msg_index = idx - break - - if msg_index >= 0: - # Message is in current buffer - use normal route page - ui.run_javascript(f'window.open("/route/{msg_index}", "_blank")') + + def _render_archive_route(self, msg_dict: dict, snapshot: dict): + """Render an inline route table for an archive message. + + Args: + msg_dict: Message dictionary from archive. + snapshot: Current snapshot for contact lookup. + """ + with ui.expansion('πŸ—ΊοΈ Route', icon='route').classes('w-full') as expansion: + expansion.classes('bg-blue-50') + with ui.column().classes('w-full gap-1 p-2'): + msg = Message.from_dict(msg_dict) + route = self._builder.build(msg, snapshot) + + path_nodes = route['path_nodes'] + sender = route['sender'] + self_node = route['self_node'] + + rows = [] + + # Sender row + if sender: + rows.append({ + 'hop': 'Start', + 'name': sender.name, + 'hash': sender.pubkey[:2].upper() if sender.pubkey else '-', + 'type': TYPE_LABELS.get(sender.type, '-'), + 'role': 'πŸ“± Sender', + }) else: - # Message is only in archive - show notification - ui.notify('Route visualization only available for recent messages', type='warning') - - main_row.on('click', open_route) + rows.append({ + 'hop': 'Start', + 'name': msg.sender or 'Unknown', + 'hash': msg.sender_pubkey[:2].upper() if msg.sender_pubkey else '-', + 'type': '-', + 'role': 'πŸ“± Sender', + }) + + # Repeaters + for i, node in enumerate(path_nodes): + rows.append({ + 'hop': str(i + 1), + 'name': node.name, + 'hash': node.pubkey[:2].upper() if node.pubkey else '-', + 'type': TYPE_LABELS.get(node.type, '-'), + 'role': 'πŸ“‘ Repeater', + }) + + # Placeholder rows for unresolved hops + if not path_nodes and msg.path_len > 0: + for i in range(msg.path_len): + rows.append({ + 'hop': str(i + 1), + 'name': '-', 'hash': '-', 'type': '-', + 'role': 'πŸ“‘ Repeater', + }) + + # Receiver (self) + rows.append({ + 'hop': 'End', + 'name': self_node.name, + 'hash': '-', + 'type': 'Companion', + 'role': 'πŸ“± Receiver' if msg.direction == 'in' else 'πŸ“± Sender', + }) + + ui.table( + columns=[ + {'name': 'hop', 'label': 'Hop', 'field': 'hop', 'align': 'center'}, + {'name': 'role', 'label': 'Role', 'field': 'role'}, + {'name': 'name', 'label': 'Name', 'field': 'name'}, + {'name': 'hash', 'label': 'ID', 'field': 'hash', 'align': 'center'}, + {'name': 'type', 'label': 'Type', 'field': 'type'}, + ], + rows=rows, + ).props('dense flat bordered').classes('w-full') @staticmethod def setup_route(shared: SharedDataReadAndLookup): diff --git a/meshcore_gui/services/route_builder.py b/meshcore_gui/services/route_builder.py index 67e36c7..6a76234 100644 --- a/meshcore_gui/services/route_builder.py +++ b/meshcore_gui/services/route_builder.py @@ -81,7 +81,7 @@ class RouteBuilder: ) if contact: result['sender'] = RouteNode( - name=contact.get('adv_name', pubkey[:8]), + name=contact.get('adv_name') or pubkey[:8], lat=contact.get('adv_lat', 0), lon=contact.get('adv_lon', 0), type=contact.get('type', 0), @@ -96,7 +96,7 @@ class RouteBuilder: pubkey, contact_data = match contact = contact_data result['sender'] = RouteNode( - name=contact_data.get('adv_name', pubkey[:8]), + name=contact_data.get('adv_name') or pubkey[:8], lat=contact_data.get('adv_lat', 0), lon=contact_data.get('adv_lon', 0), type=contact_data.get('type', 0), @@ -172,7 +172,7 @@ class RouteBuilder: if hop_contact: nodes.append(RouteNode( - name=hop_contact.get('adv_name', f'0x{hop_hash}'), + name=hop_contact.get('adv_name') or f'0x{hop_hash}', lat=hop_contact.get('adv_lat', 0), lon=hop_contact.get('adv_lon', 0), type=hop_contact.get('type', 0), diff --git a/meshcore_gui_bugfix.zip b/meshcore_gui_bugfix.zip new file mode 100644 index 0000000..37c4ad3 Binary files /dev/null and b/meshcore_gui_bugfix.zip differ