Merge branch 'dev'

This commit is contained in:
MarekWo
2026-07-22 06:42:27 +02:00
15 changed files with 2273 additions and 51 deletions
+21
View File
@@ -1120,6 +1120,27 @@ class Database:
).fetchall()
return [dict(r) for r in rows]
def get_echoes_for_payloads(self, payloads: List[str]) -> Dict[str, List[Dict]]:
"""Batch-fetch echoes for many pkt_payloads with chunked IN queries.
Returns {pkt_payload: [echo dicts ordered by received_at]}.
Chunked at 500 to stay under SQLite's host-parameter limit."""
result: Dict[str, List[Dict]] = {}
if not payloads:
return result
with self._connect() as conn:
for i in range(0, len(payloads), 500):
chunk = payloads[i:i + 500]
placeholders = ",".join("?" * len(chunk))
rows = conn.execute(
f"""SELECT * FROM echoes WHERE pkt_payload IN ({placeholders})
ORDER BY received_at ASC""",
chunk
).fetchall()
for r in rows:
result.setdefault(r['pkt_payload'], []).append(dict(r))
return result
def update_message_pkt_payload(self, msg_id: int, pkt_payload: str) -> None:
"""Set pkt_payload on a channel message (used for sent message echo correlation)."""
with self._connect() as conn:
+184 -41
View File
@@ -390,6 +390,57 @@ def save_ui_settings(settings: dict) -> bool:
return False
def _build_channel_secrets(db) -> dict:
"""Build {channel_idx: secret_hex} lookup for pkt_payload computation.
Uses DB channels (fast) instead of get_channels_cached() which can block
on device communication when cache is cold."""
channel_secrets = {}
for ch_info in (db.get_channels() if db else []):
ch_key = ch_info.get('secret', ch_info.get('key', ''))
ch_idx = ch_info.get('idx', ch_info.get('index'))
if ch_key and ch_idx is not None:
channel_secrets[ch_idx] = ch_key
return channel_secrets
def _get_row_pkt_payload(row: dict, channel_secrets: dict):
"""Return pkt_payload for a channel message row, computing it when not
stored (v2: meshcore doesn't provide it). Returns None when it cannot
be computed (legacy row or missing channel secret)."""
pkt_payload = row.get('pkt_payload')
if pkt_payload:
return pkt_payload
ch_idx = row.get('channel_idx', 0)
sender_ts = row.get('sender_timestamp')
txt_type = row.get('txt_type', 0)
if not sender_ts or ch_idx not in channel_secrets:
return None
# Use original text from raw_json (preserves trailing whitespace)
raw_text = None
raw_json_str = row.get('raw_json')
if raw_json_str:
try:
raw_text = json.loads(raw_json_str).get('text')
except (json.JSONDecodeError, TypeError):
pass
# Fallback: reconstruct from sender + content
if not raw_text:
is_own = bool(row.get('is_own', 0))
if is_own:
device_name = runtime_config.get_device_name() or ''
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
else:
sender = row.get('sender', '')
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
return compute_pkt_payload(
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
)
@api_bp.route('/messages', methods=['GET'])
def get_messages():
"""
@@ -441,47 +492,15 @@ def get_messages():
days=days,
)
# Build channel secret lookup for pkt_payload computation
# Use DB channels (fast) instead of get_channels_cached() which
# can block on device communication when cache is cold
channel_secrets = {}
db_channels = db.get_channels() if db else []
for ch_info in db_channels:
ch_key = ch_info.get('secret', ch_info.get('key', ''))
ch_idx = ch_info.get('idx', ch_info.get('index'))
if ch_key and ch_idx is not None:
channel_secrets[ch_idx] = ch_key
channel_secrets = _build_channel_secrets(db)
# Convert DB rows to frontend-compatible format
messages = []
for row in db_messages:
pkt_payload = row.get('pkt_payload')
ch_idx = row.get('channel_idx', 0)
sender_ts = row.get('sender_timestamp')
txt_type = row.get('txt_type', 0)
# Compute pkt_payload if not stored (v2: meshcore doesn't provide it)
if not pkt_payload and sender_ts and ch_idx in channel_secrets:
# Use original text from raw_json (preserves trailing whitespace)
raw_text = None
raw_json_str = row.get('raw_json')
if raw_json_str:
try:
raw_text = json.loads(raw_json_str).get('text')
except (json.JSONDecodeError, TypeError):
pass
# Fallback: reconstruct from sender + content
if not raw_text:
is_own = bool(row.get('is_own', 0))
if is_own:
device_name = runtime_config.get_device_name() or ''
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
else:
sender = row.get('sender', '')
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
pkt_payload = compute_pkt_payload(
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
)
pkt_payload = _get_row_pkt_payload(row, channel_secrets)
# Decode path_len into hop_count and path_hash_size
path_len_raw = row.get('path_len')
@@ -508,18 +527,23 @@ def get_messages():
'pkt_payload': pkt_payload,
}
# Enrich with echo data and packet hash (frontend builds analyzer URL)
if 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)
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
messages.append(msg)
# Enrich with echo data in one batch query (per-message queries are
# prohibitively slow on connection-per-call over bind mounts)
payloads = [m['pkt_payload'] for m in messages if m.get('pkt_payload')]
echoes_by_payload = db.get_echoes_for_payloads(payloads)
for msg in messages:
echoes = echoes_by_payload.get(msg.get('pkt_payload'))
if echoes:
msg['echo_count'] = len(echoes)
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
# Filter out blocked contacts' messages
blocked_names = db.get_blocked_contact_names()
if blocked_names:
@@ -550,6 +574,103 @@ def get_messages():
}), 500
@api_bp.route('/path-analyzer/messages', methods=['GET'])
def get_path_analyzer_messages():
"""
Bulk channel messages across ALL channels with batched echo data.
Used by the Path Analyzer panel. Unlike /api/messages this returns all
channels at once and fetches echoes in chunked batch queries instead of
one query per message.
Query parameters:
days (int): Time window in days (default 3, clamped to 1..30)
Returns:
JSON with messages list; each message carries an echoes[] array of
{path, snr, hash_size, direction, received_at}. Messages whose
pkt_payload cannot be computed (legacy rows, missing channel secret)
are returned with packet_hash null and empty echoes.
RSSI is not included it is not persisted for channel messages;
it would require an Observer-backed capture store (future work).
"""
try:
days = request.args.get('days', default=3, type=int)
days = max(1, min(days, 30))
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 500
db_messages = db.get_channel_messages(channel_idx=None, limit=None, days=days)
channel_secrets = _build_channel_secrets(db)
channel_names = {
ch.get('idx'): ch.get('name', '')
for ch in db.get_channels()
}
blocked_names = db.get_blocked_contact_names()
# First pass: compute payloads so echoes can be fetched in one batch
prepared = []
payloads = []
for row in db_messages:
if blocked_names and row.get('sender', '') in blocked_names:
continue
pkt_payload = _get_row_pkt_payload(row, channel_secrets)
if pkt_payload:
payloads.append(pkt_payload)
prepared.append((row, pkt_payload))
echoes_by_payload = db.get_echoes_for_payloads(payloads)
messages = []
for row, pkt_payload in prepared:
path_len_raw = row.get('path_len')
hop_count = None
path_hash_size = 1
if path_len_raw is not None:
hop_count, path_hash_size, _ = decode_path_len(path_len_raw)
ch_idx = row.get('channel_idx', 0)
messages.append({
'id': row.get('id'),
'channel_idx': ch_idx,
'channel_name': channel_names.get(ch_idx, ''),
'sender': row.get('sender', ''),
'content': row.get('content', ''),
'timestamp': row.get('timestamp', 0),
'datetime': datetime.fromtimestamp(row['timestamp']).isoformat() if row.get('timestamp') else None,
'is_own': bool(row.get('is_own', 0)),
'snr': row.get('snr'),
'hop_count': hop_count,
'path_hash_size': path_hash_size,
'packet_hash': compute_packet_hash(pkt_payload) if pkt_payload else None,
'pkt_payload': pkt_payload,
'echoes': [
{
'path': e.get('path', ''),
'snr': e.get('snr'),
'hash_size': e.get('hash_size', 1),
'direction': e.get('direction', 'incoming'),
'received_at': e.get('received_at'),
}
for e in echoes_by_payload.get(pkt_payload, [])
] if pkt_payload else [],
})
return jsonify({
'success': True,
'count': len(messages),
'days': days,
'messages': messages,
}), 200
except Exception as e:
logger.error(f"Error fetching path analyzer messages: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/messages/<int:msg_id>/meta', methods=['GET'])
def get_message_meta(msg_id):
"""Return metadata (SNR, hops, route, analyzer URL) for a single channel message."""
@@ -6274,6 +6395,28 @@ def delete_my_repeater(public_key):
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/password', methods=['GET'])
def get_my_repeater_password(public_key):
"""Return the saved password for a repeater (empty string when none).
Used to prefill the login-retry prompt: a wrong stored password and an
unreachable repeater are indistinguishable, so on a failed auto-login we
hand the (correct) saved password back to the same trusted local UI rather
than force the user to retype it. Passwords are already stored so the app
can log in on the user's behalf, so this stays within the local-app scope.
"""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
row = db.get_repeater(pk)
if not row:
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
return jsonify({'success': True, 'password': row.get('password') or ''}), 200
@api_bp.route('/repeaters/<public_key>/login', methods=['POST'])
def login_my_repeater(public_key):
"""Log into a repeater using the provided or saved password.
+9
View File
@@ -103,6 +103,15 @@ def console():
)
@views_bp.route('/path-analyzer')
def path_analyzer():
"""Path Analyzer - routing path analysis for channel messages."""
return render_template(
'path-analyzer.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/repeaters')
def repeaters():
"""My Repeaters - repeater administration panel (list + login)."""
+13 -3
View File
@@ -1637,6 +1637,13 @@ main {
color: white;
}
/* Path Analyzer FAB button + modal header (purple) */
.fab-pathanalyzer,
.pathanalyzer-header {
background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%);
color: white;
}
/* Filter bar overlay - slides down from top of chat area */
.filter-bar {
position: absolute;
@@ -1883,7 +1890,8 @@ emoji-picker {
#contactsModal .modal-dialog.modal-fullscreen,
#logsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen,
#repeatersModal .modal-dialog.modal-fullscreen {
#repeatersModal .modal-dialog.modal-fullscreen,
#pathAnalyzerModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
@@ -1895,7 +1903,8 @@ emoji-picker {
#contactsModal .modal-content,
#logsModal .modal-content,
#consoleModal .modal-content,
#repeatersModal .modal-content {
#repeatersModal .modal-content,
#pathAnalyzerModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
@@ -1905,7 +1914,8 @@ emoji-picker {
#contactsModal .modal-body,
#logsModal .modal-body,
#consoleModal .modal-body,
#repeatersModal .modal-body {
#repeatersModal .modal-body,
#pathAnalyzerModal .modal-body {
overflow: hidden !important;
}
+2 -1
View File
@@ -6030,6 +6030,7 @@ const ITEM_PLACEMENT_DEFS = {
map: { fab: '#fab-map', menu: '#mapBtn' },
console: { fab: '#fab-console', menu: '#consoleBtn' },
repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' },
pathanalyzer: { fab: '#fab-pathanalyzer', menu: '#pathAnalyzerBtn' },
deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' },
syslog: { fab: '#fab-syslog', menu: '#logsBtn' },
};
@@ -6037,7 +6038,7 @@ const ITEM_PLACEMENT_DEFS = {
const ITEM_PLACEMENT_DEFAULTS = {
filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab',
advert: 'menu', floodadvert: 'menu', map: 'menu',
console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu'
console: 'menu', repeaters: 'menu', pathanalyzer: 'menu', deviceinfo: 'menu', syslog: 'menu'
};
function readItemPlacements() {
File diff suppressed because it is too large Load Diff
+98
View File
@@ -1130,6 +1130,11 @@ function renderSettingsPane(body) {
<i class="bi bi-arrow-clockwise me-1"></i>Some changes take effect after a reboot use Actions Reboot.
</div>
<div class="sec-fields">${sec.fields.map(settingsFieldHtml).join('')}</div>
${sec.key === 'location' ? `
<button type="button" class="btn btn-sm btn-outline-primary sec-map-pick" disabled
title="Refresh the section first, then pick a point">
<i class="bi bi-geo-alt"></i> Pick from map
</button>` : ''}
<div class="d-flex align-items-center gap-2 mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary sec-refresh">
<i class="bi bi-arrow-clockwise"></i> Refresh
@@ -1159,6 +1164,8 @@ function renderSettingsPane(body) {
});
item.querySelector('.sec-refresh').addEventListener('click', () => loadSettingsSection(sec.key));
item.querySelector('.sec-apply').addEventListener('click', () => applySettingsSection(sec.key));
const mapPickBtn = item.querySelector('.sec-map-pick');
if (mapPickBtn) mapPickBtn.addEventListener('click', openLocationMapPicker);
item.querySelectorAll('.sf-input, .sf-sub').forEach(inp => {
const evt = (inp.type === 'checkbox' || inp.tagName === 'SELECT') ? 'change' : 'input';
inp.addEventListener(evt, () => updateSectionDirty(sec.key));
@@ -1288,6 +1295,8 @@ async function loadSettingsSection(secKey) {
'Reading from repeater… (one mesh round-trip per field)';
item.querySelector('.sec-refresh').disabled = true;
item.querySelector('.sec-apply').disabled = true;
const mapPickBtn = item.querySelector('.sec-map-pick');
if (mapPickBtn) mapPickBtn.disabled = true;
sec.fields.forEach(f => {
if (f.writeOnly) return;
setFieldBadge(item, f.key, 'clear');
@@ -1332,6 +1341,7 @@ async function loadSettingsSection(secKey) {
setFieldBadge(item, f.key, 'error', errors[f.key] || 'Failed to read');
}
});
if (mapPickBtn) mapPickBtn.disabled = !('lat' in st.loaded && 'lon' in st.loaded);
st.loadedOnce = true;
if (errCount) {
@@ -1467,6 +1477,92 @@ async function syncSavedPassword(newPassword) {
}
}
// ---------------- Location map picker (Settings → Location) ----------------
let _locMap = null;
let _locMapMarker = null;
let _locMapModal = null;
let _locMapPicked = null; // {lat, lon} currently chosen on the map
function openLocationMapPicker() {
const item = settingsSectionEl('location');
if (!item) return;
const latEl = sfControl(item, 'lat');
const lonEl = sfControl(item, 'lon');
// Seed from the loaded field values, then the advertised position, then a
// wide default view of the mesh area.
let seedLat = parseFloat(latEl && latEl.value);
let seedLon = parseFloat(lonEl && lonEl.value);
if (!isFinite(seedLat) || !isFinite(seedLon)) {
seedLat = (_repeater && _repeater.adv_lat) || NaN;
seedLon = (_repeater && _repeater.adv_lon) || NaN;
}
const hasSeed = isFinite(seedLat) && isFinite(seedLon) && (seedLat !== 0 || seedLon !== 0);
if (!_locMapModal) {
_locMapModal = new bootstrap.Modal(document.getElementById('locationMapModal'));
}
const useBtn = document.getElementById('locMapUseBtn');
const selLabel = document.getElementById('locMapSelected');
_locMapPicked = null;
if (useBtn) useBtn.disabled = true;
if (selLabel) selLabel.textContent = '—';
const modalEl = document.getElementById('locationMapModal');
const onShown = function () {
const center = hasSeed ? [seedLat, seedLon] : [52.0, 19.0];
const zoom = hasSeed ? 13 : 6;
if (!_locMap) {
_locMap = L.map('locLeafletMap').setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_locMap);
_locMap.on('click', (e) => setLocMapMarker(e.latlng.lat, e.latlng.lng));
} else {
_locMap.setView(center, zoom);
}
_locMap.invalidateSize();
if (_locMapMarker) { _locMap.removeLayer(_locMapMarker); _locMapMarker = null; }
if (hasSeed) setLocMapMarker(seedLat, seedLon);
modalEl.removeEventListener('shown.bs.modal', onShown);
};
modalEl.addEventListener('shown.bs.modal', onShown);
_locMapModal.show();
}
function setLocMapMarker(lat, lon) {
_locMapPicked = { lat, lon };
if (!_locMapMarker) {
_locMapMarker = L.circleMarker([lat, lon], {
radius: 9, fillColor: '#dc3545', color: '#fff',
weight: 2, opacity: 1, fillOpacity: 0.9
}).addTo(_locMap);
} else {
_locMapMarker.setLatLng([lat, lon]);
}
const useBtn = document.getElementById('locMapUseBtn');
const selLabel = document.getElementById('locMapSelected');
if (useBtn) useBtn.disabled = false;
if (selLabel) selLabel.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
}
function applyLocationFromMap() {
if (!_locMapPicked) return;
const item = settingsSectionEl('location');
if (!item) return;
const latEl = sfControl(item, 'lat');
const lonEl = sfControl(item, 'lon');
if (latEl) latEl.value = _locMapPicked.lat.toFixed(6);
if (lonEl) lonEl.value = _locMapPicked.lon.toFixed(6);
updateSectionDirty('location');
if (_locMapModal) _locMapModal.hide();
}
// ================================================================
// Actions tool
// ================================================================
@@ -1743,6 +1839,8 @@ document.addEventListener('DOMContentLoaded', () => {
input.type = input.type === 'password' ? 'text' : 'password';
});
document.getElementById('locMapUseBtn').addEventListener('click', applyLocationFromMap);
document.getElementById('copyPubkeyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey);
+24 -4
View File
@@ -171,14 +171,16 @@ function renderRepeaterRows() {
? '<span class="spinner-border spinner-border-sm text-success" role="status"></span>'
: '<i class="bi bi-diagram-3 text-success fs-4"></i>';
const roleInfo = r.last_login_at
? ` · last login: ${esc(r.last_login_role || '?')}`
: '';
const statusLine = isLoggingIn
? '<span class="text-primary">Logging in… (may take up to 60 s on flood paths)</span>'
: (r.on_device
? `<span class="rpt-path font-monospace">${esc(r.path_or_mode || '—')}</span>${roleInfo}`
? `<span class="rpt-path font-monospace">${esc(r.path_or_mode || '—')}</span>`
: '<span class="text-warning">Not stored on the device</span>');
// "last login" goes on its own line so a long path keeps the full row
// width to itself (on narrow phones it otherwise gets truncated hard).
const roleLine = (r.on_device && !isLoggingIn && r.last_login_at)
? `<small class="text-muted d-block text-truncate">last login: ${esc(r.last_login_role || '?')}</small>`
: '';
row.innerHTML = `
<div class="d-flex align-items-center gap-2">
@@ -186,6 +188,7 @@ function renderRepeaterRows() {
<div class="flex-grow-1 rpt-main">
<div class="fw-semibold text-truncate">${esc(r.name || r.public_key.substring(0, 12))}</div>
<small class="text-muted d-block text-truncate">${statusLine}</small>
${roleLine}
</div>
<div class="btn-group btn-group-sm flex-shrink-0">
<button type="button" class="btn btn-outline-secondary" data-action="path"
@@ -319,6 +322,23 @@ function openPasswordModal(mode, pubkey, errorHint = '') {
input.value = '';
input.type = 'password';
// Login retry for a repeater with a saved password: the failure is most
// likely a connection problem (a wrong stored password and an unreachable
// repeater look identical), so prefill the saved password rather than make
// the user retype a correct one.
if (mode === 'login' && r.password_set) {
fetch(`/api/repeaters/${encodeURIComponent(pubkey)}/password`)
.then(resp => resp.json())
.then(data => {
if (data && data.success && data.password
&& _passwordCtx && _passwordCtx.mode === 'login'
&& _passwordCtx.pubkey === pubkey) {
input.value = data.password;
}
})
.catch(() => {});
}
_passwordModal.show();
setTimeout(() => input.focus(), 300);
}
+18
View File
@@ -205,6 +205,13 @@
<small class="d-block text-muted">Repeater administration</small>
</div>
</button>
<button id="pathAnalyzerBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" data-bs-dismiss="offcanvas">
<i class="bi bi-signpost-split" style="font-size: 1.5rem;"></i>
<div>
<span>Path Analyzer</span>
<small class="d-block text-muted">Routing path analysis</small>
</div>
</button>
<!-- System -->
<div class="list-group-item py-2 mt-2">
@@ -894,6 +901,17 @@
</div>
</td>
</tr>
<tr data-placement-key="pathanalyzer">
<td class="ps-0">Path Analyzer</td>
<td class="pe-0 text-end">
<div class="btn-group btn-group-sm" role="group" aria-label="Path Analyzer placement">
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-fab" value="fab" autocomplete="off">
<label class="btn btn-outline-primary" for="place-pathanalyzer-fab">Quick Access</label>
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-menu" value="menu" autocomplete="off">
<label class="btn btn-outline-primary" for="place-pathanalyzer-menu">Main Menu</label>
</div>
</td>
</tr>
<tr data-placement-key="deviceinfo">
<td class="ps-0">Device Info</td>
<td class="pe-0 text-end">
+31
View File
@@ -148,6 +148,9 @@
<button class="fab fab-repeaters d-none" id="fab-repeaters" data-bs-toggle="modal" data-bs-target="#repeatersModal" title="My Repeaters">
<i class="bi bi-diagram-3"></i>
</button>
<button class="fab fab-pathanalyzer d-none" id="fab-pathanalyzer" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" title="Path Analyzer">
<i class="bi bi-signpost-split"></i>
</button>
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="Device Info">
<i class="bi bi-cpu"></i>
</button>
@@ -224,6 +227,23 @@
</div>
</div>
<!-- Path Analyzer Modal (Full Screen) -->
<div class="modal fade" id="pathAnalyzerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header pathanalyzer-header text-white">
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> Path Analyzer</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
</div>
<div class="modal-body p-0">
<iframe id="pathAnalyzerFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- System Log Modal (Full Screen) -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
@@ -324,6 +344,17 @@
});
}
// Path Analyzer modal - (re)load iframe on open for fresh data
const pathAnalyzerModal = document.getElementById('pathAnalyzerModal');
if (pathAnalyzerModal) {
pathAnalyzerModal.addEventListener('show.bs.modal', function () {
const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame');
if (pathAnalyzerFrame) {
pathAnalyzerFrame.src = '/path-analyzer';
}
});
}
// System Log modal - load iframe on open, clear on close
const logsModal = document.getElementById('logsModal');
if (logsModal) {
+683
View File
@@ -0,0 +1,683 @@
<!DOCTYPE html>
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Path Analyzer - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for the map view, stage 5) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.pa-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Collapsible filter group. On >=768px it renders as if its children
sat directly in the toolbar (display: contents), so the desktop
layout is unchanged and the collapse state is ignored. Below 768px
it is a Bootstrap collapse spanning the full toolbar width,
toggled by the Filters button. Keep these rules on the class (not
the #id) so Bootstrap's .collapse:not(.show) still wins. */
.pa-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 768px) {
.pa-filters {
display: contents !important;
}
}
@media (max-width: 767.98px) {
.pa-filters {
width: 100%;
order: 10; /* keep the counter on the first toolbar line */
}
.pa-filters input.form-control {
flex: 1 1 42%;
width: auto !important;
min-width: 0;
}
}
.pa-content {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 1rem;
}
#paMapWrap {
flex: 1 1 0;
min-height: 0;
display: flex;
gap: 0.75rem;
}
#paMapSidebar {
width: 340px;
min-width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
#paMap {
flex: 1 1 0;
min-height: 300px;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
@media (max-width: 768px) {
#paMapWrap {
flex-direction: column;
}
/* Message/path list gets the remaining space, the map keeps a
fixed ~45% of the viewport height */
#paMapSidebar {
width: auto;
min-width: 0;
max-height: none;
flex: 1 1 0;
min-height: 0;
}
#paMap {
flex: 0 0 45vh;
min-height: 0;
}
}
.pa-map-msg {
border: 1px solid var(--border-color);
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
margin-bottom: 0.4rem;
cursor: pointer;
font-size: 0.82rem;
}
.pa-map-msg:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-map-msg.pa-selected {
border-color: #6f42c1;
}
.pa-map-echo {
border-top: 1px dashed var(--border-color);
margin-top: 0.35rem;
padding: 0.25rem 0.15rem 0;
cursor: pointer;
font-size: 0.78rem;
}
.pa-map-echo:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-map-echo.pa-selected {
color: #6f42c1;
font-weight: 600;
}
.pa-legend {
margin-top: 0.4rem;
padding: 0.4rem 0.5rem;
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
border-radius: 0.4rem;
font-size: 0.78rem;
}
.pa-legend-hop {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.15rem 0;
}
.pa-pick-chip {
border: 1px solid #d39e00;
color: #b58900;
border-radius: 0.25rem;
padding: 0 0.3rem;
cursor: pointer;
font-size: 0.72rem;
}
.pa-pick-chip:hover,
.pa-pick-chip.pa-picked {
background-color: #ffc107;
color: #212529;
}
.pa-unpick {
cursor: pointer;
color: var(--text-secondary, #6c757d);
}
.pa-unpick:hover {
color: #dc3545;
}
.pa-reset-picks {
font-size: 0.72rem;
}
/* Numbered hop markers + name labels on the drawn path */
.pa-hop-badge {
width: 22px;
height: 22px;
border-radius: 50%;
background-color: #dc3545;
color: #fff;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
}
.pa-hop-label {
font-size: 0.7rem;
padding: 0 4px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: none;
}
/* Packet hash inside the expanded detail row - narrow screens only,
where the Hash column is hidden */
.pa-detail-hash {
display: none;
font-size: 0.75rem;
padding-bottom: 0.2rem;
}
.pa-time-short {
display: none;
}
.pa-table-wrap {
overflow-x: auto;
}
.pa-table {
font-size: 0.85rem;
}
.pa-table th {
white-space: nowrap;
}
.pa-table td {
vertical-align: middle;
}
.pa-hash {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.pa-hash:hover {
text-decoration: underline;
}
.pa-content-preview {
max-width: 22rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-time {
white-space: nowrap;
}
.pa-caret {
display: inline-block;
transition: transform 0.15s;
font-size: 0.7rem;
width: 1rem;
}
tr.pa-open .pa-caret {
transform: rotate(90deg);
}
.pa-msg-row {
cursor: pointer;
}
.pa-msg-row.pa-no-echoes {
cursor: default;
}
.pa-msg-row.pa-no-echoes .pa-caret {
visibility: hidden;
}
.pa-echo-cell {
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
padding: 0.35rem 0.75rem 0.35rem 2rem;
}
.pa-echo-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.2rem 0;
cursor: pointer;
border-radius: 0.25rem;
}
.pa-echo-line:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-echo-meta {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
white-space: nowrap;
}
.pa-chip {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.05rem 0.35rem;
cursor: pointer;
background-color: var(--card-bg, transparent);
}
.pa-chip:hover {
background-color: #6f42c1;
border-color: #6f42c1;
color: #fff;
}
.pa-chip-arrow {
font-size: 0.7rem;
color: var(--text-secondary, #6c757d);
}
.pa-echo-mapbtn {
cursor: pointer;
padding: 0 0.3rem;
color: var(--text-secondary, #6c757d);
}
.pa-echo-mapbtn:hover {
color: #6f42c1;
}
.pa-map-msg-chan {
font-size: 0.72rem;
color: var(--text-secondary, #6c757d);
white-space: nowrap;
}
.pa-map-msg-preview {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
margin-top: 0.1rem;
}
.pa-direct {
font-size: 0.75rem;
font-style: italic;
color: var(--text-secondary, #6c757d);
}
.pa-sortable {
cursor: pointer;
user-select: none;
}
.pa-sortable:hover {
text-decoration: underline;
}
.pa-stats-row {
cursor: pointer;
}
.pa-ambiguous {
color: var(--text-secondary, #6c757d);
font-style: italic;
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
padding: 3rem 1rem;
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
/* Narrow screens: drop secondary columns so rows fit the viewport
and echo paths can wrap (keeps the jump-to-map button reachable).
Hash and HB move into the expanded detail row. Must stay LAST in
this stylesheet so it overrides the base rules above. */
@media (max-width: 576px) {
.pa-col-hash,
.pa-col-hb,
.pa-col-msgs,
.pa-col-lasthop {
display: none;
}
.pa-content-preview {
max-width: 9rem;
}
.pa-time-full {
display: none;
}
.pa-time-short {
display: inline;
}
.pa-detail-hash {
display: block;
}
.pa-echo-cell {
padding-left: 0.75rem;
}
/* Long contact names must not push the SNR column off-screen */
#paStatsBody td:nth-child(2) {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-col-echoes {
display: none;
}
.pa-table td,
.pa-table th {
padding: 0.3rem 0.2rem;
}
/* Truncate long channel names */
#paTableBody td:nth-child(3) {
max-width: 3.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Let long headers (Avg SNR...) wrap instead of widening columns */
.pa-table th {
white-space: normal;
}
/* Truncate long sender names */
#paTableBody td:nth-child(4) {
max-width: 5.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-content-preview {
max-width: 5.2rem;
}
/* Date and time stack into two lines */
.pa-time {
white-space: normal;
}
.pa-time-short {
display: inline-block;
width: 3.2rem;
}
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="pa-toolbar">
<div class="btn-group btn-group-sm" role="group" aria-label="View">
<button id="paViewMessagesBtn" class="btn btn-primary" title="Message list">
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> Messages</span>
</button>
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="Per-repeater statistics">
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> Repeaters</span>
</button>
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="Route segment statistics">
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> Routes</span>
</button>
<button id="paViewMapBtn" class="btn btn-outline-primary" title="Map view">
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> Map</span>
</button>
</div>
<button id="paFiltersToggleBtn" class="btn btn-sm btn-outline-secondary d-md-none" type="button"
data-bs-toggle="collapse" data-bs-target="#paFiltersPanel"
aria-expanded="false" aria-controls="paFiltersPanel" title="Show/hide filters">
<i class="bi bi-funnel"></i> Filters
<span id="paFiltersBadge" class="badge text-bg-primary d-none"></span>
</button>
<div id="paFiltersPanel" class="pa-filters collapse">
<div class="vr d-none d-sm-block"></div>
<label for="paDaysSelect" class="form-label mb-0 small text-muted">Time range:</label>
<select id="paDaysSelect" class="form-select form-select-sm" style="width: auto;">
<option value="1">Last 1 day</option>
<option value="3" selected>Last 3 days</option>
<option value="5">Last 5 days</option>
<option value="7">Last 7 days</option>
</select>
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="Reload">
<i class="bi bi-arrow-clockwise"></i>
</button>
<div class="vr d-none d-sm-block"></div>
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="Filter by hop count (any echo)">
<option value="any" selected>Any hops</option>
<option value="0">0 hops</option>
<option value="1">1 hop</option>
<option value="2">2 hops</option>
<option value="3">3 hops</option>
<option value="4+">4+ hops</option>
</select>
<select id="paHashSizeFilter" class="form-select form-select-sm" style="width: auto;"
title="Filter by path hash size (bytes per hop, any routed echo)">
<option value="any" selected>Any HB</option>
<option value="1">1-byte</option>
<option value="2">2-byte</option>
<option value="3">3-byte</option>
<option value="2,3">2/3-byte</option>
</select>
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 10.5rem;"
placeholder="Repeater (3B or name)"
title="Match a repeater anywhere in a path: hash prefix (hex) or contact name. Chain with '>' for a consecutive sequence, e.g. AFE6>6E9A">
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="Sender" title="Filter by sender name">
<input id="paContentFilter" type="text" class="form-control form-control-sm" style="width: 11rem;"
placeholder="Message text" title="Filter by message content">
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear filters">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
<span id="paCounter" class="small text-muted ms-auto"></span>
</div>
<!-- Content -->
<div class="pa-content">
<div id="paLoading" class="empty-state">
<div class="spinner-border text-secondary" role="status"></div>
<div class="mt-2">Loading messages…</div>
</div>
<div id="paEmpty" class="empty-state d-none">
<i class="bi bi-signpost-split"></i>
<span id="paEmptyText">No messages in the selected time range.</span>
</div>
<div id="paTableWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th style="width: 1.5rem;"></th>
<th>Time</th>
<th>Channel</th>
<th>Sender</th>
<th>Message</th>
<th class="pa-col-hash">Hash</th>
<th class="text-end">Hops</th>
<th class="text-end pa-col-hb" title="Path hash size in bytes per hop (from routed echoes)">HB</th>
<th class="text-end pa-col-echoes">Echoes</th>
</tr>
</thead>
<tbody id="paTableBody"></tbody>
</table>
</div>
<div id="paStatsWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Repeater</th>
<th>Contact</th>
<th class="text-end pa-sortable" data-sort="relayed"
title="Echoes whose path contains this hash (anywhere)">Relayed</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="Distinct messages relayed through this hash">Messages</th>
<th class="text-end pa-sortable pa-col-lasthop" data-sort="lastHop"
title="Echoes where this hash is the final hop (heard directly by us)">As last hop</th>
<th class="text-end pa-sortable" data-sort="avgSnr"
title="Average echo SNR, counted only when this hash is the final hop - SNR is measured at our receiver for the last hop">Avg SNR (last hop)</th>
</tr>
</thead>
<tbody id="paStatsBody"></tbody>
</table>
</div>
<div id="paRoutesWrap" class="d-none">
<div class="d-flex align-items-center gap-2 mb-2">
<label for="paSegLenSelect" class="form-label small text-muted mb-0">Segment length:</label>
<select id="paSegLenSelect" class="form-select form-select-sm" style="width: auto;"
title="Number of consecutive hops per counted segment">
<option value="2" selected>2 hops</option>
<option value="3">3 hops</option>
<option value="4">4 hops</option>
</select>
</div>
<div class="pa-table-wrap">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Route</th>
<th class="text-end pa-sortable" data-sort="echoes"
title="Echoes whose path contains this segment (anywhere)">Echoes</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="Distinct messages whose path contains this segment">Messages</th>
<th class="text-end pa-sortable" data-sort="pathEnd"
title="Echoes where this segment is the end of the path - the route the message took to reach us">As path end</th>
</tr>
</thead>
<tbody id="paRoutesBody"></tbody>
</table>
</div>
</div>
<div id="paMapWrap" class="d-none">
<div id="paMapSidebar">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small text-muted">Click a message, then an echo to draw its path.</span>
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear drawn path">
<i class="bi bi-eraser"></i>
</button>
</div>
<div id="paMapMsgList"></div>
</div>
<div id="paMap"></div>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Path Analyzer</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS (for the map view, stage 5) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Path Analyzer JS -->
<script src="{{ url_for('static', filename='js/path-analyzer.js') }}"></script>
</body>
</html>
+23
View File
@@ -376,6 +376,29 @@
</div>
</div>
<!-- Location Map Picker Modal (Settings → Location) -->
<div class="modal fade" id="locationMapModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Pick Location</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="px-3 py-2 border-bottom small text-muted">
Click the map to set the repeater location.
</div>
<div id="locLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small font-monospace" id="locMapSelected"></span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="locMapUseBtn" disabled>Use this location</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
+13
View File
@@ -105,6 +105,17 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are
- **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead
- **Actions whitelist**`advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI
### Path Analyzer
The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a read-only analysis view over data the app already collects — no new tables, no background work:
- **Data source**`GET /api/path-analyzer/messages?days=N` joins `channel_messages` with `echoes` (path hex + SNR + per-echo `hash_size`, keyed by `pkt_payload`). Echoes are fetched with `db.get_echoes_for_payloads()` — chunked `IN` queries (≤500 params, under SQLite's host-parameter limit) via `idx_echoes_pkt` — deliberately avoiding the per-message echo query the older `/api/messages` path still does. The pkt_payload reconstruction (raw_json text → channel-secret AES/HMAC compute) is shared with `/api/messages` via the `_get_row_pkt_payload()` helper
- **All filtering/stats/map/routes logic is client-side** over the bulk payload (hundreds of KB for 7 days — fine on a LAN): filters operate on per-hop tokens split with each echo's own `hash_size` (mixed 1/2/3-byte networks are real), so SQL-side token filtering was rejected. Every view always reflects the active filters for free
- **Four views** share the one payload: Messages (hop-by-hop echo detail), Repeaters (per-hash relay/SNR stats), Routes (consecutive hop-segment n-grams — user-selectable length 24, counted anywhere in a path), and Map (Leaflet path drawing). The repeater filter accepts a `>`-chained sequence (each element a hash prefix or contact name) matched as consecutive hops; Routes rows write such a sequence into that filter on click
- **SNR attribution** — echo SNR is measured at our receiver, so stats credit it to the *final* hop only; intermediate hops get relay counts but never SNR
- **Hash→contact resolution** — pubkey-prefix match against `/api/contacts/cached?format=full` (memoized per token). 1-byte hashes collide by design; the map renders unresolved hops as amber candidate markers with manual pick, and the repeater-name filter is intentionally inclusive over candidates
- Legacy rows whose `pkt_payload` cannot be recomputed (missing channel secret) are returned with `packet_hash: null` and no echoes rather than dropped
---
## Project Structure
@@ -197,6 +208,7 @@ The channels API reads from the `channels` DB table rather than iterating device
| GET | `/api/messages/<id>/meta` | Get message metadata (echoes, paths) |
| POST | `/api/messages/<id>/resend` | Re-broadcast an own channel message verbatim via `CMD_SEND_RAW_PACKET` (same packet hash, so unreached repeaters pick it up). 400 for not-own / missing `raw_packet` snapshot / disconnected / firmware < 1.16, 404 for unknown id |
| GET | `/api/messages/search` | Full-text search (`?q=`, `?channel_idx=`, `?limit=`) |
| GET | `/api/path-analyzer/messages` | Bulk channel messages across **all** channels with batched echo data (`?days=1..30`, default 3); powers the Path Analyzer panel (`GET /path-analyzer`) |
### Contacts
@@ -301,6 +313,7 @@ Every mutating endpoint hot-reloads the `ObserverManager`, so broker and setting
| GET | `/api/repeaters/<pk>` | Single merged entry + login session state |
| PUT | `/api/repeaters/<pk>` | Set or clear the saved password (`{password}`; empty string clears) |
| DELETE | `/api/repeaters/<pk>` | Remove from the list (device contact untouched) |
| GET | `/api/repeaters/<pk>/password` | Saved password (empty string when none); prefills the login-retry prompt for the trusted local UI |
| POST | `/api/repeaters/<pk>/login` | Log in with `{password?, save?}` — omitted password uses the saved one |
| GET | `/api/repeaters/<pk>/session` | In-memory session state (`logged_in`, `is_admin`, `permissions`) |
| POST | `/api/repeaters/<pk>/logout` | Log out and drop the session |
+75 -2
View File
@@ -17,6 +17,7 @@ This guide covers all features and functionality of mc-webui. For installation i
- [DM Path Management](#dm-path-management)
- [Interactive Console](#interactive-console)
- [My Repeaters (Repeater Administration)](#my-repeaters-repeater-administration)
- [Path Analyzer](#path-analyzer)
- [Device Dashboard](#device-dashboard)
- [Quick-Access FAB Buttons](#quick-access-fab-buttons)
- [Settings](#settings)
@@ -556,6 +557,7 @@ Click a repeater row to log in. The first time, you are asked for the repeater p
- The repeater decides your role from the password: **ADMIN** (full access) or **GUEST** (read-only: Status, Telemetry, Neighbors).
- **Important:** a wrong password and an unreachable repeater look identical — MeshCore repeaters simply never answer a bad login. When a login times out, check reachability first (is the repeater several flood hops away?) before assuming the password is wrong.
- Because of that ambiguity, when a one-click login fails the retry prompt comes back with your **saved password already filled in** — a failure is far more often a flaky connection than a wrong password, so you can just retry without retyping it.
- Logins can take up to a minute on flood paths. Pinning a direct path (below) makes them much faster.
### Row Actions
@@ -598,6 +600,7 @@ Repeater configuration organized into collapsible sections: **Basic** (name, adm
- Changed fields are highlighted and counted on the Apply button; only those fields are sent
- Every field reports back individually: a green check (applied), a **reboot required** badge (stored, takes effect after a reboot — radio parameters work this way), or a red error showing the repeater's own reply (e.g. an out-of-range value). Failed fields stay marked so you can correct and re-apply
- Changing radio parameters asks for confirmation first — wrong values can make the repeater unreachable over the mesh
- The **Location** section has a **Pick from map** button (enabled once the section has loaded): it opens a map, and clicking a point fills the latitude and longitude fields for you and marks the section changed, ready to Apply — handy when you know where the repeater is but not its exact coordinates
- The admin password is write-only (the current one is never displayed). After a successful change, the password saved in mc-webui is updated automatically so one-click login keeps working. Changing it does **not** log out sessions that are already active
#### Actions (admin only)
@@ -613,6 +616,76 @@ Erasing the repeater's file system is **not** available over the mesh — the fi
---
## Path Analyzer
A full-screen tool for analyzing how channel messages travel through the mesh: which repeaters relayed them, how strong the signal was, and what the routes look like on a map.
To open:
1. Tap the hamburger menu (☰)
2. Select **Path Analyzer** from the menu
Pick a time range at the top (last 1, 3, 5, or 7 days) — the tool loads every channel message from **all** your channels in that window, together with every copy (echo) of each message your node overheard. Everything below works on that data set; no extra loading.
### Filters
The filter bar applies to all four views at once and updates as you type:
- **Hops** - Only messages that arrived over exactly that many hops (0 = heard directly, 4+ = long routes). A message matches when *any* of its echoes has that hop count
- **HB (path hash size)** - Only messages whose route was recorded with 1-, 2-, or 3-byte repeater hashes; a combined **2/3-byte** option covers both larger sizes at once
- **Repeater** - Type a repeater hash (e.g. `3B`) or part of a repeater's **name** (e.g. `wegrzce`); matches messages whose route passed through it. Chain several with `>` (e.g. `AFE6>6E9A` or `barbarka>wegrzce`) to match a route that passed through them **in that order, consecutively** — mixing hashes and names is fine. Note that with short 1-byte hashes two repeaters can share a hash, so a name search includes routes where the named repeater *might* be one of the candidates
- **Sender** - Part of the sender's name
- **Message text** - Part of the message content
A counter shows how much of the data set matches ("38 of 412 messages"), and **Clear** resets everything. On phones the whole filter bar collapses behind a **Filters** button (with a badge counting the filters you have set) so the results get the full screen — the view switcher and match counter stay visible.
### Messages view
A table of messages: time, channel, sender, text, packet hash (click to copy — the same hash analyzer services use), hop count, hash size, and echo count. Click a row to expand its routes:
- Every echo is shown as a chain of repeater hashes (`5A → F0 → 90`) with its SNR and receive time
- Click a single hash chip to copy that hash; click the rest of the line to copy the whole route
- The small map icon at the end of each route jumps straight to the **Map** view with that route drawn
- Echoes with an empty route show as "Direct (flood, 0 hops)"
On phones the Hash, HB, and Echoes columns fold away to keep the table readable — the packet hash and hash size appear at the top of the expanded row instead, and long routes wrap across multiple lines.
### Repeaters view
Per-repeater statistics computed from the currently filtered messages:
- **Relayed** - How many echoes passed through this repeater hash (anywhere in the route)
- **Messages** - How many distinct messages that was
- **As last hop** - How often this repeater was the final hop, i.e. the one your node heard directly
- **Avg SNR (last hop)** - Average signal quality, counted *only* when the repeater was the final hop. Your node can only measure the radio link it actually receives on, so SNR is never attributed to repeaters in the middle of a route — that's why some rows show "—"
Columns are sortable, and the **Contact** column matches each hash to your contact list (showing "ambiguous (n)" when several contacts share a short hash). Click any row to jump back to the Messages view filtered to that repeater.
### Routes view
Where the Repeaters view counts single hops, the Routes view counts **sequences** of consecutive hops — so you can see which stretches of the mesh carry the most traffic to you, wherever they sit in a route. Pick a **Segment length** (2, 3, or 4 hops) and the table lists every such sequence found across the filtered messages:
- **Route** - The hop sequence (e.g. `AFE6 → 6E9A`), with the matching contact names underneath
- **Echoes** - How many overheard copies contained this exact sequence
- **Messages** - How many distinct messages that was
- **As path end** - How often the sequence was the *end* of a route — i.e. the final stretch that actually reached your node. Sort by this to see which approaches deliver to you most often
Columns are sortable (echo count first by default). Click any row to jump to the Messages view filtered to that sequence.
### Map view
Repeaters from your contact list that have a position are plotted as dots. Pick a message in the side list — each tile shows the sender, the channel it came from (dimmed, next to the sender), the time, and the message text. Its **shortest** route is drawn automatically the moment you pick the message, so you see a path with one tap; pick a different route from the list to redraw. The path is drawn hop by hop:
- Hops that resolve to exactly one known repeater become red numbered points (the number matches the legend order) labeled with the repeater's name, connected by a red line — clearly distinct from the purple background dots of uninvolved repeaters
- When a short hash matches several contacts, all candidates are marked in amber and the legend lists them — tap the right one and the path redraws with your choice. Assignments are reversible: an undo icon next to a manually assigned hop reverts just that hop, and **Reset picks** clears every manual assignment on the current path
- Unknown hops (no matching contact, or no position) are listed in the legend and the line is drawn dashed across the gap, so you can see which parts of the route are certain
- If the sender is in your contacts with a position, it is added as a green origin point
The eraser button clears the drawn path. On phones the map takes a fixed share of the screen (about 45%) and the message list gets the rest, so scrolling through routes is comfortable.
Everything in the Path Analyzer is based on what **your node** overheard — it's a local view of the mesh, not a global one. A route you don't see here may still exist; it just never reached your radio.
---
## Device Dashboard
Access device information and statistics:
@@ -665,7 +738,7 @@ Tap the toggle button (short click, no drag) to hide or show the rest of the FAB
Open Settings → **Appearance** tab to adjust:
- **Hide Quick Access** - Master switch: hides the FAB cluster entirely and moves all actions to the Main Menu
- **Per-item placement** - Choose whether each of the 12 actions appears in the FAB or in the Main Menu. Changes take effect immediately
- **Per-item placement** - Choose whether each of the 13 actions appears in the FAB or in the Main Menu. Changes take effect immediately
- **Button size** - 28 to 72 pixels (default: 56)
- **Spacing** - 2 to 24 pixels between buttons (default: 12)
- **Reset position** - Reset both main chat and DM FAB positions to their defaults
@@ -751,7 +824,7 @@ Controls small notification toasts shown after actions (e.g. "Advert Sent", erro
**Quick Access Buttons:**
- **Hide Quick Access** - Master switch that hides the entire floating FAB cluster and moves all items to the Main Menu (slide-out)
- **Per-item placement** - A table of all 12 configurable actions (Filter messages, Search messages, Direct Messages, Contact Management, Settings, Send Advert, Flood Advert, Map, Console, My Repeaters, Device Info, System Log). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately
- **Per-item placement** - A table of all 13 configurable actions (Filter messages, Search messages, Direct Messages, Contact Management, Settings, Send Advert, Flood Advert, Map, Console, My Repeaters, Path Analyzer, Device Info, System Log). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately
- **Button size (px)** - Adjust the size of FAB buttons (default: 56)
- **Spacing (px)** - Space between FAB buttons (default: 12)
- **Position** - Reset FAB position to default (top-right)
+6
View File
@@ -15,10 +15,16 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
- **Remote CLI, Settings, and Actions (admin logins).** **CLI** is a real terminal to the repeater — quick-command chips, per-repeater history, round-trip times. **Settings** edits the repeater configuration in collapsible sections (Basic, Radio, Location, Features, Network health, Advertisement, Operator info, Advanced): values load live from the repeater, only the fields you change are sent, and every field reports back individually — including a "reboot required" badge for radio parameters and the firmware's own error text for rejected values. **Actions** covers zero-hop advert, flood advert (marked "not recommended" — high network load), clock sync, and a confirmation-guarded reboot in a Danger zone. Erasing the file system stays USB-serial-only by firmware design, and the panel says so.
- **Observer mode — feed packet analyzers straight from mc-webui.** The new **Settings → Observer** tab turns your node into a MeshCore observer: every packet the device overhears is published to one or more MQTT brokers in the standard `meshcore-packet-capture` format, so analyzer services (a self-hosted Corescope, letsmesh-style maps) see your local mesh traffic without a separate capture script or a dedicated second node. Configure brokers with host/port, optional username/password and TLS; each row shows a live connected/error badge, and the tab counts packets captured vs published in real time. Capture is completely passive — chat and direct messages are unaffected — and all changes apply immediately, no restart needed. (LetsMesh token-authenticated brokers are not supported yet.)
- **Scheduled flood adverts.** The Observer tab includes an optional advert interval in hours: the app sends a flood advert on that schedule so your observer stays visible on analyzer maps. The timer survives restarts, so a redeploy won't send an extra advert early. Set it to 0 to keep adverts fully manual.
- **Path Analyzer — see how your messages travel the mesh.** A new full-screen tool (main menu → **Path Analyzer**) loads every channel message from all your channels for a chosen window (17 days) together with every copy your node overheard, and lets you dig in four ways. **Messages**: expand any message to see all its routes hop by hop with SNR — copy a repeater hash, copy the whole route, or jump straight to the map. **Repeaters**: per-repeater statistics (how many packets each hash relayed, over how many messages, and average SNR when it was the hop you actually heard), sortable and clickable to filter. **Routes**: the busiest hop *sequences* across all your traffic — pick a length (24 hops) and see which stretches of the mesh relay the most, with an "as path end" count that highlights the routes actually delivering to you. **Map**: pick a message and its shortest route is drawn instantly as a red line with numbered, name-labeled points — when a short 1-byte hash matches several repeaters, the candidates are shown in amber and you pick the right one instead of the app guessing (a mistaken pick can be undone per hop or reset for the whole path), and uncertain segments are drawn dashed so you always know which part of a route is confirmed.
- **Path Analyzer filters.** Everything is filterable live and in combination: hop count, path-hash size (1/2/3-byte), repeater — by hash **or by name**, and now as a `>`-chained sequence to find a specific consecutive route (e.g. `AFE6>6E9A`) — sender, and message text. All four views follow the active filters, and clicking a Routes or Repeaters row drops you into the message list already filtered.
- **Path Analyzer works on phones.** On narrow screens the whole filter bar collapses behind a **Filters** button (with a badge counting the filters you have set), the tables shed their secondary columns (packet hash and hash size move into the expanded row) and long routes wrap instead of scrolling sideways, and on the map the message list gets most of the height with the map fixed to a comfortable share below it.
- **Pin a repeater's location from a map.** In **My Repeaters → Settings → Location**, a new **Pick from map** button lets you click a point on a map to fill in the latitude and longitude — no more looking up coordinates by hand.
### Reliability & polish
- **Console `login` reports your role.** A successful repeater login in the Interactive Console now answers "Logged into X as admin" (or guest) instead of a bare success line.
- **Failed repeater login no longer makes you retype the password.** Since a wrong password and an unreachable repeater are indistinguishable — and a failure is usually just a flaky connection — the retry prompt now comes back with your saved password already filled in, so you can retry with one tap.
- **Repeater list reads better on narrow phones.** A repeater's "last login" time now sits on its own line, so a long pinned path no longer squeezes the rest of the row.
### Deploy notes