Skip to content

Commit 10792b8

Browse files
MarekWoclaude
andcommitted
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>
1 parent e06e7b0 commit 10792b8

6 files changed

Lines changed: 672 additions & 23 deletions

File tree

app/database.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,81 @@ def get_default_region(self) -> Optional[Dict]:
548548
).fetchone()
549549
return dict(row) if row else None
550550

551+
# ================================================================
552+
# Analyzers (user-configured MeshCore Analyzer services)
553+
# ================================================================
554+
555+
def create_analyzer(self, name: str, url_template: str) -> int:
556+
"""Insert a new analyzer. Raises sqlite3.IntegrityError on duplicate name."""
557+
with self._connect() as conn:
558+
cursor = conn.execute(
559+
"INSERT INTO analyzers (name, url_template) VALUES (?, ?)",
560+
(name, url_template)
561+
)
562+
return cursor.lastrowid
563+
564+
def list_analyzers(self) -> List[Dict]:
565+
with self._connect() as conn:
566+
rows = conn.execute(
567+
"SELECT * FROM analyzers ORDER BY name COLLATE NOCASE"
568+
).fetchall()
569+
return [dict(r) for r in rows]
570+
571+
def get_analyzer(self, analyzer_id: int) -> Optional[Dict]:
572+
with self._connect() as conn:
573+
row = conn.execute(
574+
"SELECT * FROM analyzers WHERE id = ?", (analyzer_id,)
575+
).fetchone()
576+
return dict(row) if row else None
577+
578+
def update_analyzer(self, analyzer_id: int, name: Optional[str] = None,
579+
url_template: Optional[str] = None,
580+
is_disabled: Optional[bool] = None) -> bool:
581+
"""Update fields on an analyzer. Pass None to leave a field unchanged."""
582+
sets = []
583+
params: List[Any] = []
584+
if name is not None:
585+
sets.append("name = ?")
586+
params.append(name)
587+
if url_template is not None:
588+
sets.append("url_template = ?")
589+
params.append(url_template)
590+
if is_disabled is not None:
591+
sets.append("is_disabled = ?")
592+
params.append(1 if is_disabled else 0)
593+
if not sets:
594+
return False
595+
sets.append("updated_at = datetime('now')")
596+
params.append(analyzer_id)
597+
with self._connect() as conn:
598+
cursor = conn.execute(
599+
f"UPDATE analyzers SET {', '.join(sets)} WHERE id = ?",
600+
params
601+
)
602+
return cursor.rowcount > 0
603+
604+
def delete_analyzer(self, analyzer_id: int) -> bool:
605+
with self._connect() as conn:
606+
cursor = conn.execute("DELETE FROM analyzers WHERE id = ?", (analyzer_id,))
607+
return cursor.rowcount > 0
608+
609+
def set_default_analyzer(self, analyzer_id: Optional[int]) -> None:
610+
"""Clear any existing default, then set the given analyzer as default.
611+
612+
Passing None clears the default flag on all analyzers.
613+
"""
614+
with self._connect() as conn:
615+
conn.execute(
616+
"UPDATE analyzers SET is_default = 0, updated_at = datetime('now') "
617+
"WHERE is_default = 1"
618+
)
619+
if analyzer_id is not None:
620+
conn.execute(
621+
"UPDATE analyzers SET is_default = 1, updated_at = datetime('now') "
622+
"WHERE id = ?",
623+
(analyzer_id,)
624+
)
625+
551626
def set_channel_scope(self, channel_idx: int, region_id: Optional[int]) -> None:
552627
"""Set or clear the region mapping for a channel.
553628

app/device_manager.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from Crypto.Cipher import AES
2121

22-
ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='
22+
LETSMESH_ANALYZER_URL_TEMPLATE = 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}'
2323
GRP_TXT_TYPE_BYTE = 0x05
2424

2525
logger = logging.getLogger(__name__)
@@ -669,13 +669,12 @@ async def _on_channel_message(self, event):
669669
if path_len_raw is not None:
670670
hop_count, path_hash_size, _ = decode_path_len(path_len_raw)
671671

672-
# Compute analyzer URL from pkt_payload
673-
analyzer_url = None
672+
# Compute packet hash from pkt_payload (frontend builds URL)
673+
packet_hash = None
674674
if pkt_payload:
675675
try:
676676
raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload)
677677
packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper()
678-
analyzer_url = f"{ANALYZER_BASE_URL}{packet_hash}"
679678
except (ValueError, TypeError):
680679
pass
681680

@@ -691,7 +690,7 @@ async def _on_channel_message(self, event):
691690
'hop_count': hop_count,
692691
'path_hash_size': path_hash_size,
693692
'pkt_payload': pkt_payload,
694-
'analyzer_url': analyzer_url,
693+
'packet_hash': packet_hash,
695694
}, namespace='/chat')
696695
logger.debug(f"SocketIO emitted new_message for ch{channel_idx} msg #{msg_id}")
697696

app/routes/api.py

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from app.meshcore import cli, parser
2121
from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name
2222
from app.config import config, runtime_config
23-
from app.device_manager import decode_path_len
23+
from app.device_manager import decode_path_len, LETSMESH_ANALYZER_URL_TEMPLATE
2424
from app.archiver import manager as archive_manager
2525
from app.contacts_cache import get_all_names, get_all_contacts
2626

@@ -56,16 +56,15 @@ def _get_dm():
5656
CONTACTS_DETAILED_CACHE_TTL = 60 # seconds
5757

5858

59-
ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='
6059
GRP_TXT_TYPE_BYTE = 0x05
60+
ANALYZER_PLACEHOLDER = '{packetHash}'
6161

6262

63-
def compute_analyzer_url(pkt_payload):
64-
"""Compute MeshCore Analyzer URL from a hex-encoded pkt_payload."""
63+
def compute_packet_hash(pkt_payload):
64+
"""Compute MeshCore Analyzer packet hash (16 uppercase hex chars) from a hex-encoded pkt_payload."""
6565
try:
6666
raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload)
67-
packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper()
68-
return f"{ANALYZER_BASE_URL}{packet_hash}"
67+
return hashlib.sha256(raw).hexdigest()[:16].upper()
6968
except (ValueError, TypeError):
7069
return None
7170

@@ -483,9 +482,9 @@ def get_messages():
483482
'pkt_payload': pkt_payload,
484483
}
485484

486-
# Enrich with echo data and analyzer URL
485+
# Enrich with echo data and packet hash (frontend builds analyzer URL)
487486
if pkt_payload:
488-
msg['analyzer_url'] = compute_analyzer_url(pkt_payload)
487+
msg['packet_hash'] = compute_packet_hash(pkt_payload)
489488
echoes = db.get_echoes_for_message(pkt_payload)
490489
if echoes:
491490
msg['echo_count'] = len(echoes)
@@ -590,7 +589,7 @@ def get_message_meta(msg_id):
590589
}
591590

592591
if pkt_payload:
593-
meta['analyzer_url'] = compute_analyzer_url(pkt_payload)
592+
meta['packet_hash'] = compute_packet_hash(pkt_payload)
594593
echoes = db.get_echoes_for_message(pkt_payload)
595594
if echoes:
596595
meta['echo_count'] = len(echoes)
@@ -4186,6 +4185,157 @@ def set_default_region_api(region_id):
41864185
return jsonify({'success': False, 'error': str(e)}), 500
41874186

41884187

4188+
# =============================================================================
4189+
# Analyzers (user-configured MeshCore Analyzer services) — Settings > Analyzer tab
4190+
# =============================================================================
4191+
4192+
def _validate_analyzer_url_template(url_template: str):
4193+
"""Return (ok, error_msg). Validates the template the frontend will substitute."""
4194+
if not url_template:
4195+
return False, 'URL is required'
4196+
if not (url_template.startswith('http://') or url_template.startswith('https://')):
4197+
return False, 'URL must start with http:// or https://'
4198+
if ANALYZER_PLACEHOLDER not in url_template:
4199+
return False, f'URL must contain the {ANALYZER_PLACEHOLDER} placeholder'
4200+
return True, None
4201+
4202+
4203+
@api_bp.route('/analyzers', methods=['GET'])
4204+
def list_analyzers_api():
4205+
"""List user-configured analyzers and the built-in Letsmesh URL template."""
4206+
try:
4207+
db = _get_db()
4208+
if not db:
4209+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4210+
return jsonify({
4211+
'success': True,
4212+
'analyzers': db.list_analyzers(),
4213+
'letsmesh_url_template': LETSMESH_ANALYZER_URL_TEMPLATE,
4214+
}), 200
4215+
except Exception as e:
4216+
logger.error(f"Error listing analyzers: {e}")
4217+
return jsonify({'success': False, 'error': str(e)}), 500
4218+
4219+
4220+
@api_bp.route('/analyzers', methods=['POST'])
4221+
def create_analyzer_api():
4222+
"""Create a new analyzer. Body: {name, url_template}."""
4223+
try:
4224+
data = request.get_json() or {}
4225+
name = (data.get('name') or '').strip()
4226+
url_template = (data.get('url_template') or '').strip()
4227+
4228+
if not name:
4229+
return jsonify({'success': False, 'error': 'Name is required'}), 400
4230+
ok, err = _validate_analyzer_url_template(url_template)
4231+
if not ok:
4232+
return jsonify({'success': False, 'error': err}), 400
4233+
4234+
db = _get_db()
4235+
if not db:
4236+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4237+
4238+
import sqlite3
4239+
try:
4240+
aid = db.create_analyzer(name, url_template)
4241+
except sqlite3.IntegrityError:
4242+
return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409
4243+
4244+
return jsonify({'success': True, 'analyzer': db.get_analyzer(aid)}), 201
4245+
except Exception as e:
4246+
logger.error(f"Error creating analyzer: {e}")
4247+
return jsonify({'success': False, 'error': str(e)}), 500
4248+
4249+
4250+
@api_bp.route('/analyzers/<int:analyzer_id>', methods=['PUT'])
4251+
def update_analyzer_api(analyzer_id):
4252+
"""Update an analyzer. Body: {name?, url_template?, is_disabled?}."""
4253+
try:
4254+
db = _get_db()
4255+
if not db:
4256+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4257+
4258+
if db.get_analyzer(analyzer_id) is None:
4259+
return jsonify({'success': False, 'error': 'Analyzer not found'}), 404
4260+
4261+
data = request.get_json() or {}
4262+
name = data.get('name')
4263+
url_template = data.get('url_template')
4264+
is_disabled = data.get('is_disabled')
4265+
4266+
if name is not None:
4267+
name = name.strip()
4268+
if not name:
4269+
return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400
4270+
if url_template is not None:
4271+
url_template = url_template.strip()
4272+
ok, err = _validate_analyzer_url_template(url_template)
4273+
if not ok:
4274+
return jsonify({'success': False, 'error': err}), 400
4275+
4276+
import sqlite3
4277+
try:
4278+
db.update_analyzer(analyzer_id, name=name, url_template=url_template,
4279+
is_disabled=is_disabled)
4280+
except sqlite3.IntegrityError:
4281+
return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409
4282+
4283+
return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200
4284+
except Exception as e:
4285+
logger.error(f"Error updating analyzer: {e}")
4286+
return jsonify({'success': False, 'error': str(e)}), 500
4287+
4288+
4289+
@api_bp.route('/analyzers/<int:analyzer_id>', methods=['DELETE'])
4290+
def delete_analyzer_api(analyzer_id):
4291+
"""Delete an analyzer."""
4292+
try:
4293+
db = _get_db()
4294+
if not db:
4295+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4296+
4297+
if db.get_analyzer(analyzer_id) is None:
4298+
return jsonify({'success': False, 'error': 'Analyzer not found'}), 404
4299+
4300+
db.delete_analyzer(analyzer_id)
4301+
return jsonify({'success': True}), 200
4302+
except Exception as e:
4303+
logger.error(f"Error deleting analyzer: {e}")
4304+
return jsonify({'success': False, 'error': str(e)}), 500
4305+
4306+
4307+
@api_bp.route('/analyzers/default', methods=['DELETE'])
4308+
def clear_default_analyzer_api():
4309+
"""Clear the default-analyzer flag."""
4310+
try:
4311+
db = _get_db()
4312+
if not db:
4313+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4314+
db.set_default_analyzer(None)
4315+
return jsonify({'success': True}), 200
4316+
except Exception as e:
4317+
logger.error(f"Error clearing default analyzer: {e}")
4318+
return jsonify({'success': False, 'error': str(e)}), 500
4319+
4320+
4321+
@api_bp.route('/analyzers/<int:analyzer_id>/default', methods=['POST'])
4322+
def set_default_analyzer_api(analyzer_id):
4323+
"""Mark an analyzer as default. Clears any previous default in the same transaction."""
4324+
try:
4325+
db = _get_db()
4326+
if not db:
4327+
return jsonify({'success': False, 'error': 'Database not available'}), 500
4328+
4329+
if db.get_analyzer(analyzer_id) is None:
4330+
return jsonify({'success': False, 'error': 'Analyzer not found'}), 404
4331+
4332+
db.set_default_analyzer(analyzer_id)
4333+
return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200
4334+
except Exception as e:
4335+
logger.error(f"Error setting default analyzer: {e}")
4336+
return jsonify({'success': False, 'error': str(e)}), 500
4337+
4338+
41894339
# =============================================================================
41904340
# Message Retention Settings
41914341
# =============================================================================

app/schema.sql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ CREATE TABLE IF NOT EXISTS regions (
5353
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
5454
);
5555

56+
-- User-configured MeshCore Analyzer services
57+
CREATE TABLE IF NOT EXISTS analyzers (
58+
id INTEGER PRIMARY KEY AUTOINCREMENT,
59+
name TEXT NOT NULL UNIQUE,
60+
url_template TEXT NOT NULL, -- must contain '{packetHash}'
61+
is_default INTEGER NOT NULL DEFAULT 0,
62+
is_disabled INTEGER NOT NULL DEFAULT 0,
63+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
64+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
65+
);
66+
CREATE UNIQUE INDEX IF NOT EXISTS idx_analyzers_one_default
67+
ON analyzers(is_default) WHERE is_default = 1;
68+
5669
-- Per-channel region mapping (absent row = no override; firmware default applies)
5770
CREATE TABLE IF NOT EXISTS channel_scopes (
5871
channel_idx INTEGER PRIMARY KEY,

0 commit comments

Comments
 (0)