feat(analyzer): seed Letsmesh as a normal DB row and flip switch UX

Letsmesh Analyzer is now a regular analyzers row, seeded once via an
app_settings flag on first startup. It can be renamed, disabled, or
deleted like any user entry. The frontend drops the special built-in
pseudo-row and the built-in slot in the chooser.

Click resolution updated: 0 enabled → toast "No analyzer configured";
1 enabled → open directly; multiple with a default → open default;
multiple without default → chooser.

The row switch and the edit modal now show "Enabled" (checked = active),
which matches intuition. The DB column remains is_disabled so no data
migration is needed — the UI simply inverts the value on read/write.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-01 21:56:55 +02:00
parent ca4c77da36
commit debb711b71
6 changed files with 48 additions and 47 deletions
+19
View File
@@ -558,6 +558,25 @@ class Database:
# Analyzers (user-configured MeshCore Analyzer services) # Analyzers (user-configured MeshCore Analyzer services)
# ================================================================ # ================================================================
LETSMESH_ANALYZER_URL_TEMPLATE = 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}'
LETSMESH_ANALYZER_NAME = 'Letsmesh Analyzer'
_LETSMESH_SEED_FLAG = 'analyzer_letsmesh_seeded'
def seed_default_analyzers(self) -> None:
"""Seed the Letsmesh Analyzer row on first startup after this feature was added.
Uses an app_settings flag so the seed happens exactly once per installation.
If the user later renames, disables or deletes the row, we won't re-create it.
"""
if self.get_setting(self._LETSMESH_SEED_FLAG) is not None:
return
with self._connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO analyzers (name, url_template) VALUES (?, ?)",
(self.LETSMESH_ANALYZER_NAME, self.LETSMESH_ANALYZER_URL_TEMPLATE)
)
self.set_setting(self._LETSMESH_SEED_FLAG, '1')
def create_analyzer(self, name: str, url_template: str) -> int: def create_analyzer(self, name: str, url_template: str) -> int:
"""Insert a new analyzer. Raises sqlite3.IntegrityError on duplicate name.""" """Insert a new analyzer. Raises sqlite3.IntegrityError on duplicate name."""
with self._connect() as conn: with self._connect() as conn:
-1
View File
@@ -20,7 +20,6 @@ from urllib.parse import urlparse, parse_qs
from Crypto.Cipher import AES from Crypto.Cipher import AES
LETSMESH_ANALYZER_URL_TEMPLATE = 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}'
GRP_TXT_TYPE_BYTE = 0x05 GRP_TXT_TYPE_BYTE = 0x05
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+3
View File
@@ -253,6 +253,9 @@ def create_app():
db = Database(db_path) db = Database(db_path)
app.db = db app.db = db
# One-time seed: add Letsmesh Analyzer row so it behaves like any other entry.
db.seed_default_analyzers()
# Migrate settings from .webui_settings.json to DB (one-time) # Migrate settings from .webui_settings.json to DB (one-time)
settings_file = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" settings_file = Path(config.MC_CONFIG_DIR) / ".webui_settings.json"
if settings_file.exists() and db.get_setting('manual_add_contacts') is None: if settings_file.exists() and db.get_setting('manual_add_contacts') is None:
+2 -3
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 import cli, parser
from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name
from app.config import config, runtime_config from app.config import config, runtime_config
from app.device_manager import decode_path_len, LETSMESH_ANALYZER_URL_TEMPLATE from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager from app.archiver import manager as archive_manager
from app.contacts_cache import get_all_names, get_all_contacts from app.contacts_cache import get_all_names, get_all_contacts
@@ -4257,7 +4257,7 @@ def _validate_analyzer_url_template(url_template: str):
@api_bp.route('/analyzers', methods=['GET']) @api_bp.route('/analyzers', methods=['GET'])
def list_analyzers_api(): def list_analyzers_api():
"""List user-configured analyzers and the built-in Letsmesh URL template.""" """List user-configured analyzers."""
try: try:
db = _get_db() db = _get_db()
if not db: if not db:
@@ -4265,7 +4265,6 @@ def list_analyzers_api():
return jsonify({ return jsonify({
'success': True, 'success': True,
'analyzers': db.list_analyzers(), 'analyzers': db.list_analyzers(),
'letsmesh_url_template': LETSMESH_ANALYZER_URL_TEMPLATE,
}), 200 }), 200
except Exception as e: except Exception as e:
logger.error(f"Error listing analyzers: {e}") logger.error(f"Error listing analyzers: {e}")
+22 -41
View File
@@ -3160,7 +3160,6 @@ async function clearDefaultRegion() {
const ANALYZER_PLACEHOLDER = '{packetHash}'; const ANALYZER_PLACEHOLDER = '{packetHash}';
window.analyzerCache = window.analyzerCache || { window.analyzerCache = window.analyzerCache || {
analyzers: [], analyzers: [],
letsmesh_url_template: 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}',
loaded: false, loaded: false,
}; };
@@ -3185,9 +3184,6 @@ async function loadAnalyzers() {
const data = await resp.json(); const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed'); if (!data.success) throw new Error(data.error || 'Failed');
window.analyzerCache.analyzers = data.analyzers || []; window.analyzerCache.analyzers = data.analyzers || [];
if (data.letsmesh_url_template) {
window.analyzerCache.letsmesh_url_template = data.letsmesh_url_template;
}
window.analyzerCache.loaded = true; window.analyzerCache.loaded = true;
renderAnalyzersList(); renderAnalyzersList();
} catch (e) { } catch (e) {
@@ -3204,26 +3200,15 @@ function renderAnalyzersList() {
if (!listEl) return; if (!listEl) return;
const analyzers = window.analyzerCache.analyzers || []; 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" style="min-width: 0;">
<div><strong>Letsmesh Analyzer</strong> <span class="badge bg-light text-muted">built-in</span></div>
<code class="small text-muted text-break" style="word-break: break-all;">${escapeHtml(window.analyzerCache.letsmesh_url_template)}</code>
</div>
</div>
`;
if (analyzers.length === 0) { if (analyzers.length === 0) {
listEl.innerHTML = builtinRow + listEl.innerHTML =
'<div class="text-center text-muted small py-3">No custom analyzers. Click "Add analyzer" to add one.</div>'; '<div class="text-center text-muted small py-3">No analyzers configured. Click "Add analyzer" to add one.</div>';
return; return;
} }
const rows = analyzers.map(a => { const rows = analyzers.map(a => {
const disabled = !!a.is_disabled; const disabled = !!a.is_disabled;
const enabled = !disabled;
const isDefault = !!a.is_default; const isDefault = !!a.is_default;
const starIcon = isDefault ? 'bi-star-fill text-warning' : 'bi-star'; const starIcon = isDefault ? 'bi-star-fill text-warning' : 'bi-star';
const disabledBadge = disabled const disabledBadge = disabled
@@ -3241,10 +3226,10 @@ function renderAnalyzersList() {
<div class="${nameClass}"><strong>${safeName}</strong>${disabledBadge}</div> <div class="${nameClass}"><strong>${safeName}</strong>${disabledBadge}</div>
<code class="small text-muted text-break" style="word-break: break-all;">${escapeHtml(a.url_template)}</code> <code class="small text-muted text-break" style="word-break: break-all;">${escapeHtml(a.url_template)}</code>
</div> </div>
<div class="form-check form-switch mb-0" title="Disabled"> <div class="form-check form-switch mb-0" title="${enabled ? 'Enabled' : 'Disabled'}">
<input class="form-check-input" type="checkbox" <input class="form-check-input" type="checkbox"
id="analyzerDisabled_${a.id}" ${disabled ? 'checked' : ''} id="analyzerEnabled_${a.id}" ${enabled ? 'checked' : ''}
onchange="toggleAnalyzerDisabled(${a.id}, this.checked)"> onchange="toggleAnalyzerDisabled(${a.id}, !this.checked)">
</div> </div>
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openAnalyzerEditModal(${a.id})" title="Edit"> onclick="openAnalyzerEditModal(${a.id})" title="Edit">
@@ -3258,7 +3243,7 @@ function renderAnalyzersList() {
`; `;
}).join(''); }).join('');
listEl.innerHTML = builtinRow + rows; listEl.innerHTML = rows;
} }
function openAnalyzerEditModal(id) { function openAnalyzerEditModal(id) {
@@ -3268,7 +3253,7 @@ function openAnalyzerEditModal(id) {
const idEl = document.getElementById('analyzerEditId'); const idEl = document.getElementById('analyzerEditId');
const nameEl = document.getElementById('analyzerEditName'); const nameEl = document.getElementById('analyzerEditName');
const urlEl = document.getElementById('analyzerEditUrl'); const urlEl = document.getElementById('analyzerEditUrl');
const disabledEl = document.getElementById('analyzerEditDisabled'); const enabledEl = document.getElementById('analyzerEditEnabled');
const errorEl = document.getElementById('analyzerEditError'); const errorEl = document.getElementById('analyzerEditError');
errorEl.classList.add('d-none'); errorEl.classList.add('d-none');
@@ -3281,13 +3266,13 @@ function openAnalyzerEditModal(id) {
idEl.value = String(a.id); idEl.value = String(a.id);
nameEl.value = a.name || ''; nameEl.value = a.name || '';
urlEl.value = a.url_template || ''; urlEl.value = a.url_template || '';
disabledEl.checked = !!a.is_disabled; enabledEl.checked = !a.is_disabled;
} else { } else {
titleEl.textContent = 'Add analyzer'; titleEl.textContent = 'Add analyzer';
idEl.value = ''; idEl.value = '';
nameEl.value = ''; nameEl.value = '';
urlEl.value = ''; urlEl.value = '';
disabledEl.checked = false; enabledEl.checked = true;
} }
modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true }); modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true });
@@ -3298,13 +3283,13 @@ async function saveAnalyzerFromForm() {
const idEl = document.getElementById('analyzerEditId'); const idEl = document.getElementById('analyzerEditId');
const nameEl = document.getElementById('analyzerEditName'); const nameEl = document.getElementById('analyzerEditName');
const urlEl = document.getElementById('analyzerEditUrl'); const urlEl = document.getElementById('analyzerEditUrl');
const disabledEl = document.getElementById('analyzerEditDisabled'); const enabledEl = document.getElementById('analyzerEditEnabled');
const errorEl = document.getElementById('analyzerEditError'); const errorEl = document.getElementById('analyzerEditError');
const id = idEl.value ? parseInt(idEl.value, 10) : null; const id = idEl.value ? parseInt(idEl.value, 10) : null;
const name = (nameEl.value || '').trim(); const name = (nameEl.value || '').trim();
const url_template = (urlEl.value || '').trim(); const url_template = (urlEl.value || '').trim();
const is_disabled = !!disabledEl.checked; const is_disabled = !enabledEl.checked;
if (!name) { if (!name) {
showAnalyzerFormError('Name is required'); showAnalyzerFormError('Name is required');
@@ -3428,11 +3413,10 @@ async function openMessageAnalyzer(packetHash) {
await ensureAnalyzersLoaded(); await ensureAnalyzersLoaded();
const enabled = getEnabledCustomAnalyzers(); const enabled = getEnabledCustomAnalyzers();
const letsmeshTpl = window.analyzerCache.letsmesh_url_template;
// No custom analyzers — open Letsmesh directly. // Nothing enabled — user has deliberately turned everything off or deleted it.
if (enabled.length === 0) { if (enabled.length === 0) {
window.open(substituteAnalyzerUrl(letsmeshTpl, packetHash), 'meshcore-analyzer'); showNotification('No analyzer configured. Add one in Settings → Analyzer.', 'warning');
return; return;
} }
@@ -3443,7 +3427,13 @@ async function openMessageAnalyzer(packetHash) {
return; return;
} }
// Otherwise — show chooser modal (Letsmesh + enabled customs sorted by name). // Only one enabled analyzer — open it directly, no need to ask.
if (enabled.length === 1) {
window.open(substituteAnalyzerUrl(enabled[0].url_template, packetHash), 'meshcore-analyzer');
return;
}
// Multiple enabled, no default — show chooser.
openAnalyzerChooser(packetHash, enabled); openAnalyzerChooser(packetHash, enabled);
} }
@@ -3452,26 +3442,17 @@ function openAnalyzerChooser(packetHash, enabled) {
const listEl = document.getElementById('analyzerChooserList'); const listEl = document.getElementById('analyzerChooserList');
if (!modalEl || !listEl) return; if (!modalEl || !listEl) return;
const letsmeshTpl = window.analyzerCache.letsmesh_url_template;
const sorted = (enabled || []).slice().sort((a, b) => const sorted = (enabled || []).slice().sort((a, b) =>
(a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }) (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })
); );
const builtinItem = ` listEl.innerHTML = sorted.map(a => `
<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" <button type="button" class="list-group-item list-group-item-action"
data-url="${escapeHtml(substituteAnalyzerUrl(a.url_template, packetHash))}"> data-url="${escapeHtml(substituteAnalyzerUrl(a.url_template, packetHash))}">
<i class="bi bi-clipboard-data"></i> ${escapeHtml(a.name)} <i class="bi bi-clipboard-data"></i> ${escapeHtml(a.name)}
</button> </button>
`).join(''); `).join('');
listEl.innerHTML = builtinItem + customItems;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl); const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
listEl.querySelectorAll('button[data-url]').forEach(btn => { listEl.querySelectorAll('button[data-url]').forEach(btn => {
+2 -2
View File
@@ -1056,8 +1056,8 @@
<div class="form-text small">Must include <code>{packetHash}</code>.</div> <div class="form-text small">Must include <code>{packetHash}</code>.</div>
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="analyzerEditDisabled"> <input class="form-check-input" type="checkbox" id="analyzerEditEnabled" checked>
<label class="form-check-label small" for="analyzerEditDisabled">Disabled</label> <label class="form-check-label small" for="analyzerEditEnabled">Enabled</label>
</div> </div>
<div id="analyzerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div> <div id="analyzerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
</div> </div>