mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-06-13 02:04:50 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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)
|
||||
// ================================================================
|
||||
|
||||
@@ -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 — 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">
|
||||
|
||||
Reference in New Issue
Block a user