From 582e56ffb5bbab68671a3e771050ba352df30449 Mon Sep 17 00:00:00 2001 From: Stacy Olivas Date: Sun, 22 Mar 2026 23:46:40 -0700 Subject: [PATCH] feat: zombie radio banner on all pages + zombie alert config screen options - base.html: persistent danger banner appears on every web page when is_radio_zombie is true; shows datetime zombie was detected; includes "Restart Bot Processing" button (POST /api/admin/zombie-recover) that clears _radio_zombie_detected and _radio_fail_count on the live bot object and removes the persisted DB flag; banner turns green on success - config.html: new "Zombie Radio Alert" card with enable/disable toggle and alert-email field; "Save" writes to bot_metadata (immediate, survives restarts); "Save to config.ini" also persists values to config.ini and keeps the in-memory config in sync; card shows current config.ini values as baseline defaults - app.py: inject_template_vars context processor now provides radio_zombie and radio_zombie_since to all templates; added GET/POST /api/config/zombie-alert endpoints (GET returns both bot_metadata and config.ini values; POST supports write_to_config flag); added POST /api/admin/zombie-recover endpoint; stored config_path on self for write-back use - scheduler.py: send_zombie_alert_email now prefers bot_metadata (zombie.alert_enabled, zombie.alert_email) over config.ini so web UI changes take effect without a restart; uses isinstance(..., str) guard so mock/None values safely fall through to config.ini defaults --- modules/scheduler.py | 25 +++++- modules/web_viewer/app.py | 113 ++++++++++++++++++++++++- modules/web_viewer/templates/base.html | 57 +++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/modules/scheduler.py b/modules/scheduler.py index cac476f..e0bf65e 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -1201,7 +1201,18 @@ class MessageScheduler: import ssl as _ssl from email.message import EmailMessage - if not self.bot.config.getboolean('Bot', 'radio_zombie_alert_enabled', fallback=True): + # Prefer bot_metadata (set via web UI) over config.ini so the config page + # takes effect without requiring a config.ini edit or bot restart. + # Use isinstance(…, str) so a missing/mock value safely falls through. + try: + alert_enabled_meta = self.bot.db_manager.get_metadata('zombie.alert_enabled') + except Exception: + alert_enabled_meta = None + if isinstance(alert_enabled_meta, str) and alert_enabled_meta: + alert_enabled = alert_enabled_meta.lower() == 'true' + else: + alert_enabled = self.bot.config.getboolean('Bot', 'radio_zombie_alert_enabled', fallback=True) + if not alert_enabled: return smtp_host = self._get_notif('smtp_host') @@ -1211,8 +1222,16 @@ class MessageScheduler: from_name = self._get_notif('from_name') or 'MeshCore Bot' from_email = self._get_notif('from_email') - # Alert recipients: dedicated config key, falls back to nightly recipients - alert_email_cfg = self.bot.config.get('Bot', 'radio_zombie_alert_email', fallback='').strip() + # Alert recipients: bot_metadata first, then config.ini, then nightly recipients + try: + _email_meta = self.bot.db_manager.get_metadata('zombie.alert_email') + alert_email_meta = _email_meta.strip() if isinstance(_email_meta, str) else '' + except Exception: + alert_email_meta = '' + alert_email_cfg = ( + alert_email_meta + or self.bot.config.get('Bot', 'radio_zombie_alert_email', fallback='').strip() + ) if alert_email_cfg: recipients = [r.strip() for r in alert_email_cfg.split(',') if r.strip()] else: diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 428aa17..d3d206c 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -142,6 +142,7 @@ class BotDataViewer: # Load configuration self.config = self._load_config(config_path) + self.config_path = config_path # kept for config.ini write-back endpoints # Resolve db_path relative to the config file's directory — matches core.py's bot_root # property which is Path(config_file).parent.resolve(). Using self.bot_root (the project @@ -277,15 +278,30 @@ class BotDataViewer: bot_name = (self.config.get('Bot', 'bot_name', fallback='MeshCore Bot') or '').strip() or 'MeshCore Bot' except (configparser.NoSectionError, configparser.NoOptionError): bot_name = 'MeshCore Bot' + try: + radio_zombie = self.db_manager.get_metadata('bot.radio_zombie') == 'true' + radio_zombie_since = self.db_manager.get_metadata('bot.radio_zombie_since') or None + except Exception: + radio_zombie = False + radio_zombie_since = None return { 'greeter_enabled': greeter_enabled, 'feed_manager_enabled': feed_manager_enabled, 'bot_name': bot_name, 'version_info': version_info, + 'radio_zombie': radio_zombie, + 'radio_zombie_since': radio_zombie_since, } except Exception as e: self.logger.exception("Template context processor failed: %s", e) - return {'greeter_enabled': False, 'feed_manager_enabled': False, 'bot_name': 'MeshCore Bot', 'version_info': version_info} + return { + 'greeter_enabled': False, + 'feed_manager_enabled': False, + 'bot_name': 'MeshCore Bot', + 'version_info': version_info, + 'radio_zombie': False, + 'radio_zombie_since': None, + } def _init_databases(self): """Initialize database connections""" @@ -1488,6 +1504,101 @@ class BotDataViewer: self.logger.info(f"Maintenance config updated: {', '.join(saved)}") return jsonify({'success': True, 'saved': saved}) + # ── Zombie radio alert config ──────────────────────────────────────── + + @self.app.route('/api/config/zombie-alert') + def api_config_zombie_alert_get() -> "Response": + """Return zombie alert settings. + + Response includes both ``bot_metadata`` values (set via web UI) and + ``config_ini`` values (read from config.ini) so the browser can + show config.ini as the baseline defaults. + """ + meta: dict[str, str] = {} + for key in ('zombie.alert_enabled', 'zombie.alert_email'): + short = key.split('.', 1)[1] + val = self.db_manager.get_metadata(key) + meta[short] = val if isinstance(val, str) else '' + if not meta.get('alert_enabled'): + meta['alert_enabled'] = 'false' + ini: dict[str, str] = { + 'alert_enabled': ( + 'true' + if self.config.getboolean('Bot', 'radio_zombie_alert_enabled', fallback=False) + else 'false' + ), + 'alert_email': self.config.get('Bot', 'radio_zombie_alert_email', fallback=''), + } + return jsonify({'meta': meta, 'config_ini': ini}) + + @self.app.route('/api/config/zombie-alert', methods=['POST']) + def api_config_zombie_alert_post() -> "Response": + """Save zombie alert settings to bot_metadata. + + If ``write_to_config`` is ``true`` in the request body, the values + are also written back to config.ini under ``[Bot]``. The config + object in memory is updated immediately so the scheduler reads the + new values without a restart. + """ + data = request.get_json(silent=True) or {} + allowed = {'alert_enabled', 'alert_email'} + saved = [] + for field in allowed: + if field in data: + self.db_manager.set_metadata(f'zombie.{field}', str(data[field])) + saved.append(field) + self.logger.info("Zombie alert config updated (metadata): %s", ', '.join(saved)) + + write_to_config = str(data.get('write_to_config', '')).lower() == 'true' + config_saved = False + if write_to_config: + try: + if not self.config.has_section('Bot'): + self.config.add_section('Bot') + if 'alert_enabled' in data: + self.config.set( + 'Bot', 'radio_zombie_alert_enabled', + 'true' if str(data['alert_enabled']).lower() == 'true' else 'false', + ) + if 'alert_email' in data: + self.config.set('Bot', 'radio_zombie_alert_email', str(data['alert_email'])) + with open(self.config_path, 'w') as fh: + self.config.write(fh) + config_saved = True + self.logger.info("Zombie alert settings written to config.ini") + except OSError as exc: + self.logger.error("Failed to write zombie alert settings to config.ini: %s", exc) + return jsonify({ + 'success': False, + 'error': 'Could not write config.ini — check file permissions', + }), 500 + + return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) + + # ── Zombie recover ─────────────────────────────────────────────────── + + @self.app.route('/api/admin/zombie-recover', methods=['POST']) + def api_admin_zombie_recover() -> "Response": + """Clear zombie state so bot resumes processing after a radio power cycle. + + Clears the ``_radio_zombie_detected`` flag on the live bot object (if + accessible) and removes the persisted flag from bot_metadata so the + web-viewer banner disappears on the next page load. + """ + try: + self.db_manager.set_metadata('bot.radio_zombie', 'false') + self.db_manager.set_metadata('bot.radio_zombie_since', '') + bot = getattr(self, 'bot', None) + if bot is not None: + bot._radio_zombie_detected = False + bot._radio_fail_count = 0 + bot._last_radio_probe = 0 # force probe on next cycle + self.logger.info("Zombie state cleared via web UI recover action") + return jsonify({'success': True, 'message': 'Zombie state cleared; bot will resume'}) + except Exception: + self.logger.exception("Error clearing zombie state") + return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500 + # ── Maintenance status ─────────────────────────────────────────────── @self.app.route('/api/maintenance/backup_now', methods=['POST']) diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html index 60de20e..c41e748 100644 --- a/modules/web_viewer/templates/base.html +++ b/modules/web_viewer/templates/base.html @@ -455,6 +455,63 @@ + + {% if radio_zombie %} + + + {% endif %} +
{% block content %}{% endblock %}