mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-30 21:43:19 +02:00
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:
@@ -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:
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user