diff --git a/app/database.py b/app/database.py index 148533c..ceff526 100644 --- a/app/database.py +++ b/app/database.py @@ -548,6 +548,81 @@ class Database: ).fetchone() return dict(row) if row else None + # ================================================================ + # Analyzers (user-configured MeshCore Analyzer services) + # ================================================================ + + def create_analyzer(self, name: str, url_template: str) -> int: + """Insert a new analyzer. Raises sqlite3.IntegrityError on duplicate name.""" + with self._connect() as conn: + cursor = conn.execute( + "INSERT INTO analyzers (name, url_template) VALUES (?, ?)", + (name, url_template) + ) + return cursor.lastrowid + + def list_analyzers(self) -> List[Dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM analyzers ORDER BY name COLLATE NOCASE" + ).fetchall() + return [dict(r) for r in rows] + + def get_analyzer(self, analyzer_id: int) -> Optional[Dict]: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM analyzers WHERE id = ?", (analyzer_id,) + ).fetchone() + return dict(row) if row else None + + def update_analyzer(self, analyzer_id: int, name: Optional[str] = None, + url_template: Optional[str] = None, + is_disabled: Optional[bool] = None) -> bool: + """Update fields on an analyzer. Pass None to leave a field unchanged.""" + sets = [] + params: List[Any] = [] + if name is not None: + sets.append("name = ?") + params.append(name) + if url_template is not None: + sets.append("url_template = ?") + params.append(url_template) + if is_disabled is not None: + sets.append("is_disabled = ?") + params.append(1 if is_disabled else 0) + if not sets: + return False + sets.append("updated_at = datetime('now')") + params.append(analyzer_id) + with self._connect() as conn: + cursor = conn.execute( + f"UPDATE analyzers SET {', '.join(sets)} WHERE id = ?", + params + ) + return cursor.rowcount > 0 + + def delete_analyzer(self, analyzer_id: int) -> bool: + with self._connect() as conn: + cursor = conn.execute("DELETE FROM analyzers WHERE id = ?", (analyzer_id,)) + return cursor.rowcount > 0 + + def set_default_analyzer(self, analyzer_id: Optional[int]) -> None: + """Clear any existing default, then set the given analyzer as default. + + Passing None clears the default flag on all analyzers. + """ + with self._connect() as conn: + conn.execute( + "UPDATE analyzers SET is_default = 0, updated_at = datetime('now') " + "WHERE is_default = 1" + ) + if analyzer_id is not None: + conn.execute( + "UPDATE analyzers SET is_default = 1, updated_at = datetime('now') " + "WHERE id = ?", + (analyzer_id,) + ) + def set_channel_scope(self, channel_idx: int, region_id: Optional[int]) -> None: """Set or clear the region mapping for a channel. diff --git a/app/device_manager.py b/app/device_manager.py index dbe3e79..8eb5ac5 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -19,7 +19,7 @@ from urllib.parse import urlparse, parse_qs from Crypto.Cipher import AES -ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash=' +LETSMESH_ANALYZER_URL_TEMPLATE = 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}' GRP_TXT_TYPE_BYTE = 0x05 logger = logging.getLogger(__name__) @@ -669,13 +669,12 @@ class DeviceManager: if path_len_raw is not None: hop_count, path_hash_size, _ = decode_path_len(path_len_raw) - # Compute analyzer URL from pkt_payload - analyzer_url = None + # Compute packet hash from pkt_payload (frontend builds URL) + packet_hash = None if pkt_payload: try: raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload) packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper() - analyzer_url = f"{ANALYZER_BASE_URL}{packet_hash}" except (ValueError, TypeError): pass @@ -691,7 +690,7 @@ class DeviceManager: 'hop_count': hop_count, 'path_hash_size': path_hash_size, 'pkt_payload': pkt_payload, - 'analyzer_url': analyzer_url, + 'packet_hash': packet_hash, }, namespace='/chat') logger.debug(f"SocketIO emitted new_message for ch{channel_idx} msg #{msg_id}") diff --git a/app/routes/api.py b/app/routes/api.py index 2d9f10f..7a46b34 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, send_file, current_app from app.meshcore import cli, parser from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name from app.config import config, runtime_config -from app.device_manager import decode_path_len +from app.device_manager import decode_path_len, LETSMESH_ANALYZER_URL_TEMPLATE from app.archiver import manager as archive_manager from app.contacts_cache import get_all_names, get_all_contacts @@ -56,16 +56,15 @@ _contacts_detailed_cache_timestamp = 0 CONTACTS_DETAILED_CACHE_TTL = 60 # seconds -ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash=' GRP_TXT_TYPE_BYTE = 0x05 +ANALYZER_PLACEHOLDER = '{packetHash}' -def compute_analyzer_url(pkt_payload): - """Compute MeshCore Analyzer URL from a hex-encoded pkt_payload.""" +def compute_packet_hash(pkt_payload): + """Compute MeshCore Analyzer packet hash (16 uppercase hex chars) from a hex-encoded pkt_payload.""" try: raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload) - packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper() - return f"{ANALYZER_BASE_URL}{packet_hash}" + return hashlib.sha256(raw).hexdigest()[:16].upper() except (ValueError, TypeError): return None @@ -483,9 +482,9 @@ def get_messages(): 'pkt_payload': pkt_payload, } - # Enrich with echo data and analyzer URL + # Enrich with echo data and packet hash (frontend builds analyzer URL) if pkt_payload: - msg['analyzer_url'] = compute_analyzer_url(pkt_payload) + msg['packet_hash'] = compute_packet_hash(pkt_payload) echoes = db.get_echoes_for_message(pkt_payload) if echoes: msg['echo_count'] = len(echoes) @@ -590,7 +589,7 @@ def get_message_meta(msg_id): } if pkt_payload: - meta['analyzer_url'] = compute_analyzer_url(pkt_payload) + meta['packet_hash'] = compute_packet_hash(pkt_payload) echoes = db.get_echoes_for_message(pkt_payload) if echoes: meta['echo_count'] = len(echoes) @@ -4186,6 +4185,157 @@ def set_default_region_api(region_id): return jsonify({'success': False, 'error': str(e)}), 500 +# ============================================================================= +# Analyzers (user-configured MeshCore Analyzer services) — Settings > Analyzer tab +# ============================================================================= + +def _validate_analyzer_url_template(url_template: str): + """Return (ok, error_msg). Validates the template the frontend will substitute.""" + if not url_template: + return False, 'URL is required' + if not (url_template.startswith('http://') or url_template.startswith('https://')): + return False, 'URL must start with http:// or https://' + if ANALYZER_PLACEHOLDER not in url_template: + return False, f'URL must contain the {ANALYZER_PLACEHOLDER} placeholder' + return True, None + + +@api_bp.route('/analyzers', methods=['GET']) +def list_analyzers_api(): + """List user-configured analyzers and the built-in Letsmesh URL template.""" + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + return jsonify({ + 'success': True, + 'analyzers': db.list_analyzers(), + 'letsmesh_url_template': LETSMESH_ANALYZER_URL_TEMPLATE, + }), 200 + except Exception as e: + logger.error(f"Error listing analyzers: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/analyzers', methods=['POST']) +def create_analyzer_api(): + """Create a new analyzer. Body: {name, url_template}.""" + try: + data = request.get_json() or {} + name = (data.get('name') or '').strip() + url_template = (data.get('url_template') or '').strip() + + if not name: + return jsonify({'success': False, 'error': 'Name is required'}), 400 + ok, err = _validate_analyzer_url_template(url_template) + if not ok: + return jsonify({'success': False, 'error': err}), 400 + + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + import sqlite3 + try: + aid = db.create_analyzer(name, url_template) + except sqlite3.IntegrityError: + return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409 + + return jsonify({'success': True, 'analyzer': db.get_analyzer(aid)}), 201 + except Exception as e: + logger.error(f"Error creating analyzer: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/analyzers/', methods=['PUT']) +def update_analyzer_api(analyzer_id): + """Update an analyzer. Body: {name?, url_template?, is_disabled?}.""" + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + if db.get_analyzer(analyzer_id) is None: + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 + + data = request.get_json() or {} + name = data.get('name') + url_template = data.get('url_template') + is_disabled = data.get('is_disabled') + + if name is not None: + name = name.strip() + if not name: + return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400 + if url_template is not None: + url_template = url_template.strip() + ok, err = _validate_analyzer_url_template(url_template) + if not ok: + return jsonify({'success': False, 'error': err}), 400 + + import sqlite3 + try: + db.update_analyzer(analyzer_id, name=name, url_template=url_template, + is_disabled=is_disabled) + except sqlite3.IntegrityError: + return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409 + + return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200 + except Exception as e: + logger.error(f"Error updating analyzer: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/analyzers/', methods=['DELETE']) +def delete_analyzer_api(analyzer_id): + """Delete an analyzer.""" + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + if db.get_analyzer(analyzer_id) is None: + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 + + db.delete_analyzer(analyzer_id) + return jsonify({'success': True}), 200 + except Exception as e: + logger.error(f"Error deleting analyzer: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/analyzers/default', methods=['DELETE']) +def clear_default_analyzer_api(): + """Clear the default-analyzer flag.""" + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + db.set_default_analyzer(None) + return jsonify({'success': True}), 200 + except Exception as e: + logger.error(f"Error clearing default analyzer: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/analyzers//default', methods=['POST']) +def set_default_analyzer_api(analyzer_id): + """Mark an analyzer as default. Clears any previous default in the same transaction.""" + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + if db.get_analyzer(analyzer_id) is None: + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 + + db.set_default_analyzer(analyzer_id) + return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200 + except Exception as e: + logger.error(f"Error setting default analyzer: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + # ============================================================================= # Message Retention Settings # ============================================================================= diff --git a/app/schema.sql b/app/schema.sql index e7e1636..8c7e7cc 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -53,6 +53,19 @@ CREATE TABLE IF NOT EXISTS regions ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- User-configured MeshCore Analyzer services +CREATE TABLE IF NOT EXISTS analyzers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + url_template TEXT NOT NULL, -- must contain '{packetHash}' + is_default INTEGER NOT NULL DEFAULT 0, + is_disabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_analyzers_one_default + ON analyzers(is_default) WHERE is_default = 1; + -- Per-channel region mapping (absent row = no override; firmware default applies) CREATE TABLE IF NOT EXISTS channel_scopes ( channel_idx INTEGER PRIMARY KEY, diff --git a/app/static/js/app.js b/app/static/js/app.js index 1c1df54..e859283 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1066,7 +1066,7 @@ function appendMessageFromSocket(data) { echo_paths: [], echo_snrs: [], echo_hash_sizes: [], - analyzer_url: data.analyzer_url || null, + packet_hash: data.packet_hash || null, pkt_payload: data.pkt_payload || null, txt_type: data.txt_type || 0, }; @@ -1178,13 +1178,13 @@ function updateMessageMetaDOM(wrapper, meta) { } // Add analyzer button if not already present - if (meta.analyzer_url) { + if (meta.packet_hash) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) { const ignoreBtn = actionsEl.querySelector('[title^="Ignore"]'); const analyzerBtn = document.createElement('button'); analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action'; - analyzerBtn.setAttribute('onclick', `window.open('${meta.analyzer_url}', 'meshcore-analyzer')`); + analyzerBtn.setAttribute('onclick', `openMessageAnalyzer('${meta.packet_hash}')`); analyzerBtn.title = 'View in Analyzer'; analyzerBtn.innerHTML = ''; actionsEl.insertBefore(analyzerBtn, ignoreBtn); @@ -1218,13 +1218,13 @@ function updateMessageMetaDOM(wrapper, meta) { } // Add analyzer button - if (meta.analyzer_url) { + if (meta.packet_hash) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) { const resendBtn = actionsEl.querySelector('[title="Resend"]'); const analyzerBtn = document.createElement('button'); analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action'; - analyzerBtn.setAttribute('onclick', `window.open('${meta.analyzer_url}', 'meshcore-analyzer')`); + analyzerBtn.setAttribute('onclick', `openMessageAnalyzer('${meta.packet_hash}')`); analyzerBtn.title = 'View in Analyzer'; analyzerBtn.innerHTML = ''; actionsEl.insertBefore(analyzerBtn, resendBtn); @@ -1308,8 +1308,8 @@ function createMessageElement(msg) {
${processMessageContent(msg.content)}
${echoDisplay} - ${msg.analyzer_url ? ` - ` : ''} @@ -1352,8 +1352,8 @@ function createMessageElement(msg) { ` : ''} - ${msg.analyzer_url ? ` - ` : ''} @@ -2438,6 +2438,7 @@ document.addEventListener('DOMContentLoaded', () => { loadUiSettings(); loadContactsSettings(); loadRegions(); + loadAnalyzers(); }); settingsModal.addEventListener('shown.bs.modal', () => { settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { @@ -2484,6 +2485,22 @@ document.addEventListener('DOMContentLoaded', () => { regionIndicator.addEventListener('click', () => openRegionPicker(currentChannelIdx)); } + // Analyzer tab: add button + edit form submit + const addAnalyzerBtn = document.getElementById('addAnalyzerBtn'); + if (addAnalyzerBtn) { + addAnalyzerBtn.addEventListener('click', () => openAnalyzerEditModal(null)); + } + const analyzerEditForm = document.getElementById('analyzerEditForm'); + if (analyzerEditForm) { + analyzerEditForm.addEventListener('submit', (e) => { + e.preventDefault(); + saveAnalyzerFromForm(); + }); + } + + // Preload analyzers so the first click on a chart icon doesn't need a round-trip. + loadAnalyzers(); + const dmRetryForm = document.getElementById('dmRetrySettingsForm'); if (dmRetryForm) { dmRetryForm.addEventListener('submit', (e) => { @@ -3003,6 +3020,326 @@ async function clearDefaultRegion() { } } +// ================================================================ +// Analyzers (Settings > Analyzer + group chat View in Analyzer button) +// ================================================================ + +const ANALYZER_PLACEHOLDER = '{packetHash}'; +window.analyzerCache = window.analyzerCache || { + analyzers: [], + letsmesh_url_template: 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}', + loaded: false, +}; + +function substituteAnalyzerUrl(template, packetHash) { + return (template || '').replaceAll(ANALYZER_PLACEHOLDER, packetHash || ''); +} + +async function loadAnalyzers() { + try { + const resp = await fetch('/api/analyzers'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (!data.success) throw new Error(data.error || 'Failed'); + window.analyzerCache.analyzers = data.analyzers || []; + if (data.letsmesh_url_template) { + window.analyzerCache.letsmesh_url_template = data.letsmesh_url_template; + } + window.analyzerCache.loaded = true; + renderAnalyzersList(); + } catch (e) { + console.error('Error loading analyzers:', e); + const listEl = document.getElementById('analyzersList'); + if (listEl) { + listEl.innerHTML = '
Failed to load analyzers
'; + } + } +} + +function renderAnalyzersList() { + const listEl = document.getElementById('analyzersList'); + if (!listEl) return; + const analyzers = window.analyzerCache.analyzers || []; + + const builtinRow = ` +
+ + + +
+
Letsmesh Analyzer built-in
+ ${escapeHtml(window.analyzerCache.letsmesh_url_template)} +
+
+ `; + + if (analyzers.length === 0) { + listEl.innerHTML = builtinRow + + '
No custom analyzers. Click "Add analyzer" to add one.
'; + return; + } + + const rows = analyzers.map(a => { + const disabled = !!a.is_disabled; + const isDefault = !!a.is_default; + const starIcon = isDefault ? 'bi-star-fill text-warning' : 'bi-star'; + const disabledBadge = disabled + ? 'Disabled' : ''; + const nameClass = disabled ? 'text-muted text-decoration-line-through' : ''; + const safeName = escapeHtml(a.name); + return ` +
+ +
+
${safeName}${disabledBadge}
+ ${escapeHtml(a.url_template)} +
+
+ +
+ + +
+ `; + }).join(''); + + listEl.innerHTML = builtinRow + rows; +} + +function openAnalyzerEditModal(id) { + const modalEl = document.getElementById('analyzerEditModal'); + if (!modalEl) return; + const titleEl = document.getElementById('analyzerEditModalTitle'); + const idEl = document.getElementById('analyzerEditId'); + const nameEl = document.getElementById('analyzerEditName'); + const urlEl = document.getElementById('analyzerEditUrl'); + const disabledEl = document.getElementById('analyzerEditDisabled'); + const errorEl = document.getElementById('analyzerEditError'); + + errorEl.classList.add('d-none'); + errorEl.textContent = ''; + + if (id) { + const a = (window.analyzerCache.analyzers || []).find(x => x.id === id); + if (!a) return; + titleEl.textContent = 'Edit analyzer'; + idEl.value = String(a.id); + nameEl.value = a.name || ''; + urlEl.value = a.url_template || ''; + disabledEl.checked = !!a.is_disabled; + } else { + titleEl.textContent = 'Add analyzer'; + idEl.value = ''; + nameEl.value = ''; + urlEl.value = ''; + disabledEl.checked = false; + } + + bootstrap.Modal.getOrCreateInstance(modalEl).show(); +} + +async function saveAnalyzerFromForm() { + const idEl = document.getElementById('analyzerEditId'); + const nameEl = document.getElementById('analyzerEditName'); + const urlEl = document.getElementById('analyzerEditUrl'); + const disabledEl = document.getElementById('analyzerEditDisabled'); + const errorEl = document.getElementById('analyzerEditError'); + + const id = idEl.value ? parseInt(idEl.value, 10) : null; + const name = (nameEl.value || '').trim(); + const url_template = (urlEl.value || '').trim(); + const is_disabled = !!disabledEl.checked; + + if (!name) { + showAnalyzerFormError('Name is required'); + return; + } + if (!url_template.startsWith('http://') && !url_template.startsWith('https://')) { + showAnalyzerFormError('URL must start with http:// or https://'); + return; + } + if (!url_template.includes(ANALYZER_PLACEHOLDER)) { + showAnalyzerFormError(`URL must contain the ${ANALYZER_PLACEHOLDER} placeholder`); + return; + } + + try { + const url = id ? `/api/analyzers/${id}` : '/api/analyzers'; + const method = id ? 'PUT' : 'POST'; + const body = id ? { name, url_template, is_disabled } : { name, url_template }; + const resp = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showAnalyzerFormError(data.error || 'Failed to save analyzer'); + return; + } + // If creating a new analyzer with disabled=true, push the flag in a follow-up PUT. + if (!id && is_disabled && data.analyzer) { + await fetch(`/api/analyzers/${data.analyzer.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_disabled: true }), + }); + } + errorEl.classList.add('d-none'); + bootstrap.Modal.getInstance(document.getElementById('analyzerEditModal'))?.hide(); + await loadAnalyzers(); + } catch (e) { + console.error('Error saving analyzer:', e); + showAnalyzerFormError('Network error saving analyzer'); + } +} + +function showAnalyzerFormError(msg) { + const errorEl = document.getElementById('analyzerEditError'); + if (!errorEl) return; + errorEl.textContent = msg; + errorEl.classList.remove('d-none'); +} + +async function deleteAnalyzer(id, name) { + if (!confirm(`Delete analyzer "${name}"?`)) return; + try { + const resp = await fetch(`/api/analyzers/${id}`, { method: 'DELETE' }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to delete analyzer', 'danger'); + return; + } + await loadAnalyzers(); + } catch (e) { + console.error('Error deleting analyzer:', e); + showNotification('Network error deleting analyzer', 'danger'); + } +} + +async function toggleAnalyzerDefault(id, currentlyDefault) { + try { + const url = currentlyDefault ? '/api/analyzers/default' : `/api/analyzers/${id}/default`; + const method = currentlyDefault ? 'DELETE' : 'POST'; + const resp = await fetch(url, { method }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to update default', 'danger'); + await loadAnalyzers(); + return; + } + await loadAnalyzers(); + } catch (e) { + console.error('Error toggling analyzer default:', e); + showNotification('Network error updating default', 'danger'); + await loadAnalyzers(); + } +} + +async function toggleAnalyzerDisabled(id, disabled) { + try { + const resp = await fetch(`/api/analyzers/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_disabled: !!disabled }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to update analyzer', 'danger'); + await loadAnalyzers(); + return; + } + await loadAnalyzers(); + } catch (e) { + console.error('Error toggling analyzer disabled:', e); + showNotification('Network error updating analyzer', 'danger'); + await loadAnalyzers(); + } +} + +function getEnabledCustomAnalyzers() { + return (window.analyzerCache.analyzers || []).filter(a => !a.is_disabled); +} + +async function ensureAnalyzersLoaded() { + if (!window.analyzerCache.loaded) { + await loadAnalyzers(); + } +} + +async function openMessageAnalyzer(packetHash) { + if (!packetHash) return; + await ensureAnalyzersLoaded(); + + const enabled = getEnabledCustomAnalyzers(); + const letsmeshTpl = window.analyzerCache.letsmesh_url_template; + + // No custom analyzers — open Letsmesh directly. + if (enabled.length === 0) { + window.open(substituteAnalyzerUrl(letsmeshTpl, packetHash), 'meshcore-analyzer'); + return; + } + + // Default exists and is enabled — open it directly. + const defaultRow = enabled.find(a => a.is_default); + if (defaultRow) { + window.open(substituteAnalyzerUrl(defaultRow.url_template, packetHash), 'meshcore-analyzer'); + return; + } + + // Otherwise — show chooser modal (Letsmesh + enabled customs sorted by name). + openAnalyzerChooser(packetHash, enabled); +} + +function openAnalyzerChooser(packetHash, enabled) { + const modalEl = document.getElementById('analyzerChooserModal'); + const listEl = document.getElementById('analyzerChooserList'); + if (!modalEl || !listEl) return; + + const letsmeshTpl = window.analyzerCache.letsmesh_url_template; + const sorted = (enabled || []).slice().sort((a, b) => + (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }) + ); + + const builtinItem = ` + + `; + const customItems = sorted.map(a => ` + + `).join(''); + + listEl.innerHTML = builtinItem + customItems; + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + + listEl.querySelectorAll('button[data-url]').forEach(btn => { + btn.addEventListener('click', () => { + window.open(btn.getAttribute('data-url'), 'meshcore-analyzer'); + modal.hide(); + }, { once: true }); + }); + + modal.show(); +} + // ================================================================ // Per-channel region picker (Manage Channels > row > pin icon) // ================================================================ diff --git a/app/templates/base.html b/app/templates/base.html index 4a540b1..6573d85 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -456,6 +456,9 @@ + @@ -986,6 +989,26 @@ Tip: pick the default region via the radio button, or select None to fall back to the firmware default. The chosen region is also pushed to the firmware so any untagged channel uses it.
+
+
+ Add MeshCore Analyzer services to choose from when clicking + under a group chat message. The URL must contain the + {packetHash} placeholder — it is replaced with the message's packet hash. +
+
Analyzer Services
+
+
+
Loading... +
+
+ +
+ Tip: star one analyzer to use it without being asked. Clear the star to be prompted on every click. + Disabled analyzers stay in the list but are hidden from the chooser. +
+

Browser notifications appear when the app is hidden or in the background.

+ + + + + +