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)
# ================================================================
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:
"""Insert a new analyzer. Raises sqlite3.IntegrityError on duplicate name."""
with self._connect() as conn:
-1
View File
@@ -20,7 +20,6 @@ from urllib.parse import urlparse, parse_qs
from Crypto.Cipher import AES
LETSMESH_ANALYZER_URL_TEMPLATE = 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}'
GRP_TXT_TYPE_BYTE = 0x05
logger = logging.getLogger(__name__)
+3
View File
@@ -253,6 +253,9 @@ def create_app():
db = Database(db_path)
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)
settings_file = Path(config.MC_CONFIG_DIR) / ".webui_settings.json"
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.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, LETSMESH_ANALYZER_URL_TEMPLATE
from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager
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'])
def list_analyzers_api():
"""List user-configured analyzers and the built-in Letsmesh URL template."""
"""List user-configured analyzers."""
try:
db = _get_db()
if not db:
@@ -4265,7 +4265,6 @@ def list_analyzers_api():
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}")
+22 -41
View File
@@ -3160,7 +3160,6 @@ async function clearDefaultRegion() {
const ANALYZER_PLACEHOLDER = '{packetHash}';
window.analyzerCache = window.analyzerCache || {
analyzers: [],
letsmesh_url_template: 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}',
loaded: false,
};
@@ -3185,9 +3184,6 @@ async function loadAnalyzers() {
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) {
@@ -3204,26 +3200,15 @@ function renderAnalyzersList() {
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" 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) {
listEl.innerHTML = builtinRow +
'<div class="text-center text-muted small py-3">No custom analyzers. Click "Add analyzer" to add one.</div>';
listEl.innerHTML =
'<div class="text-center text-muted small py-3">No analyzers configured. Click "Add analyzer" to add one.</div>';
return;
}
const rows = analyzers.map(a => {
const disabled = !!a.is_disabled;
const enabled = !disabled;
const isDefault = !!a.is_default;
const starIcon = isDefault ? 'bi-star-fill text-warning' : 'bi-star';
const disabledBadge = disabled
@@ -3241,10 +3226,10 @@ function renderAnalyzersList() {
<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>
</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"
id="analyzerDisabled_${a.id}" ${disabled ? 'checked' : ''}
onchange="toggleAnalyzerDisabled(${a.id}, this.checked)">
id="analyzerEnabled_${a.id}" ${enabled ? 'checked' : ''}
onchange="toggleAnalyzerDisabled(${a.id}, !this.checked)">
</div>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openAnalyzerEditModal(${a.id})" title="Edit">
@@ -3258,7 +3243,7 @@ function renderAnalyzersList() {
`;
}).join('');
listEl.innerHTML = builtinRow + rows;
listEl.innerHTML = rows;
}
function openAnalyzerEditModal(id) {
@@ -3268,7 +3253,7 @@ function openAnalyzerEditModal(id) {
const idEl = document.getElementById('analyzerEditId');
const nameEl = document.getElementById('analyzerEditName');
const urlEl = document.getElementById('analyzerEditUrl');
const disabledEl = document.getElementById('analyzerEditDisabled');
const enabledEl = document.getElementById('analyzerEditEnabled');
const errorEl = document.getElementById('analyzerEditError');
errorEl.classList.add('d-none');
@@ -3281,13 +3266,13 @@ function openAnalyzerEditModal(id) {
idEl.value = String(a.id);
nameEl.value = a.name || '';
urlEl.value = a.url_template || '';
disabledEl.checked = !!a.is_disabled;
enabledEl.checked = !a.is_disabled;
} else {
titleEl.textContent = 'Add analyzer';
idEl.value = '';
nameEl.value = '';
urlEl.value = '';
disabledEl.checked = false;
enabledEl.checked = true;
}
modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true });
@@ -3298,13 +3283,13 @@ async function saveAnalyzerFromForm() {
const idEl = document.getElementById('analyzerEditId');
const nameEl = document.getElementById('analyzerEditName');
const urlEl = document.getElementById('analyzerEditUrl');
const disabledEl = document.getElementById('analyzerEditDisabled');
const enabledEl = document.getElementById('analyzerEditEnabled');
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;
const is_disabled = !enabledEl.checked;
if (!name) {
showAnalyzerFormError('Name is required');
@@ -3428,11 +3413,10 @@ async function openMessageAnalyzer(packetHash) {
await ensureAnalyzersLoaded();
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) {
window.open(substituteAnalyzerUrl(letsmeshTpl, packetHash), 'meshcore-analyzer');
showNotification('No analyzer configured. Add one in Settings → Analyzer.', 'warning');
return;
}
@@ -3443,7 +3427,13 @@ async function openMessageAnalyzer(packetHash) {
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);
}
@@ -3452,26 +3442,17 @@ function openAnalyzerChooser(packetHash, enabled) {
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 => `
listEl.innerHTML = 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 => {
+2 -2
View File
@@ -1056,8 +1056,8 @@
<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>
<input class="form-check-input" type="checkbox" id="analyzerEditEnabled" checked>
<label class="form-check-label small" for="analyzerEditEnabled">Enabled</label>
</div>
<div id="analyzerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
</div>