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
This commit is contained in:
Stacy Olivas
2026-03-22 23:46:40 -07:00
parent 63adc87d19
commit 582e56ffb5
3 changed files with 191 additions and 4 deletions
+22 -3
View File
@@ -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:
+112 -1
View File
@@ -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'])
+57
View File
@@ -455,6 +455,63 @@
</div>
</nav>
<!-- Zombie Radio Banner (persistent — shown on all pages when radio is in zombie state) -->
{% if radio_zombie %}
<div id="zombie-banner" class="alert alert-danger mb-0 rounded-0 border-0 border-bottom border-danger" role="alert" style="border-width: 2px !important;">
<div class="container-fluid">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<i class="fas fa-skull-crossbones me-2"></i>
<strong>Radio Zombie State Detected</strong> — The bot radio firmware is unresponsive.
A physical <strong>power cycle</strong> of the radio hardware is required.
Disconnect/reconnect will <em>not</em> fix this.
{% if radio_zombie_since %}
<span class="ms-3 text-danger-emphasis small opacity-75">
<i class="fas fa-clock me-1"></i>Since: {{ radio_zombie_since }}
</span>
{% endif %}
</div>
<div class="d-flex gap-2 align-items-center flex-shrink-0">
<span class="small opacity-75">After power cycling:</span>
<button id="zombie-recover-btn" class="btn btn-warning btn-sm fw-semibold"
onclick="zombieRecover()" type="button">
<i class="fas fa-power-off me-1"></i>Restart Bot Processing
</button>
</div>
</div>
</div>
</div>
<script>
function zombieRecover() {
var btn = document.getElementById('zombie-recover-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Restarting\u2026';
fetch('/api/admin/zombie-recover', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(function(r) { return r.json(); })
.then(function(data) {
var banner = document.getElementById('zombie-banner');
if (data.success) {
banner.className = 'alert alert-success mb-0 rounded-0 border-0 border-bottom border-success';
banner.innerHTML = '<div class="container-fluid"><i class="fas fa-check-circle me-2"></i>' +
'<strong>Reconnect scheduled.</strong> Refresh this page in a few seconds to confirm the radio is back online.</div>';
} else {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-power-off me-1"></i>Restart Bot Processing';
alert('Error: ' + (data.error || 'Unknown error — check server logs'));
}
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-power-off me-1"></i>Restart Bot Processing';
alert('Network error — could not reach the server.');
});
}
</script>
{% endif %}
<!-- Main Content -->
<div class="container-fluid mt-4">
{% block content %}{% endblock %}