mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-11-03 11:02:30 +01:00
Add a RepeaterConfigPoller that reads flood limits, timing parameters, interference threshold, RX gain, AGC interval and region once a day, inside a window that opens at 03:00 local time. These values are configuration, not telemetry. No binary request returns them, so each one costs its own CLI round trip. Putting them on the 15-minute statistics schedule would add ten round trips per poll for values that did not change, and would lengthen exactly the window that 1.24.3 closed: a longer poll is likelier to be cancelled by outgoing traffic, and a cancelled poll writes no record. Readings go to their own append-only JSONL archive, so the statistics record schema stays untouched. Both pollers share the one cancellable task slot, so they can never occupy the radio at the same time. CLI replies arrive as CONTACT_MSG_RECV with text type CLI_DATA and are now dropped in on_contact_msg instead of appearing in the message list as a direct message nobody sent.
1131 lines
46 KiB
Python
1131 lines
46 KiB
Python
"""
|
||
Main dashboard page for MeshCore GUI.
|
||
|
||
Thin orchestrator that owns the layout and the 500 ms update timer.
|
||
All visual content is delegated to individual panel classes in
|
||
:mod:`meshcore_gui.gui.panels`.
|
||
"""
|
||
|
||
import logging
|
||
from urllib.parse import urlencode
|
||
|
||
from nicegui import ui
|
||
|
||
from meshcore_gui import config
|
||
|
||
from meshcore_gui.core.protocols import SharedDataReader
|
||
from meshcore_gui.gui.panels import (
|
||
ActionsPanel,
|
||
BbsPanel,
|
||
BotPanel,
|
||
ChannelBackupPanel,
|
||
ChannelPanel,
|
||
ContactsPanel,
|
||
DevicePanel,
|
||
MapPanel,
|
||
MessagesPanel,
|
||
RepeaterStatsPanel,
|
||
RoomServerPanel,
|
||
RxLogPanel,
|
||
)
|
||
from meshcore_gui.gui.archive_page import ArchivePage
|
||
from meshcore_gui.services.bbs_config_store import BbsConfigStore
|
||
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
|
||
from meshcore_gui.services.bot_config_store import BotConfigStore
|
||
from meshcore_gui.services.channel_service import (
|
||
CHANNEL_SORT_BY_INDEX,
|
||
CHANNEL_SORT_BY_NAME,
|
||
sort_channels,
|
||
)
|
||
from meshcore_gui.services.channel_sort_store import ChannelSortStore
|
||
from meshcore_gui.services.pin_store import PinStore
|
||
from meshcore_gui.services.room_password_store import RoomPasswordStore
|
||
|
||
|
||
# Suppress the harmless "Client has been deleted" warning that NiceGUI
|
||
# emits when a browser tab is refreshed while a ui.timer is active.
|
||
class _DeletedClientFilter(logging.Filter):
|
||
def filter(self, record: logging.LogRecord) -> bool:
|
||
return 'Client has been deleted' not in record.getMessage()
|
||
|
||
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
|
||
|
||
|
||
# ── DOMCA Theme ──────────────────────────────────────────────────────
|
||
# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar.
|
||
# Dark/light variable sets switch via Quasar's body--dark / body--light classes.
|
||
|
||
_DOMCA_HEAD = '''
|
||
<link rel="manifest" href="/static/manifest.json">
|
||
<meta name="theme-color" content="#0d1f35">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="DOMCA">
|
||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ── DOMCA theme variables (dark) ── */
|
||
body.body--dark {
|
||
--bg: #0A1628;
|
||
--grid: #0077B6; --grid-op: 0.15;
|
||
--mesh-bg: #48CAE4; --mesh-bg-op: 0.08;
|
||
--line: #0077B6; --line-op: 0.6;
|
||
--wave: #48CAE4; --node: #00B4D8; --node-center: #CAF0F8;
|
||
--hub-text: #0A1628; --outer: #0077B6;
|
||
--title: #48CAE4; --subtitle: #48CAE4;
|
||
--tagline: #90E0EF; --tag-op: 0.5;
|
||
--badge-stroke: #0077B6; --badge-text: #48CAE4;
|
||
--callsign: #0077B6;
|
||
}
|
||
/* ── DOMCA theme variables (light) ── */
|
||
body.body--light {
|
||
--bg: #FFFFFF;
|
||
--grid: #023E8A; --grid-op: 0.04;
|
||
--mesh-bg: #0077B6; --mesh-bg-op: 0.05;
|
||
--line: #0096C7; --line-op: 0.35;
|
||
--wave: #0096C7; --node: #0077B6; --node-center: #FFFFFF;
|
||
--hub-text: #FFFFFF; --outer: #0096C7;
|
||
--title: #0077B6; --subtitle: #0077B6;
|
||
--tagline: #0096C7; --tag-op: 0.4;
|
||
--badge-stroke: #0077B6; --badge-text: #0077B6;
|
||
--callsign: #0096C7;
|
||
}
|
||
|
||
/* ── DOMCA page background ── */
|
||
body.body--dark { background: #0A1628 !important; }
|
||
body.body--light { background: #f4f8fb !important; }
|
||
body.body--dark .q-page { background: #0A1628 !important; }
|
||
body.body--light .q-page { background: #f4f8fb !important; }
|
||
|
||
/* ── DOMCA header ── */
|
||
body.body--dark .q-header { background: #0d1f35 !important; }
|
||
body.body--light .q-header { background: #0077B6 !important; }
|
||
|
||
/* ── DOMCA drawer — distinct from page background ── */
|
||
body.body--dark .domca-drawer { background: #0f2340 !important; border-right: 1px solid rgba(0,119,182,0.25) !important; }
|
||
body.body--light .domca-drawer { background: rgba(244,248,251,0.97) !important; }
|
||
.domca-drawer .q-btn__content { justify-content: flex-start !important; }
|
||
|
||
/* ── DOMCA cards — dark mode readable ── */
|
||
body.body--dark .q-card {
|
||
background: #112240 !important;
|
||
color: #e0f0f8 !important;
|
||
border: 1px solid rgba(0,119,182,0.15) !important;
|
||
}
|
||
body.body--dark .q-card .text-gray-600 { color: #48CAE4 !important; }
|
||
body.body--dark .q-card .text-gray-500 { color: #8badc4 !important; }
|
||
body.body--dark .q-card .text-gray-400 { color: #6a8fa8 !important; }
|
||
body.body--dark .q-card .text-xs { color: #c0dce8 !important; }
|
||
body.body--dark .q-card .text-sm { color: #d0e8f2 !important; }
|
||
body.body--dark .q-card .text-red-400 { color: #f87171 !important; }
|
||
|
||
/* ── Dark mode: message area, inputs, tables ── */
|
||
body.body--dark .bg-gray-50 { background: #0c1a2e !important; color: #c0dce8 !important; }
|
||
body.body--dark .bg-gray-100 { background: #152a45 !important; }
|
||
body.body--dark .hover\\:bg-gray-100:hover { background: #1a3352 !important; }
|
||
body.body--dark .hover\\:bg-blue-50:hover { background: #0d2a4a !important; }
|
||
body.body--dark .bg-yellow-50 { background: rgba(72,202,228,0.06) !important; }
|
||
|
||
body.body--dark .q-field__control { background: #0c1a2e !important; color: #e0f0f8 !important; }
|
||
body.body--dark .q-field__native { color: #e0f0f8 !important; }
|
||
body.body--dark .q-field__label { color: #8badc4 !important; }
|
||
|
||
body.body--dark .q-table { background: #112240 !important; color: #c0dce8 !important; }
|
||
body.body--dark .q-table thead th { color: #48CAE4 !important; }
|
||
body.body--dark .q-table tbody td { color: #c0dce8 !important; }
|
||
|
||
body.body--dark .q-checkbox__label { color: #c0dce8 !important; }
|
||
body.body--dark .q-btn--flat:not(.domca-menu-btn):not(.domca-sub-btn) { color: #48CAE4 !important; }
|
||
|
||
body.body--dark .q-separator { background: rgba(0,119,182,0.2) !important; }
|
||
|
||
/* ── DOMCA menu link styling ── */
|
||
body.body--dark .domca-menu-btn { color: #8badc4 !important; }
|
||
body.body--dark .domca-menu-btn:hover { color: #48CAE4 !important; }
|
||
body.body--light .domca-menu-btn { color: #3d6380 !important; }
|
||
body.body--light .domca-menu-btn:hover { color: #0077B6 !important; }
|
||
|
||
body.body--dark .domca-ext-link { color: #8badc4 !important; }
|
||
body.body--light .domca-ext-link { color: #3d6380 !important; }
|
||
|
||
/* ── DOMCA active menu item ── */
|
||
body.body--dark .domca-menu-active { color: #48CAE4 !important; background: rgba(72,202,228,0.1) !important; }
|
||
body.body--light .domca-menu-active { color: #0077B6 !important; background: rgba(0,119,182,0.08) !important; }
|
||
|
||
/* ── DOMCA submenu item styling ── */
|
||
body.body--dark .domca-sub-btn { color: #6a8fa8 !important; }
|
||
body.body--dark .domca-sub-btn:hover { color: #48CAE4 !important; }
|
||
body.body--light .domca-sub-btn { color: #5a7a90 !important; }
|
||
body.body--light .domca-sub-btn:hover { color: #0077B6 !important; }
|
||
|
||
/* ── DOMCA expansion panel in drawer ── */
|
||
.domca-drawer .q-expansion-item {
|
||
font-family: 'JetBrains Mono', monospace !important;
|
||
letter-spacing: 2px;
|
||
font-size: 0.8rem;
|
||
}
|
||
.domca-drawer .q-expansion-item .q-item {
|
||
padding: 0.35rem 1.2rem !important;
|
||
min-height: 32px !important;
|
||
}
|
||
.domca-drawer .q-expansion-item .q-expansion-item__content {
|
||
padding: 0 !important;
|
||
}
|
||
.domca-drawer .q-expansion-item + .q-expansion-item {
|
||
margin-top: 0 !important;
|
||
}
|
||
body.body--dark .domca-drawer .q-expansion-item { color: #8badc4 !important; }
|
||
body.body--dark .domca-drawer .q-expansion-item__container { background: transparent !important; }
|
||
body.body--dark .domca-drawer .q-item { color: #8badc4 !important; }
|
||
body.body--light .domca-drawer .q-expansion-item { color: #3d6380 !important; }
|
||
body.body--light .domca-drawer .q-item { color: #3d6380 !important; }
|
||
|
||
/* ── Landing page centering ── */
|
||
.domca-landing {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: calc(100vh - 64px);
|
||
padding: 0.5rem;
|
||
}
|
||
.domca-landing svg {
|
||
width: min(90vw, 800px);
|
||
height: auto;
|
||
display: block;
|
||
}
|
||
|
||
/* ── Panel container — responsive single column ── */
|
||
.domca-panel {
|
||
width: 100%;
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
/* ── Responsive heights — override fixed Tailwind heights in panels ── */
|
||
.domca-panel .h-40 { height: calc(100vh - 20rem) !important; min-height: 10rem; }
|
||
.domca-panel .h-32 { height: calc(100vh - 24rem) !important; min-height: 8rem; }
|
||
.domca-panel .h-72 { height: calc(100vh - 12rem) !important; min-height: 14rem; }
|
||
.domca-panel .h-96 { height: calc(100vh - 8rem) !important; min-height: 16rem; }
|
||
.domca-panel .max-h-48 { max-height: calc(100vh - 16rem) !important; min-height: 6rem; }
|
||
|
||
/* ── Allow narrow viewports down to 320px ── */
|
||
body, .q-layout, .q-page {
|
||
min-width: 0 !important;
|
||
}
|
||
.q-drawer { max-width: 85vw !important; width: 360px !important; min-width: 240px !important; }
|
||
|
||
/* ── Mobile optimisations ── */
|
||
@media (max-width: 640px) {
|
||
.domca-landing svg { width: 98vw; }
|
||
.domca-panel { padding: 0.25rem; }
|
||
.domca-panel .q-card { border-radius: 8px !important; }
|
||
}
|
||
@media (max-width: 400px) {
|
||
.domca-landing { padding: 0.25rem; }
|
||
.domca-landing svg { width: 100vw; }
|
||
.q-header { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
|
||
}
|
||
|
||
/* ── Footer label ── */
|
||
.domca-footer {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 0.65rem;
|
||
letter-spacing: 2px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
/* ── Header text: icon-only on narrow viewports ── */
|
||
@media (max-width: 599px) {
|
||
.domca-header-text { display: none !important; }
|
||
}
|
||
</style>
|
||
'''
|
||
|
||
# ── Landing SVG loader ────────────────────────────────────────────────
|
||
# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign}
|
||
# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder
|
||
# when the file is missing.
|
||
|
||
|
||
def _load_landing_svg() -> str:
|
||
"""Load the landing page SVG from disk.
|
||
|
||
Returns:
|
||
SVG markup string with ``{callsign}`` replaced by the
|
||
configured operator callsign.
|
||
"""
|
||
path = config.LANDING_SVG_PATH
|
||
try:
|
||
raw = path.read_text(encoding="utf-8")
|
||
return raw.replace("{callsign}", config.OPERATOR_CALLSIGN)
|
||
except FileNotFoundError:
|
||
return (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 100">'
|
||
'<text x="200" y="55" text-anchor="middle" '
|
||
'font-family="\'JetBrains Mono\',monospace" font-size="14" '
|
||
f'fill="var(--title)">Landing SVG not found: {path.name}</text>'
|
||
'</svg>'
|
||
)
|
||
|
||
|
||
# ── Standalone menu items (no submenus) ──────────────────────────────
|
||
|
||
_STANDALONE_ITEMS = [
|
||
('\U0001f465', 'CONTACTS', 'contacts'),
|
||
('\U0001f5fa\ufe0f', 'MAP', 'map'),
|
||
('\U0001f4e1', 'DEVICE', 'device'),
|
||
('\u26a1', 'ACTIONS', 'actions'),
|
||
('\U0001f4ca', 'RX LOG', 'rxlog'),
|
||
('\U0001f916', 'BOT', 'bot'),
|
||
('\U0001f4cb', 'BBS', 'bbs'),
|
||
]
|
||
|
||
_EXT_LINKS = config.EXT_LINKS
|
||
|
||
# ── Shared button styles ─────────────────────────────────────────────
|
||
|
||
_SUB_BTN_STYLE = (
|
||
"font-family: 'JetBrains Mono', monospace; "
|
||
"letter-spacing: 1px; font-size: 0.72rem; "
|
||
"padding: 0.2rem 1.2rem 0.2rem 2.4rem"
|
||
)
|
||
|
||
_MENU_BTN_STYLE = (
|
||
"font-family: 'JetBrains Mono', monospace; "
|
||
"letter-spacing: 2px; font-size: 0.8rem; "
|
||
"padding: 0.35rem 1.2rem"
|
||
)
|
||
|
||
|
||
class DashboardPage:
|
||
"""Main dashboard rendered at ``/``.
|
||
|
||
Args:
|
||
shared: SharedDataReader for data access and command dispatch.
|
||
"""
|
||
|
||
def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore, bot_config_store: BotConfigStore | None = None, device_id: str = "", repeater_config_store=None, repeater_stats_archive=None, repeater_config_archive=None) -> None:
|
||
self._shared = shared
|
||
self._pin_store = pin_store
|
||
self._room_password_store = room_password_store
|
||
self._device_id = device_id
|
||
|
||
# BBS service and config store (singletons shared with bot routing)
|
||
self._bbs_config_store = BbsConfigStore()
|
||
self._bbs_service = BbsService()
|
||
self._bbs_handler = BbsCommandHandler(
|
||
self._bbs_service, self._bbs_config_store
|
||
)
|
||
|
||
# Bot config store — injected from __main__ so dashboard and worker
|
||
# share the same device-scoped file. Falls back to a default-scoped
|
||
# instance when not provided (e.g. unit tests, legacy callers).
|
||
self._bot_config_store = bot_config_store if bot_config_store is not None else BotConfigStore()
|
||
|
||
# Channel sort preference store — global (not per-device) UI setting
|
||
# driving the drawer Messages/Archive submenu order.
|
||
self._channel_sort_store = ChannelSortStore()
|
||
|
||
# Panels (created fresh on each render)
|
||
self._device: DevicePanel | None = None
|
||
self._contacts: ContactsPanel | None = None
|
||
self._map: MapPanel | None = None
|
||
self._messages: MessagesPanel | None = None
|
||
self._actions: ActionsPanel | None = None
|
||
self._rxlog: RxLogPanel | None = None
|
||
self._room_server: RoomServerPanel | None = None
|
||
self._bbs: BbsPanel | None = None
|
||
self._bot: BotPanel | None = None
|
||
|
||
# Repeater statistics — read-only panel, only present when both
|
||
# the configuration store and the archive are supplied.
|
||
self._repeater_config_store = repeater_config_store
|
||
self._repeater_stats_archive = repeater_stats_archive
|
||
self._repeater_config_archive = repeater_config_archive
|
||
self._repeater_stats: RepeaterStatsPanel | None = None
|
||
|
||
# Channel add dialog panel
|
||
self._channel_panel: ChannelPanel | None = None
|
||
|
||
# Channel backup / restore dialogs
|
||
self._channel_backup_panel: ChannelBackupPanel | None = None
|
||
|
||
# Channel delete confirmation dialog
|
||
self._confirm_delete_dialog = None
|
||
self._confirm_delete_label = None
|
||
self._pending_delete_cmd: dict | None = None
|
||
|
||
# Header status label
|
||
self._status_label = None
|
||
|
||
# Local first-render flag
|
||
self._initialized: bool = False
|
||
|
||
# Panel switching state (layout)
|
||
self._panel_containers: dict = {}
|
||
self._active_panel: str = 'landing'
|
||
self._drawer = None
|
||
self._menu_buttons: dict = {}
|
||
|
||
# Submenu containers (for dynamic channel/room items)
|
||
self._msg_sub_container = None
|
||
self._archive_sub_container = None
|
||
self._rooms_sub_container = None
|
||
self._last_channel_fingerprint = None
|
||
self._last_rooms_fingerprint = None
|
||
|
||
# Archive page reference (for inline channel switching)
|
||
self._archive_page: ArchivePage | None = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public
|
||
# ------------------------------------------------------------------
|
||
|
||
def render(self) -> None:
|
||
"""Build the complete dashboard layout and start the timer."""
|
||
self._initialized = False
|
||
|
||
# Reset fingerprints: render() creates new (empty) NiceGUI
|
||
# containers, so _update_submenus must rebuild into them even
|
||
# when the channel/room data hasn't changed since last session.
|
||
self._last_channel_fingerprint = None
|
||
self._last_rooms_fingerprint = None
|
||
|
||
# Create panel instances (UNCHANGED functional wiring)
|
||
put_cmd = self._shared.put_command
|
||
self._device = DevicePanel()
|
||
self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server)
|
||
self._map = MapPanel()
|
||
self._messages = MessagesPanel(put_cmd)
|
||
self._actions = ActionsPanel(put_cmd)
|
||
self._rxlog = RxLogPanel()
|
||
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
|
||
self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
|
||
self._bot = BotPanel(
|
||
put_cmd,
|
||
self._shared.set_bot_enabled,
|
||
self._bot_config_store,
|
||
self._pin_store,
|
||
)
|
||
if (
|
||
self._repeater_config_store is not None
|
||
and self._repeater_stats_archive is not None
|
||
):
|
||
self._repeater_stats = RepeaterStatsPanel(
|
||
self._repeater_config_store,
|
||
self._repeater_stats_archive,
|
||
put_command=put_cmd,
|
||
config_archive=self._repeater_config_archive,
|
||
)
|
||
|
||
self._channel_panel = ChannelPanel(put_cmd)
|
||
self._channel_panel.render()
|
||
|
||
self._channel_backup_panel = ChannelBackupPanel(
|
||
self._device_id, put_cmd
|
||
)
|
||
self._channel_backup_panel.render()
|
||
|
||
# ── Channel delete confirmation dialog ────────────────────
|
||
self._confirm_delete_dialog = ui.dialog()
|
||
with self._confirm_delete_dialog:
|
||
with ui.card().classes('w-full').style('min-width: 280px; max-width: 380px'):
|
||
ui.label('🗑️ Delete Channel').classes('font-bold text-gray-600 text-base')
|
||
self._confirm_delete_label = ui.label('').classes('text-sm text-gray-500')
|
||
with ui.row().classes('gap-2 justify-end w-full'):
|
||
ui.button(
|
||
'Cancel',
|
||
on_click=lambda: self._confirm_delete_dialog.close(),
|
||
).props('flat no-caps')
|
||
ui.button(
|
||
'Delete',
|
||
on_click=self._on_delete_confirmed,
|
||
).props('unelevated color=negative no-caps')
|
||
|
||
# Inject DOMCA theme (fonts + CSS variables)
|
||
ui.add_head_html(_DOMCA_HEAD)
|
||
|
||
# Default to dark mode (DOMCA theme)
|
||
dark = ui.dark_mode(True)
|
||
dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value))
|
||
self._map.set_ui_dark_mode(dark.value)
|
||
|
||
# ── Left Drawer (must be created before header for Quasar) ────
|
||
self._drawer = ui.left_drawer(value=False, bordered=True).classes(
|
||
'domca-drawer'
|
||
).style('padding: 0')
|
||
|
||
with self._drawer:
|
||
# DOMCA branding (clickable → landing page)
|
||
with ui.column().style('padding: 0.2rem 1.2rem 0'):
|
||
ui.button(
|
||
'DOMCA',
|
||
on_click=lambda: self._navigate_panel('landing'),
|
||
).props('flat no-caps').style(
|
||
"font-family: 'Exo 2', sans-serif; font-size: 1.4rem; "
|
||
"font-weight: 800; color: var(--title); letter-spacing: 4px; "
|
||
"margin-bottom: 0.3rem; padding: 0"
|
||
)
|
||
|
||
self._menu_buttons = {}
|
||
|
||
# ── 💬 MESSAGES (expandable with channel submenu) ──────
|
||
with ui.expansion(
|
||
'\U0001f4ac MESSAGES', icon=None, value=False,
|
||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||
self._msg_sub_container = ui.column().classes('w-full gap-0')
|
||
with self._msg_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('messages', channel=None)
|
||
)
|
||
self._make_sub_btn(
|
||
'DM', lambda: self._navigate_panel('messages', channel='DM')
|
||
)
|
||
# Dynamic channel items populated by _update_submenus
|
||
self._make_sub_btn(
|
||
'+ Add Channel',
|
||
lambda: self._channel_panel.open() if self._channel_panel else None,
|
||
)
|
||
self._make_sub_btn(
|
||
'💾 Backup Channels',
|
||
lambda: (
|
||
self._channel_backup_panel.open_backup()
|
||
if self._channel_backup_panel else None
|
||
),
|
||
)
|
||
self._make_sub_btn(
|
||
'📥 Restore Channels',
|
||
lambda: (
|
||
self._channel_backup_panel.open_restore()
|
||
if self._channel_backup_panel else None
|
||
),
|
||
)
|
||
|
||
# ── 🏠 ROOMS (expandable with room submenu) ───────────
|
||
with ui.expansion(
|
||
'\U0001f3e0 ROOMS', icon=None, value=False,
|
||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||
self._rooms_sub_container = ui.column().classes('w-full gap-0')
|
||
with self._rooms_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('rooms')
|
||
)
|
||
# Pre-populate from persisted rooms
|
||
for entry in self._room_password_store.get_rooms():
|
||
short = entry.name or entry.pubkey[:12]
|
||
self._make_sub_btn(
|
||
f'\U0001f3e0 {short}',
|
||
lambda: self._navigate_panel('rooms'),
|
||
)
|
||
|
||
# ── 📚 ARCHIVE (expandable with channel submenu) ──────
|
||
with ui.expansion(
|
||
'\U0001f4da ARCHIVE', icon=None, value=False,
|
||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||
self._archive_sub_container = ui.column().classes('w-full gap-0')
|
||
with self._archive_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('archive', channel=None)
|
||
)
|
||
self._make_sub_btn(
|
||
'DM', lambda: self._navigate_panel('archive', channel='DM')
|
||
)
|
||
# Dynamic channel items populated by _update_submenus
|
||
|
||
ui.separator().classes('my-1')
|
||
|
||
# ── Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG)
|
||
_items = list(_STANDALONE_ITEMS)
|
||
if self._repeater_stats is not None:
|
||
_items.append(('\U0001f4f6', 'REPEATERS', 'repeaters'))
|
||
for icon, label, panel_id in _items:
|
||
btn = ui.button(
|
||
f'{icon} {label}',
|
||
on_click=lambda pid=panel_id: self._navigate_panel(pid),
|
||
).props('flat no-caps align=left').classes(
|
||
'w-full justify-start domca-menu-btn'
|
||
).style(_MENU_BTN_STYLE)
|
||
self._menu_buttons[panel_id] = btn
|
||
|
||
ui.separator().classes('my-2')
|
||
|
||
# External links (same as domca.nl navigation)
|
||
with ui.column().style('padding: 0 1.2rem'):
|
||
for label, url in _EXT_LINKS:
|
||
ui.link(label, url, new_tab=True).classes(
|
||
'domca-ext-link'
|
||
).style(
|
||
"font-family: 'JetBrains Mono', monospace; "
|
||
"letter-spacing: 2px; font-size: 0.72rem; "
|
||
"text-decoration: none; opacity: 0.6; "
|
||
"display: block; padding: 0.35rem 0"
|
||
)
|
||
|
||
# Footer in drawer
|
||
ui.space()
|
||
ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem')
|
||
|
||
# ── Header ────────────────────────────────────────────────
|
||
with ui.header().classes('items-center px-4 py-2 shadow-md'):
|
||
menu_btn = ui.button(
|
||
icon='menu',
|
||
on_click=lambda: self._drawer.toggle(),
|
||
).props('flat round dense color=white')
|
||
|
||
# Swap icon: menu ↔ close
|
||
self._drawer.on_value_change(
|
||
lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}')
|
||
)
|
||
|
||
ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes(
|
||
'text-lg font-bold ml-2 domca-header-text'
|
||
).style("font-family: 'JetBrains Mono', monospace")
|
||
|
||
# Transport mode badge
|
||
_is_ble = config.TRANSPORT == "ble"
|
||
_badge_icon = '🔵' if _is_ble else '🟢'
|
||
_badge_label = 'BLE' if _is_ble else 'Serial'
|
||
ui.label(f'{_badge_icon} {_badge_label}').classes(
|
||
'text-xs ml-2 domca-header-text'
|
||
).style(
|
||
"font-family: 'JetBrains Mono', monospace; "
|
||
"opacity: 0.65; letter-spacing: 1px"
|
||
)
|
||
|
||
ui.space()
|
||
|
||
_initial_status = self._shared.get_snapshot().get('status', 'Starting...')
|
||
self._status_label = ui.label(_initial_status).classes(
|
||
'text-sm opacity-70 domca-header-text'
|
||
)
|
||
|
||
ui.button(
|
||
icon='brightness_6',
|
||
on_click=lambda: dark.toggle(),
|
||
).props('flat round dense color=white').tooltip('Toggle dark / light')
|
||
|
||
# ── Main Content Area ─────────────────────────────────────
|
||
self._panel_containers = {}
|
||
|
||
# Landing page (SVG splash from file — visible by default)
|
||
landing = ui.column().classes('domca-landing w-full')
|
||
with landing:
|
||
ui.html(_load_landing_svg())
|
||
self._panel_containers['landing'] = landing
|
||
|
||
# Panel containers (hidden by default, shown on menu click)
|
||
panel_defs = [
|
||
('messages', self._messages),
|
||
('contacts', self._contacts),
|
||
('map', self._map),
|
||
('device', self._device),
|
||
('actions', self._actions),
|
||
('rxlog', self._rxlog),
|
||
('rooms', self._room_server),
|
||
('bbs', self._bbs),
|
||
('bot', self._bot),
|
||
]
|
||
|
||
if self._repeater_stats is not None:
|
||
panel_defs.append(('repeaters', self._repeater_stats))
|
||
|
||
for panel_id, panel_obj in panel_defs:
|
||
container = ui.column().classes('domca-panel')
|
||
container.set_visibility(False)
|
||
with container:
|
||
panel_obj.render()
|
||
self._panel_containers[panel_id] = container
|
||
|
||
# Archive panel (inline — replaces separate /archive page)
|
||
archive_container = ui.column().classes('domca-panel')
|
||
archive_container.set_visibility(False)
|
||
with archive_container:
|
||
self._archive_page = ArchivePage(self._shared)
|
||
self._archive_page.render()
|
||
self._panel_containers['archive'] = archive_container
|
||
|
||
self._active_panel = 'landing'
|
||
|
||
# Start update timer
|
||
self._apply_url_state()
|
||
ui.timer(0.5, self._update_ui)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Submenu button helper (layout only)
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _make_sub_btn(label: str, on_click) -> ui.button:
|
||
"""Create a submenu button in the drawer."""
|
||
return ui.button(
|
||
label,
|
||
on_click=on_click,
|
||
).props('flat no-caps align=left').classes(
|
||
'w-full justify-start domca-sub-btn'
|
||
).style(_SUB_BTN_STYLE)
|
||
|
||
@staticmethod
|
||
def _make_channel_sub_item(label: str, on_click, on_delete, on_move) -> None:
|
||
"""Create a channel submenu item with inline move and delete buttons.
|
||
|
||
Renders a full-width row containing a navigation button (flex-1),
|
||
a compact move button (↕) and a compact delete button (🗑).
|
||
|
||
Args:
|
||
label: Button label text (e.g. '[1] #localmesh').
|
||
on_click: Callback for navigating to the channel.
|
||
on_delete: Callback for deleting the channel.
|
||
on_move: Callback for opening the move/reindex dialog.
|
||
"""
|
||
with ui.row().classes('w-full gap-0 items-center').style('padding: 0'):
|
||
ui.button(
|
||
label,
|
||
on_click=on_click,
|
||
).props('flat no-caps align=left').classes(
|
||
'flex-1 justify-start domca-sub-btn'
|
||
).style(_SUB_BTN_STYLE)
|
||
ui.button(
|
||
'↕',
|
||
on_click=on_move,
|
||
).props('flat dense no-caps').classes(
|
||
'domca-sub-btn'
|
||
).style(
|
||
"font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem"
|
||
)
|
||
ui.button(
|
||
'🗑',
|
||
on_click=on_delete,
|
||
).props('flat dense no-caps').classes(
|
||
'domca-sub-btn'
|
||
).style(
|
||
"font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem"
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Channel sort helpers (drawer submenu)
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _sort_btn_label(mode: str) -> str:
|
||
"""Return the display label for the sort-toggle sub-button.
|
||
|
||
The label reflects the CURRENT sort mode, so the user sees at a
|
||
glance how the list is ordered; clicking the button switches to
|
||
the other mode.
|
||
|
||
Args:
|
||
mode: Current sort mode string.
|
||
|
||
Returns:
|
||
A label suitable for passing to :meth:`_make_sub_btn`.
|
||
"""
|
||
if mode == CHANNEL_SORT_BY_NAME:
|
||
return '↕ Sort: name'
|
||
return '↕ Sort: index'
|
||
|
||
def _toggle_channel_sort(self) -> None:
|
||
"""Flip the drawer channel sort mode and refresh both submenus.
|
||
|
||
Persists the new mode via :class:`ChannelSortStore`, invalidates
|
||
the submenu fingerprint to force a rebuild, and triggers an
|
||
immediate refresh so the user sees the reordered list without
|
||
waiting for the next 500 ms dashboard tick.
|
||
"""
|
||
self._channel_sort_store.toggle_mode()
|
||
self._last_channel_fingerprint = None
|
||
data = self._shared.get_snapshot()
|
||
self._update_submenus(data)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Dynamic submenu updates (layout — called from _update_ui)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _update_submenus(self, data: dict) -> None:
|
||
"""Rebuild channel/room submenu items when data changes.
|
||
|
||
Only the dynamic items are rebuilt; the container is cleared and
|
||
ALL items (static + dynamic) are re-rendered.
|
||
"""
|
||
# ── Channel submenus (Messages + Archive) ──
|
||
channels = data.get('channels', [])
|
||
sort_mode = self._channel_sort_store.get_mode()
|
||
# Sort mode is part of the fingerprint so a user-initiated
|
||
# toggle forces a rebuild even when the channel list itself is
|
||
# unchanged.
|
||
ch_fingerprint = (
|
||
tuple((ch['idx'], ch['name']) for ch in channels),
|
||
sort_mode,
|
||
)
|
||
|
||
if ch_fingerprint != self._last_channel_fingerprint and channels:
|
||
self._last_channel_fingerprint = ch_fingerprint
|
||
sorted_channels = sort_channels(channels, sort_mode)
|
||
|
||
# Rebuild Messages submenu
|
||
if self._msg_sub_container:
|
||
self._msg_sub_container.clear()
|
||
with self._msg_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('messages', channel=None)
|
||
)
|
||
self._make_sub_btn(
|
||
'DM', lambda: self._navigate_panel('messages', channel='DM')
|
||
)
|
||
self._make_sub_btn(
|
||
self._sort_btn_label(sort_mode),
|
||
self._toggle_channel_sort,
|
||
)
|
||
for ch in sorted_channels:
|
||
idx = ch['idx']
|
||
name = ch['name']
|
||
self._make_channel_sub_item(
|
||
f"[{idx}] {name}",
|
||
on_click=lambda i=idx: self._navigate_panel('messages', channel=i),
|
||
on_delete=lambda i=idx, n=name, chs=channels: (
|
||
self._open_delete_confirm(i, n, chs)
|
||
),
|
||
on_move=lambda i=idx: (
|
||
self._channel_panel.open(mode='move', preselect_idx=i)
|
||
if self._channel_panel else None
|
||
),
|
||
)
|
||
self._make_sub_btn(
|
||
'+ Add Channel',
|
||
lambda: self._channel_panel.open() if self._channel_panel else None,
|
||
)
|
||
self._make_sub_btn(
|
||
'💾 Backup Channels',
|
||
lambda: (
|
||
self._channel_backup_panel.open_backup()
|
||
if self._channel_backup_panel else None
|
||
),
|
||
)
|
||
self._make_sub_btn(
|
||
'📥 Restore Channels',
|
||
lambda: (
|
||
self._channel_backup_panel.open_restore()
|
||
if self._channel_backup_panel else None
|
||
),
|
||
)
|
||
|
||
# Rebuild Archive submenu
|
||
if self._archive_sub_container:
|
||
self._archive_sub_container.clear()
|
||
with self._archive_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('archive', channel=None)
|
||
)
|
||
self._make_sub_btn(
|
||
'DM', lambda: self._navigate_panel('archive', channel='DM')
|
||
)
|
||
self._make_sub_btn(
|
||
self._sort_btn_label(sort_mode),
|
||
self._toggle_channel_sort,
|
||
)
|
||
for ch in sorted_channels:
|
||
idx = ch['idx']
|
||
name = ch['name']
|
||
self._make_channel_sub_item(
|
||
f"[{idx}] {name}",
|
||
on_click=lambda n=name: self._navigate_panel('archive', channel=n),
|
||
on_delete=lambda i=idx, n=name, chs=channels: (
|
||
self._open_delete_confirm(i, n, chs)
|
||
),
|
||
on_move=lambda i=idx: (
|
||
self._channel_panel.open(mode='move', preselect_idx=i)
|
||
if self._channel_panel else None
|
||
),
|
||
)
|
||
|
||
# ── Room submenus ──
|
||
rooms = self._room_password_store.get_rooms()
|
||
rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms)
|
||
|
||
if rooms_fingerprint != self._last_rooms_fingerprint:
|
||
self._last_rooms_fingerprint = rooms_fingerprint
|
||
|
||
if self._rooms_sub_container:
|
||
self._rooms_sub_container.clear()
|
||
with self._rooms_sub_container:
|
||
self._make_sub_btn(
|
||
'ALL', lambda: self._navigate_panel('rooms')
|
||
)
|
||
for entry in rooms:
|
||
short = entry.name or entry.pubkey[:12]
|
||
self._make_sub_btn(
|
||
f'\U0001f3e0 {short}',
|
||
lambda: self._navigate_panel('rooms'),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Panel switching (layout helper — no functional logic)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _apply_url_state(self) -> None:
|
||
"""Apply panel selection from URL query params on first render."""
|
||
try:
|
||
params = ui.context.client.request.query_params
|
||
except Exception:
|
||
return
|
||
|
||
panel = params.get('panel') or 'landing'
|
||
channel = params.get('channel')
|
||
|
||
if panel not in self._panel_containers:
|
||
panel = 'landing'
|
||
channel = None
|
||
|
||
if panel == 'messages':
|
||
if channel is None or channel.lower() == 'all':
|
||
channel = None
|
||
elif channel.upper() == 'DM':
|
||
channel = 'DM'
|
||
else:
|
||
channel = int(channel) if channel.isdigit() else None
|
||
elif panel == 'archive':
|
||
if channel is None or channel.lower() == 'all':
|
||
channel = None
|
||
elif channel.upper() == 'DM':
|
||
channel = 'DM'
|
||
else:
|
||
channel = None
|
||
|
||
self._show_panel(panel, channel)
|
||
|
||
def _build_panel_url(self, panel_id: str, channel=None) -> str:
|
||
params = {'panel': panel_id}
|
||
if channel is not None:
|
||
params['channel'] = str(channel)
|
||
return '/?' + urlencode(params)
|
||
|
||
def _navigate_panel(self, panel_id: str, channel=None) -> None:
|
||
"""Navigate with panel id in the URL so browser back restores state."""
|
||
ui.navigate.to(self._build_panel_url(panel_id, channel))
|
||
|
||
def _show_panel(self, panel_id: str, channel=None) -> None:
|
||
"""Show the selected panel, hide all others, close the drawer.
|
||
|
||
Args:
|
||
panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms').
|
||
channel: Optional channel filter.
|
||
For messages: None=all, 'DM'=DM only, int=channel idx.
|
||
For archive: None=all, 'DM'=DM only, str=channel name.
|
||
"""
|
||
for pid, container in self._panel_containers.items():
|
||
container.set_visibility(pid == panel_id)
|
||
self._active_panel = panel_id
|
||
|
||
# Apply channel filter to messages panel
|
||
if panel_id == 'messages' and self._messages:
|
||
self._messages.set_active_channel(channel)
|
||
|
||
# Apply channel filter to archive panel
|
||
if panel_id == 'archive' and self._archive_page:
|
||
self._archive_page.set_channel_filter(channel)
|
||
|
||
self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
|
||
|
||
# Update active menu highlight (standalone buttons only)
|
||
for pid, btn in self._menu_buttons.items():
|
||
if pid == panel_id:
|
||
btn.classes('domca-menu-active', remove='')
|
||
else:
|
||
btn.classes(remove='domca-menu-active')
|
||
|
||
# Close drawer after selection
|
||
if self._drawer:
|
||
self._drawer.hide()
|
||
|
||
def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
|
||
"""Refresh only the currently visible panel.
|
||
|
||
This is used directly after a panel switch so the user does not
|
||
need to wait for the next 500 ms dashboard tick.
|
||
"""
|
||
data = self._shared.get_snapshot()
|
||
|
||
if data.get('channels'):
|
||
self._messages.update_filters(data)
|
||
self._messages.update_channel_options(data['channels'])
|
||
self._update_submenus(data)
|
||
|
||
if self._active_panel == 'device':
|
||
self._device.update(data)
|
||
elif self._active_panel == 'map':
|
||
if force_map_center:
|
||
data['force_center'] = True
|
||
self._map.update(data)
|
||
elif self._active_panel == 'actions':
|
||
self._actions.update(data)
|
||
elif self._active_panel == 'contacts':
|
||
self._contacts.update(data)
|
||
elif self._active_panel == 'messages':
|
||
self._messages.update(
|
||
data,
|
||
self._messages.channel_filters,
|
||
self._messages.last_channels,
|
||
room_pubkeys=(
|
||
self._room_server.get_room_pubkeys()
|
||
if self._room_server else None
|
||
),
|
||
)
|
||
elif self._active_panel == 'rooms':
|
||
self._room_server.update(data)
|
||
elif self._active_panel == 'rxlog':
|
||
self._rxlog.update(data)
|
||
elif self._active_panel == 'bbs':
|
||
if self._bbs:
|
||
self._bbs.update(data)
|
||
elif self._active_panel == 'bot':
|
||
if self._bot:
|
||
self._bot.update(data)
|
||
elif self._active_panel == 'repeaters':
|
||
if self._repeater_stats:
|
||
self._repeater_stats.update(data)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Room Server callback (from ContactsPanel)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None:
|
||
"""Handle adding a Room Server from the contacts panel.
|
||
|
||
Delegates to the RoomServerPanel which persists the entry,
|
||
creates the UI card and sends the login command.
|
||
"""
|
||
if self._room_server:
|
||
self._room_server.add_room(pubkey, name, password)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Channel delete confirmation (dialog + dispatch)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _open_delete_confirm(self, idx: int, name: str, channels: list) -> None:
|
||
"""Open the delete confirmation dialog for a channel.
|
||
|
||
Stores the pending command so ``_on_delete_confirmed`` can dispatch
|
||
it without needing to capture mutable closure state.
|
||
|
||
Args:
|
||
idx: Channel index to delete.
|
||
name: Channel name (shown in the dialog label).
|
||
channels: Current channel list snapshot passed to the handler.
|
||
"""
|
||
self._pending_delete_cmd = {
|
||
'action': 'del_channel',
|
||
'idx': idx,
|
||
'channels': list(channels),
|
||
}
|
||
if self._confirm_delete_label:
|
||
self._confirm_delete_label.text = (
|
||
f'Remove channel [{idx}] "{name}" from the device? '
|
||
'Higher-numbered channels will be re-indexed automatically.'
|
||
)
|
||
if self._confirm_delete_dialog:
|
||
self._confirm_delete_dialog.open()
|
||
|
||
def _on_delete_confirmed(self) -> None:
|
||
"""Dispatch the pending delete command and close the dialog."""
|
||
if self._confirm_delete_dialog:
|
||
self._confirm_delete_dialog.close()
|
||
if self._pending_delete_cmd is not None:
|
||
self._shared.put_command(self._pending_delete_cmd)
|
||
self._pending_delete_cmd = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Timer-driven UI update
|
||
# ------------------------------------------------------------------
|
||
|
||
def _update_ui(self) -> None:
|
||
try:
|
||
if not self._status_label:
|
||
return
|
||
|
||
# Atomic snapshot + flag clear: eliminates race condition
|
||
# where worker sets channels_updated between separate
|
||
# get_snapshot() and clear_update_flags() calls.
|
||
data = self._shared.get_snapshot_and_clear_flags()
|
||
is_first = not self._initialized
|
||
|
||
# Mark initialised immediately — even if a panel update
|
||
# crashes below, we must NOT retry the full first-render
|
||
# path every 500 ms (that causes the infinite rebuild).
|
||
if is_first:
|
||
self._initialized = True
|
||
|
||
# Always update status
|
||
self._status_label.text = data['status']
|
||
|
||
# Channel-dependent drawer/submenu state may stay global.
|
||
# The helpers below already contain equality checks, so this
|
||
# remains cheap while keeping navigation consistent.
|
||
if data['channels']:
|
||
self._messages.update_filters(data)
|
||
self._messages.update_channel_options(data['channels'])
|
||
self._update_submenus(data)
|
||
if self._channel_panel:
|
||
self._channel_panel.update(data)
|
||
if self._channel_backup_panel:
|
||
self._channel_backup_panel.update(data)
|
||
|
||
if self._active_panel == 'device':
|
||
if data['device_updated'] or is_first:
|
||
self._device.update(data)
|
||
|
||
elif self._active_panel == 'map':
|
||
# Keep sending snapshots while the map panel is active.
|
||
# The browser runtime coalesces pending payloads, so only
|
||
# the newest snapshot is applied.
|
||
self._map.update(data)
|
||
|
||
elif self._active_panel == 'actions':
|
||
if data['channels_updated'] or is_first:
|
||
self._actions.update(data)
|
||
|
||
elif self._active_panel == 'contacts':
|
||
if data['contacts_updated'] or is_first:
|
||
self._contacts.update(data)
|
||
|
||
elif self._active_panel == 'messages':
|
||
self._messages.update(
|
||
data,
|
||
self._messages.channel_filters,
|
||
self._messages.last_channels,
|
||
room_pubkeys=(
|
||
self._room_server.get_room_pubkeys()
|
||
if self._room_server else None
|
||
),
|
||
)
|
||
|
||
elif self._active_panel == 'rooms':
|
||
self._room_server.update(data)
|
||
|
||
elif self._active_panel == 'rxlog':
|
||
if data['rxlog_updated'] or is_first:
|
||
self._rxlog.update(data)
|
||
|
||
elif self._active_panel == 'bbs':
|
||
if self._bbs:
|
||
self._bbs.update(data)
|
||
|
||
elif self._active_panel == 'bot':
|
||
if self._bot:
|
||
self._bot.update(data)
|
||
|
||
elif self._active_panel == 'repeaters':
|
||
if self._repeater_stats:
|
||
self._repeater_stats.update(data)
|
||
|
||
# Signal worker that GUI is ready for data
|
||
if is_first and data['channels'] and data['contacts']:
|
||
self._shared.mark_gui_initialized()
|
||
|
||
except Exception as e:
|
||
err = str(e).lower()
|
||
if "deleted" not in err and "client" not in err:
|
||
import traceback
|
||
print(f"GUI update error: {e}")
|
||
traceback.print_exc()
|