feat(analyzer): add configurable analyzer services in Settings

Add a Settings > Analyzer tab letting users CRUD custom MeshCore Analyzer
services with a star-toggle default and inline disabled switch. The chart
icon under each group-chat message now resolves at click time: built-in
Letsmesh when no enabled customs, the default when set, or a chooser
modal otherwise. Backend stops shipping the prebuilt analyzer_url and
emits packet_hash instead — the frontend substitutes {packetHash} in the
chosen URL template.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-06-05 15:34:45 +02:00
parent e06e7b06dd
commit 10792b8566
6 changed files with 672 additions and 23 deletions
+75
View File
@@ -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.
+4 -5
View File
@@ -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}")
+159 -9
View File
@@ -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/<int:analyzer_id>', 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/<int:analyzer_id>', 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/<int:analyzer_id>/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
# =============================================================================
+13
View File
@@ -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,
+346 -9
View File
@@ -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 = '<i class="bi bi-clipboard-data"></i>';
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 = '<i class="bi bi-clipboard-data"></i>';
actionsEl.insertBefore(analyzerBtn, resendBtn);
@@ -1308,8 +1308,8 @@ function createMessageElement(msg) {
<div class="message-content">${processMessageContent(msg.content)}</div>
<div class="message-actions justify-content-end">
${echoDisplay}
${msg.analyzer_url ? `
<button class="btn btn-outline-secondary btn-msg-action" onclick="window.open('${msg.analyzer_url}', 'meshcore-analyzer')" title="View in Analyzer">
${msg.packet_hash ? `
<button class="btn btn-outline-secondary btn-msg-action" onclick="openMessageAnalyzer('${msg.packet_hash}')" title="View in Analyzer">
<i class="bi bi-clipboard-data"></i>
</button>
` : ''}
@@ -1352,8 +1352,8 @@ function createMessageElement(msg) {
<i class="bi bi-geo-alt"></i>
</button>
` : ''}
${msg.analyzer_url ? `
<button class="btn btn-outline-secondary btn-msg-action" onclick="window.open('${msg.analyzer_url}', 'meshcore-analyzer')" title="View in Analyzer">
${msg.packet_hash ? `
<button class="btn btn-outline-secondary btn-msg-action" onclick="openMessageAnalyzer('${msg.packet_hash}')" title="View in Analyzer">
<i class="bi bi-clipboard-data"></i>
</button>
` : ''}
@@ -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 = '<div class="text-center text-danger small py-2">Failed to load analyzers</div>';
}
}
}
function renderAnalyzersList() {
const listEl = document.getElementById('analyzersList');
if (!listEl) return;
const analyzers = window.analyzerCache.analyzers || [];
const builtinRow = `
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<span class="text-muted" title="Built-in default — not configurable">
<i class="bi bi-star"></i>
</span>
<div class="flex-grow-1">
<div><strong>Letsmesh Analyzer</strong> <span class="badge bg-light text-muted">built-in</span></div>
<code class="small text-muted">${escapeHtml(window.analyzerCache.letsmesh_url_template)}</code>
</div>
</div>
`;
if (analyzers.length === 0) {
listEl.innerHTML = builtinRow +
'<div class="text-center text-muted small py-3">No custom analyzers. Click "Add analyzer" to add one.</div>';
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
? '<span class="badge bg-secondary ms-1">Disabled</span>' : '';
const nameClass = disabled ? 'text-muted text-decoration-line-through' : '';
const safeName = escapeHtml(a.name);
return `
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<button type="button" class="btn btn-link p-0 text-decoration-none"
onclick="toggleAnalyzerDefault(${a.id}, ${isDefault})"
title="${isDefault ? 'Clear default' : 'Mark as default'}">
<i class="bi ${starIcon}"></i>
</button>
<div class="flex-grow-1">
<div class="${nameClass}"><strong>${safeName}</strong>${disabledBadge}</div>
<code class="small text-muted">${escapeHtml(a.url_template)}</code>
</div>
<div class="form-check form-switch mb-0" title="Disabled">
<input class="form-check-input" type="checkbox"
id="analyzerDisabled_${a.id}" ${disabled ? 'checked' : ''}
onchange="toggleAnalyzerDisabled(${a.id}, this.checked)">
</div>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openAnalyzerEditModal(${a.id})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteAnalyzer(${a.id}, '${safeName.replace(/'/g, "\\'")}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
`;
}).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 = `
<button type="button" class="list-group-item list-group-item-action"
data-url="${escapeHtml(substituteAnalyzerUrl(letsmeshTpl, packetHash))}">
<i class="bi bi-clipboard-data"></i> Letsmesh Analyzer
<span class="badge bg-light text-muted ms-1">built-in</span>
</button>
`;
const customItems = sorted.map(a => `
<button type="button" class="list-group-item list-group-item-action"
data-url="${escapeHtml(substituteAnalyzerUrl(a.url_template, packetHash))}">
<i class="bi bi-clipboard-data"></i> ${escapeHtml(a.name)}
</button>
`).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)
// ================================================================
+75
View File
@@ -456,6 +456,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsRegions" type="button">Regions</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAnalyzer" type="button">Analyzer</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsNotifications" type="button">Notifications</button>
</li>
@@ -986,6 +989,26 @@
Tip: pick the default region via the radio button, or select <em>None</em> to fall back to the firmware default. The chosen region is also pushed to the firmware so any untagged channel uses it.
</div>
</div>
<div class="tab-pane fade" id="tabSettingsAnalyzer">
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> Add MeshCore Analyzer services to choose from when clicking
<i class="bi bi-clipboard-data"></i> under a group chat message. The URL must contain the
<code>{packetHash}</code> placeholder &mdash; it is replaced with the message's packet hash.
</div>
<h6 class="mb-2">Analyzer Services</h6>
<div id="analyzersList" class="list-group mb-3">
<div class="text-center text-muted py-3 small">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<button type="button" class="btn btn-primary btn-sm" id="addAnalyzerBtn">
<i class="bi bi-plus-circle"></i> Add analyzer
</button>
<div class="form-text small mt-2">
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.
</div>
</div>
<div class="tab-pane fade" id="tabSettingsNotifications">
<p class="text-muted small mb-3">Browser notifications appear when the app is hidden or in the background.</p>
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3 border rounded" type="button" style="width: 100%;">
@@ -1010,6 +1033,58 @@
</div>
</div>
<!-- Analyzer Edit Modal (create + edit) -->
<div class="modal fade" id="analyzerEditModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-clipboard-data"></i> <span id="analyzerEditModalTitle">Add analyzer</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="analyzerEditForm">
<div class="modal-body">
<input type="hidden" id="analyzerEditId" value="">
<div class="mb-3">
<label class="form-label small mb-1" for="analyzerEditName">Name</label>
<input type="text" class="form-control form-control-sm" id="analyzerEditName"
placeholder="e.g. MarWoj Analyzer" required maxlength="60" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label small mb-1" for="analyzerEditUrl">URL template</label>
<input type="text" class="form-control form-control-sm" id="analyzerEditUrl"
placeholder="https://analyzer.example.com/#/packets/{packetHash}" required autocomplete="off">
<div class="form-text small">Must include <code>{packetHash}</code>.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="analyzerEditDisabled">
<label class="form-check-label small" for="analyzerEditDisabled">Disabled</label>
</div>
<div id="analyzerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Analyzer Chooser Modal -->
<div class="modal fade" id="analyzerChooserModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-clipboard-data"></i> Choose analyzer</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2">
<div id="analyzerChooserList" class="list-group"></div>
</div>
</div>
</div>
</div>
<!-- Coordinate Picker Map Modal -->
<div class="modal fade" id="coordPickerModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">