diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..efdba87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf diff --git a/app/database.py b/app/database.py index 6a01acf..25bae37 100644 --- a/app/database.py +++ b/app/database.py @@ -715,6 +715,55 @@ class Database: cursor = conn.execute("DELETE FROM observer_brokers WHERE id = ?", (broker_id,)) return cursor.rowcount > 0 + # ================================================================ + # My Repeaters (saved repeater logins for the admin panel) + # ================================================================ + + def get_repeaters(self) -> List[Dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM repeaters ORDER BY added_at" + ).fetchall() + return [dict(r) for r in rows] + + def get_repeater(self, public_key: str) -> Optional[Dict]: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM repeaters WHERE public_key = ?", (public_key,) + ).fetchone() + return dict(row) if row else None + + def add_repeater(self, public_key: str) -> bool: + """Add a repeater entry. Returns False if it already exists.""" + with self._connect() as conn: + cursor = conn.execute( + "INSERT OR IGNORE INTO repeaters (public_key) VALUES (?)", + (public_key,) + ) + return cursor.rowcount > 0 + + def set_repeater_password(self, public_key: str, password: str) -> bool: + with self._connect() as conn: + cursor = conn.execute( + "UPDATE repeaters SET password = ? WHERE public_key = ?", + (password, public_key) + ) + return cursor.rowcount > 0 + + def update_repeater_login(self, public_key: str, role: str) -> bool: + with self._connect() as conn: + cursor = conn.execute( + "UPDATE repeaters SET last_login_at = datetime('now'), " + "last_login_role = ? WHERE public_key = ?", + (role, public_key) + ) + return cursor.rowcount > 0 + + def delete_repeater(self, public_key: str) -> bool: + with self._connect() as conn: + cursor = conn.execute("DELETE FROM repeaters WHERE public_key = ?", (public_key,)) + return cursor.rowcount > 0 + def set_channel_scope(self, channel_idx: int, region_id: Optional[int]) -> None: """Set or clear the region mapping for a channel. diff --git a/app/device_manager.py b/app/device_manager.py index e18023a..9ac5286 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -220,6 +220,15 @@ class DeviceManager: self._last_rx_at: float = 0.0 # unix ts of last RX_LOG_DATA / event from device self._consecutive_stats_failures: int = 0 # incremented on get_stats_* / get_bat failures + # Repeater administration (My Repeaters panel + console commands). + # The companion firmware tracks a single pending remote request, so + # concurrent repeater operations would corrupt each other's matching. + self._repeater_lock = threading.Lock() + self._repeater_sessions = {} # {public_key: {is_admin, permissions, logged_in_at}} + # Single-slot waiter for a repeater CLI text reply (guarded by + # _repeater_lock: only one remote command is ever in flight). + self._cli_waiter = None # {'prefix': 12-hex, 'event': threading.Event, 'reply': str|None} + # In-place reconnect (heals degraded long-lived TCP without container restart) self._reconnect_lock = threading.Lock() # prevents concurrent force_reconnect calls self._last_force_reconnect_at: float = 0.0 @@ -838,6 +847,20 @@ class DeviceManager: """Handle incoming direct message.""" try: data = getattr(event, 'payload', {}) + + # Repeater CLI replies (txt_type=1 CLI_DATA) awaited by + # repeater_cmd_wait() are consumed here and never stored as + # chat DMs. Unmatched CLI replies (e.g. console fire-and-forget + # `cmd`) keep the legacy behavior and land in the DM panel. + if data.get('txt_type') == 1: + waiter = self._cli_waiter + prefix = (data.get('pubkey_prefix') or '').lower() + if waiter and prefix and waiter['prefix'] == prefix: + waiter['reply'] = data.get('text', '') + waiter['event'].set() + logger.debug(f"CLI reply consumed from {prefix}") + return + ts = data.get('timestamp', int(time.time())) content = data.get('text', '') sender_key = data.get('public_key', data.get('pubkey_prefix', '')) @@ -3047,37 +3070,93 @@ class DeviceManager: # ── Repeater Management ────────────────────────────────────────── + REPEATER_BUSY_ERROR = 'Another repeater operation is in progress' + + def _locked_repeater_execute(self, coro, timeout: int, error_label: str) -> Dict: + """Run one remote repeater request under the repeater lock. + + The companion firmware tracks a single pending remote request + (each new send clears the previous one), so all repeater + operations must be serialized app-side. + """ + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} + try: + result = self.execute(coro, timeout=timeout) + if result is not None: + return {'success': True, 'data': result} + return {'success': False, 'error': f'No {error_label} response (timeout)'} + finally: + self._repeater_lock.release() + def repeater_login(self, name_or_key: str, password: str) -> Dict: - """Log into a repeater with given password.""" + """Log into a repeater and capture the granted role. + + A wrong password is indistinguishable from an unreachable + repeater: the firmware simply never replies, so both surface + as a timeout. + """ if not self.is_connected: return {'success': False, 'error': 'Device not connected'} contact = self.resolve_contact(name_or_key) if not contact: return {'success': False, 'error': f"Contact not found: {name_or_key}"} + coro = self._repeater_login_async(contact, password) + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} try: - from meshcore.events import EventType - res = self.execute( - self.mc.commands.send_login(contact, password), - timeout=10 - ) - # Wait for LOGIN_SUCCESS or LOGIN_FAILED - timeout = 30 - if res and hasattr(res, 'payload') and 'suggested_timeout' in res.payload: - timeout = res.payload['suggested_timeout'] / 800 - timeout = max(timeout, contact.get('timeout', 0) or 30) - event = self.execute( - self.mc.wait_for_event(EventType.LOGIN_SUCCESS, timeout=timeout), - timeout=timeout + 5 - ) - if event and hasattr(event, 'type') and event.type == EventType.LOGIN_SUCCESS: - return {'success': True, 'message': f'Logged into {contact.get("adv_name", name_or_key)}'} - return {'success': False, 'error': 'Login failed (timeout)'} + result = self.execute(coro, timeout=75) + if result.get('success'): + pubkey = (contact.get('public_key') or '').lower() + self._repeater_sessions[pubkey] = { + 'is_admin': result.get('is_admin', False), + 'permissions': result.get('permissions'), + 'logged_in_at': time.time(), + } + return result except Exception as e: - err = str(e) - if 'LOGIN_FAILED' in err or 'login' in err.lower(): - return {'success': False, 'error': 'Login failed (wrong password?)'} logger.error(f"Repeater login failed: {e}") - return {'success': False, 'error': err} + return {'success': False, 'error': str(e)} + finally: + self._repeater_lock.release() + + async def _repeater_login_async(self, contact: Dict, password: str) -> Dict: + """Send login and wait for a LOGIN_SUCCESS from this repeater.""" + from meshcore.events import EventType + res = await self.mc.commands.send_login(contact, password) + if res is None or getattr(res, 'type', None) == EventType.ERROR: + detail = getattr(res, 'payload', None) if res is not None else 'no response from device' + return {'success': False, 'error': f'Failed to send login: {detail}'} + timeout = 30.0 + payload = getattr(res, 'payload', None) or {} + if isinstance(payload, dict) and 'suggested_timeout' in payload: + timeout = payload['suggested_timeout'] / 800 + timeout = min(max(timeout, contact.get('timeout', 0) or 0, 15.0), 60.0) + prefix = (contact.get('public_key') or '')[:12] + event = await self.mc.wait_for_event( + EventType.LOGIN_SUCCESS, + attribute_filters={'pubkey_prefix': prefix}, + timeout=timeout, + ) + if event is None: + return { + 'success': False, + 'error': 'No response — repeater unreachable or wrong password', + 'timeout': True, + } + ev_payload = getattr(event, 'payload', None) or {} + return { + 'success': True, + 'is_admin': bool(ev_payload.get('is_admin')), + 'permissions': ev_payload.get('permissions'), + 'name': contact.get('adv_name', ''), + } + + def get_repeater_session(self, public_key: str) -> Optional[Dict]: + """Return the in-memory login session for a repeater, if any.""" + return self._repeater_sessions.get((public_key or '').lower()) def repeater_logout(self, name_or_key: str) -> Dict: """Log out of a repeater.""" @@ -3086,27 +3165,83 @@ class DeviceManager: contact = self.resolve_contact(name_or_key) if not contact: return {'success': False, 'error': f"Contact not found: {name_or_key}"} + coro = self.mc.commands.send_logout(contact) + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} try: - self.execute(self.mc.commands.send_logout(contact), timeout=10) + self.execute(coro, timeout=15) + self._repeater_sessions.pop((contact.get('public_key') or '').lower(), None) return {'success': True, 'message': f'Logged out of {contact.get("adv_name", name_or_key)}'} except Exception as e: logger.error(f"Repeater logout failed: {e}") return {'success': False, 'error': str(e)} + finally: + self._repeater_lock.release() def repeater_cmd(self, name_or_key: str, cmd: str) -> Dict: - """Send a command to a repeater.""" + """Send a command to a repeater (fire-and-forget; reply arrives as a DM).""" if not self.is_connected: return {'success': False, 'error': 'Device not connected'} contact = self.resolve_contact(name_or_key) if not contact: return {'success': False, 'error': f"Contact not found: {name_or_key}"} + coro = self.mc.commands.send_cmd(contact, cmd) + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} try: - res = self.execute(self.mc.commands.send_cmd(contact, cmd), timeout=10) + self.execute(coro, timeout=15) msg = f'Command sent to {contact.get("adv_name", name_or_key)}: {cmd}' return {'success': True, 'message': msg} except Exception as e: logger.error(f"Repeater cmd failed: {e}") return {'success': False, 'error': str(e)} + finally: + self._repeater_lock.release() + + def repeater_cmd_wait(self, name_or_key: str, cmd: str, timeout: float = 45.0) -> Dict: + """Send a CLI command to a repeater and wait for its text reply. + + The reply arrives asynchronously as a CONTACT_MSG_RECV with + txt_type=1 and carries no protocol-level correlation, so we rely + on serialization (repeater lock = single command in flight) plus + a sender-prefix match in _on_dm_received. + """ + if not self.is_connected: + return {'success': False, 'error': 'Device not connected'} + contact = self.resolve_contact(name_or_key) + if not contact: + return {'success': False, 'error': f"Contact not found: {name_or_key}"} + coro = self.mc.commands.send_cmd(contact, cmd) + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} + try: + prefix = (contact.get('public_key') or '')[:12].lower() + waiter = {'prefix': prefix, 'event': threading.Event(), 'reply': None} + self._cli_waiter = waiter + started = time.time() + + res = self.execute(coro, timeout=15) + wait_s = 30.0 + payload = getattr(res, 'payload', None) or {} + if isinstance(payload, dict) and 'suggested_timeout' in payload: + wait_s = payload['suggested_timeout'] / 800 + wait_s = min(max(wait_s, 10.0), float(timeout)) + + if waiter['event'].wait(timeout=wait_s): + elapsed_ms = int((time.time() - started) * 1000) + return {'success': True, 'reply': waiter['reply'] or '', 'elapsed_ms': elapsed_ms} + return {'success': False, + 'error': f'No reply from repeater within {wait_s:.0f}s', + 'timeout': True} + except Exception as e: + logger.error(f"repeater_cmd_wait failed: {e}") + return {'success': False, 'error': str(e)} + finally: + self._cli_waiter = None + self._repeater_lock.release() def repeater_req_status(self, name_or_key: str) -> Dict: """Request status from a repeater.""" @@ -3117,13 +3252,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_status_sync(contact, contact_timeout, min_timeout=15), - timeout=120 + timeout=120, + error_label='status', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No status response (timeout)'} except Exception as e: logger.error(f"req_status failed: {e}") return {'success': False, 'error': str(e)} @@ -3137,13 +3270,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_regions_sync(contact, contact_timeout), - timeout=120 + timeout=120, + error_label='regions', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No regions response (timeout)'} except Exception as e: logger.error(f"req_regions failed: {e}") return {'success': False, 'error': str(e)} @@ -3157,13 +3288,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_owner_sync(contact, contact_timeout), - timeout=120 + timeout=120, + error_label='owner', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No owner response (timeout)'} except Exception as e: logger.error(f"req_owner failed: {e}") return {'success': False, 'error': str(e)} @@ -3177,13 +3306,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_acl_sync(contact, contact_timeout, min_timeout=15), - timeout=120 + timeout=120, + error_label='ACL', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No ACL response (timeout)'} except Exception as e: logger.error(f"req_acl failed: {e}") return {'success': False, 'error': str(e)} @@ -3197,13 +3324,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_basic_sync(contact, contact_timeout), - timeout=120 + timeout=120, + error_label='clock', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No clock response (timeout)'} except Exception as e: logger.error(f"req_clock failed: {e}") return {'success': False, 'error': str(e)} @@ -3217,17 +3342,33 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.req_mma_sync(contact, from_secs, to_secs, contact_timeout, min_timeout=15), - timeout=120 + timeout=120, + error_label='MMA', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No MMA response (timeout)'} except Exception as e: logger.error(f"req_mma failed: {e}") return {'success': False, 'error': str(e)} + def repeater_req_telemetry(self, name_or_key: str) -> Dict: + """Request telemetry (Cayenne LPP) from a repeater.""" + if not self.is_connected: + return {'success': False, 'error': 'Device not connected'} + contact = self.resolve_contact(name_or_key) + if not contact: + return {'success': False, 'error': f"Contact not found: {name_or_key}"} + try: + contact_timeout = contact.get('timeout', 0) or 0 + return self._locked_repeater_execute( + self.mc.commands.req_telemetry_sync(contact, contact_timeout, min_timeout=15), + timeout=120, + error_label='telemetry', + ) + except Exception as e: + logger.error(f"req_telemetry failed: {e}") + return {'success': False, 'error': str(e)} + def repeater_req_neighbours(self, name_or_key: str) -> Dict: """Request neighbours from a repeater.""" if not self.is_connected: @@ -3237,13 +3378,11 @@ class DeviceManager: return {'success': False, 'error': f"Contact not found: {name_or_key}"} try: contact_timeout = contact.get('timeout', 0) or 0 - result = self.execute( + return self._locked_repeater_execute( self.mc.commands.fetch_all_neighbours(contact, timeout=contact_timeout, min_timeout=15), - timeout=120 + timeout=120, + error_label='neighbours', ) - if result is not None: - return {'success': True, 'data': result} - return {'success': False, 'error': 'No neighbours response (timeout)'} except Exception as e: logger.error(f"req_neighbours failed: {e}") return {'success': False, 'error': str(e)} diff --git a/app/main.py b/app/main.py index 8950ff8..dff6819 100644 --- a/app/main.py +++ b/app/main.py @@ -675,7 +675,8 @@ def _execute_console_command(args: list) -> str: password = ' '.join(args[2:]) result = device_manager.repeater_login(name, password) if result.get('success'): - return result.get('message', 'OK') + role = 'admin' if result.get('is_admin') else 'guest' + return f"Logged into {result.get('name') or name} as {role}" return f"Error: {result.get('error')}" elif cmd == 'login': diff --git a/app/routes/api.py b/app/routes/api.py index 18ad7bc..ca6c435 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -181,6 +181,27 @@ def invalidate_contacts_cache(): logger.debug("Contacts cache invalidated") +def _format_path_display(out_path_len, out_path, out_path_hash_mode): + """Format device out_path fields as 'Flood' / 'Direct' / 'AB→CD12…'. + + meshcore lib 2.x: out_path_len already holds the hop count (6 LSB) and + the hash-size mode is stored separately in out_path_hash_mode. The raw + out_path is truncated to the meaningful bytes (firmware buffer may have + trailing garbage). + """ + if out_path_len and out_path_len > 0 and out_path: + hash_mode = out_path_hash_mode or 0 + hash_size = max(1, hash_mode + 1) if hash_mode >= 0 else 1 + chunk = hash_size * 2 + meaningful_hex = out_path[:out_path_len * chunk] + hops = [meaningful_hex[i:i + chunk].upper() + for i in range(0, len(meaningful_hex), chunk)] + return '→'.join(hops) if hops else out_path + if out_path_len == 0: + return 'Direct' + return 'Flood' + + # ============================================================================= # Protected Contacts Management # ============================================================================= @@ -3018,25 +3039,10 @@ def get_contacts_detailed_api(): blocked_keys = db.get_blocked_keys() if db else set() for public_key, details in contacts_detailed.items(): - # Compute path display string. - # meshcore lib 2.x: out_path_len already holds the hop count (6 LSB) - # and the hash-size mode is stored separately in out_path_hash_mode. out_path_len = details.get('out_path_len', -1) out_path_raw = details.get('out_path', '') out_path_hash_mode = details.get('out_path_hash_mode', 0) - if out_path_len > 0 and out_path_raw: - hop_count = out_path_len - hash_size = max(1, out_path_hash_mode + 1) if out_path_hash_mode >= 0 else 1 - chunk = hash_size * 2 - # Truncate to meaningful bytes (firmware buffer may have trailing garbage) - meaningful_hex = out_path_raw[:hop_count * chunk] - # Format as HEX→HEX→HEX (each hop is hash_size*2 hex chars) - hops = [meaningful_hex[i:i+chunk].upper() for i in range(0, len(meaningful_hex), chunk)] - path_or_mode = '→'.join(hops) if hops else out_path_raw - elif out_path_len == 0: - path_or_mode = 'Direct' - else: - path_or_mode = 'Flood' + path_or_mode = _format_path_display(out_path_len, out_path_raw, out_path_hash_mode) contact = { # All original fields from contact_info @@ -5666,3 +5672,653 @@ def get_logs_api(): except Exception as e: logger.error(f"Error getting logs: {e}") return jsonify({'success': False, 'error': str(e)}), 500 + + +# ============================================================================= +# My Repeaters API (repeater administration panel) +# ============================================================================= + +_PUBKEY_RE = re.compile(r'^[0-9a-f]{64}$') + + +def _normalize_repeater_key(public_key): + """Lowercase and validate a full 64-hex repeater public key.""" + pk = (public_key or '').strip().lower() + return pk if _PUBKEY_RE.match(pk) else None + + +def _repeater_result_status(result): + """Map a device_manager repeater result to an HTTP status code.""" + if result.get('success'): + return 200 + if result.get('busy'): + return 429 + if result.get('timeout'): + return 504 + error = (result.get('error') or '').lower() + if 'not connected' in error: + return 503 + if 'not found' in error: + return 404 + return 500 + + +@api_bp.route('/repeaters', methods=['GET']) +def list_my_repeaters(): + """List saved repeaters merged with device contact truth.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + rows = db.get_repeaters() + device_by_key = {} + if rows: + force_refresh = request.args.get('refresh', 'false').lower() == 'true' + success, contacts_detailed, _error = get_contacts_detailed_cached(force_refresh) + if success and contacts_detailed: + device_by_key = {k.lower(): v for k, v in contacts_detailed.items()} + + repeaters = [_merged_repeater_entry(row, device_by_key) for row in rows] + return jsonify({'success': True, 'repeaters': repeaters}), 200 + except Exception as e: + logger.error(f"Error listing repeaters: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _merged_repeater_entry(row, device_by_key): + """Merge a repeaters DB row with device contact truth for API output.""" + pk = row['public_key'].lower() + details = device_by_key.get(pk) + entry = { + 'public_key': pk, + 'password_set': bool(row.get('password')), + 'added_at': row.get('added_at'), + 'last_login_at': row.get('last_login_at'), + 'last_login_role': row.get('last_login_role'), + 'on_device': details is not None, + 'name': '', + 'out_path_len': None, + 'out_path': '', + 'out_path_hash_mode': 0, + 'path_or_mode': '', + 'adv_lat': None, + 'adv_lon': None, + 'last_advert': None, + } + if details: + entry.update({ + 'name': details.get('adv_name', ''), + 'out_path_len': details.get('out_path_len', -1), + 'out_path': details.get('out_path', ''), + 'out_path_hash_mode': details.get('out_path_hash_mode', 0), + 'path_or_mode': _format_path_display( + details.get('out_path_len', -1), + details.get('out_path', ''), + details.get('out_path_hash_mode', 0)), + 'adv_lat': details.get('adv_lat'), + 'adv_lon': details.get('adv_lon'), + 'last_advert': details.get('last_advert'), + }) + return entry + + +def _repeater_session_payload(dm, pk): + """Session dict for API output ({'logged_in': False} when absent).""" + session = dm.get_repeater_session(pk) if dm else None + if not session: + return {'logged_in': False} + return { + 'logged_in': True, + 'is_admin': session.get('is_admin', False), + 'permissions': session.get('permissions'), + 'logged_in_at': session.get('logged_in_at'), + } + + +@api_bp.route('/repeaters/', methods=['GET']) +def get_my_repeater(public_key): + """Single saved repeater merged with device truth + login session state.""" + 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 + try: + row = db.get_repeater(pk) + if not row: + return jsonify({'success': False, 'error': 'Repeater not in list'}), 404 + device_by_key = {} + success, contacts_detailed, _error = get_contacts_detailed_cached() + if success and contacts_detailed: + device_by_key = {k.lower(): v for k, v in contacts_detailed.items()} + return jsonify({ + 'success': True, + 'repeater': _merged_repeater_entry(row, device_by_key), + 'session': _repeater_session_payload(_get_dm(), pk), + }), 200 + except Exception as e: + logger.error(f"Error getting repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//session', methods=['GET']) +def get_my_repeater_session(public_key): + """Login session state for a repeater (in-memory; cleared on app restart).""" + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + return jsonify({'success': True, **_repeater_session_payload(_get_dm(), pk)}), 200 + + +@api_bp.route('/repeaters//logout', methods=['POST']) +def logout_my_repeater(public_key): + """Log out of a repeater and drop the in-memory session.""" + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + try: + result = dm.repeater_logout(pk) + if result.get('success'): + return jsonify({'success': True}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Logout failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error logging out of repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _require_repeater_login(dm, pk): + """Return (None) if a session exists, else an (json, status) error tuple. + + A remote request only succeeds if the repeater has us in its ACL, which + requires a prior login. Failing fast here avoids a pointless ~2 min + timeout when the panel is opened without a live session. + """ + if not dm.get_repeater_session(pk): + return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401 + return None + + +def _require_repeater_admin(dm, pk): + """Like _require_repeater_login, but also demands an admin session. + + The firmware silently drops text CLI commands from non-admin clients + (which would surface as a pointless timeout), so guest sessions are + rejected up front. + """ + session = dm.get_repeater_session(pk) + if not session: + return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401 + if not session.get('is_admin'): + return jsonify({'success': False, 'error': 'Admin login required'}), 403 + return None + + +@api_bp.route('/repeaters//status', methods=['GET']) +def repeater_status(public_key): + """Binary status request to a repeater (battery, radio and packet stats).""" + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + login_error = _require_repeater_login(dm, pk) + if login_error: + return login_error + try: + result = dm.repeater_req_status(pk) + if result.get('success'): + return jsonify({'success': True, 'data': result['data']}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'No status response')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error getting repeater status: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//telemetry', methods=['GET']) +def repeater_telemetry(public_key): + """Telemetry request to a repeater (Cayenne LPP entries, all channels).""" + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + login_error = _require_repeater_login(dm, pk) + if login_error: + return login_error + try: + result = dm.repeater_req_telemetry(pk) + if result.get('success'): + return jsonify({'success': True, 'lpp': result['data']}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'No telemetry response')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error getting repeater telemetry: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//cli', methods=['POST']) +def repeater_cli(public_key): + """Send a CLI text command to a repeater and return its reply. Admin-only.""" + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + auth_error = _require_repeater_admin(dm, pk) + if auth_error: + return auth_error + data = request.get_json(silent=True) or {} + command = (data.get('command') or '').strip() + if not command: + return jsonify({'success': False, 'error': 'Missing command'}), 400 + try: + result = dm.repeater_cmd_wait(pk, command) + if result.get('success'): + return jsonify({'success': True, + 'output': result.get('reply', ''), + 'elapsed_ms': result.get('elapsed_ms')}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Command failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error running repeater CLI command: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +# Settings section → ordered CLI-readable fields (`get `), mirrored by +# SETTINGS_SECTIONS in repeater-manage.js. The admin password is write-only +# (`password `, no get), so it is POST-able but never part of a GET batch. +_REPEATER_SETTINGS_FIELDS = { + 'basic': ('name', 'guest.password'), + 'radio': ('radio', 'tx', 'radio.rxgain'), + 'location': ('lat', 'lon'), + 'features': ('repeat', 'allow.read.only', 'multi.acks'), + 'network': ('loop.detect', 'dutycycle'), + 'advert': ('advert.interval', 'flood.advert.interval', 'flood.max'), + 'operator': ('owner.info',), + 'advanced': ('path.hash.mode', 'txdelay', 'direct.txdelay', 'int.thresh', + 'agc.reset.interval'), +} + +_REPEATER_SETTINGS_WRITABLE = frozenset( + field for fields in _REPEATER_SETTINGS_FIELDS.values() for field in fields +) | {'password'} + +# Per CLI round-trip; a lost packet should not stall the batch for long. +_SETTINGS_FIELD_TIMEOUT = 30.0 + + +def _settings_batch_fatal(result): + """True when a repeater_cmd_wait failure will also hit every remaining field.""" + error = (result.get('error') or '').lower() + return bool(result.get('busy')) or 'not connected' in error + + +@api_bp.route('/repeaters//settings', methods=['GET']) +def repeater_settings_get(public_key): + """Read one settings section from a repeater as a sequential `get` batch. + + Every field is a full mesh round-trip, so sections are fetched lazily + by the UI. One failed field only marks that field ({errors}) — the + rest of the section still loads. Admin-gated like all text CLI traffic. + """ + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + auth_error = _require_repeater_admin(dm, pk) + if auth_error: + return auth_error + section = request.args.get('section', '') + fields = _REPEATER_SETTINGS_FIELDS.get(section) + if fields is None: + return jsonify({'success': False, 'error': f'Unknown section: {section}'}), 400 + + values = {} + errors = {} + abort_error = None + try: + for field in fields: + if abort_error: + errors[field] = abort_error + continue + result = dm.repeater_cmd_wait(pk, f'get {field}', + timeout=_SETTINGS_FIELD_TIMEOUT) + if not result.get('success'): + errors[field] = result.get('error') or 'Request failed' + if _settings_batch_fatal(result): + abort_error = errors[field] + continue + reply = (result.get('reply') or '').strip() + if reply.startswith('>'): + values[field] = reply[1:].strip() + else: + errors[field] = f'Unexpected reply: {reply}' if reply else 'Empty reply' + return jsonify({'success': True, 'section': section, + 'values': values, 'errors': errors}), 200 + except Exception as e: + logger.error(f"Error reading repeater settings: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//settings', methods=['POST']) +def repeater_settings_post(public_key): + """Apply changed settings to a repeater as a sequential `set` batch. + + Body: {'values': {field: value}} — dirty fields only. Per-field result + status: 'ok' | 'failed' | 'reboot_required', classified from the CLI + reply text (`ok`/`password now` = ok, mention of reboot = stored but + applied after a reboot, anything else = failed). + """ + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + auth_error = _require_repeater_admin(dm, pk) + if auth_error: + return auth_error + data = request.get_json(silent=True) or {} + values = data.get('values') + if not isinstance(values, dict) or not values: + return jsonify({'success': False, 'error': 'No values to apply'}), 400 + unknown = sorted(set(values) - _REPEATER_SETTINGS_WRITABLE) + if unknown: + return jsonify({'success': False, + 'error': f"Unknown fields: {', '.join(unknown)}"}), 400 + + results = {} + abort_error = None + try: + for field, raw in values.items(): + if abort_error: + results[field] = {'status': 'failed', 'error': abort_error} + continue + value = str('' if raw is None else raw) + value = value.replace('\r', ' ').replace('\n', ' ').strip() + if not value and field != 'owner.info': + results[field] = {'status': 'failed', 'error': 'Empty value not allowed'} + continue + cmd = f'password {value}' if field == 'password' else f'set {field} {value}' + result = dm.repeater_cmd_wait(pk, cmd, timeout=_SETTINGS_FIELD_TIMEOUT) + if not result.get('success'): + results[field] = {'status': 'failed', + 'error': result.get('error') or 'Request failed'} + if _settings_batch_fatal(result): + abort_error = results[field]['error'] + continue + reply = (result.get('reply') or '').strip() + low = reply.lower() + if 'reboot' in low: + results[field] = {'status': 'reboot_required', 'reply': reply} + elif low.startswith('ok') or low.startswith('password now'): + results[field] = {'status': 'ok', 'reply': reply} + else: + results[field] = {'status': 'failed', 'reply': reply, + 'error': reply or 'Empty reply'} + return jsonify({'success': True, 'results': results}), 200 + except Exception as e: + logger.error(f"Error applying repeater settings: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +# Action key → CLI command. `advert` alone floods the whole mesh; +# `advert.zerohop` reaches direct neighbours only. `reboot` never +# replies: the firmware restarts immediately without building one. +_REPEATER_ACTIONS = { + 'zerohop_advert': {'cmd': 'advert.zerohop'}, + 'flood_advert': {'cmd': 'advert'}, + 'clock_sync': {'cmd': 'clock sync'}, + 'reboot': {'cmd': 'reboot', 'no_reply': True}, +} + + +@api_bp.route('/repeaters//action', methods=['POST']) +def repeater_action(public_key): + """Run a one-shot action on a repeater (adverts, clock sync, reboot). + + Body: {'action': key}. Replies are surfaced verbatim with an `ok` + flag (reply starts with OK). For `reboot`, a clean send followed by + silence is reported as success — the firmware never replies to it. + """ + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + auth_error = _require_repeater_admin(dm, pk) + if auth_error: + return auth_error + data = request.get_json(silent=True) or {} + action = data.get('action') or '' + spec = _REPEATER_ACTIONS.get(action) + if not spec: + return jsonify({'success': False, 'error': f'Unknown action: {action}'}), 400 + try: + timeout = 15.0 if spec.get('no_reply') else 45.0 + result = dm.repeater_cmd_wait(pk, spec['cmd'], timeout=timeout) + if result.get('success'): + reply = (result.get('reply') or '').strip() + return jsonify({'success': True, 'reply': reply, + 'ok': reply.lower().startswith('ok'), + 'elapsed_ms': result.get('elapsed_ms')}), 200 + if spec.get('no_reply') and result.get('timeout'): + return jsonify({'success': True, 'ok': True, 'no_reply': True, + 'reply': 'Reboot command sent — the repeater should be ' + 'restarting (no reply is expected)'}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Action failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error running repeater action: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//neighbours', methods=['GET']) +def repeater_neighbours(public_key): + """Zero-hop neighbours of a repeater, enriched with contact names/coords. + + Neighbour entries carry only a 4-byte pubkey prefix; names and GPS + positions are resolved by prefix-matching device contacts (with the + DB contact cache as fallback), so unknown repeaters stay prefix-only. + """ + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + login_error = _require_repeater_login(dm, pk) + if login_error: + return login_error + try: + result = dm.repeater_req_neighbours(pk) + if not result.get('success'): + return jsonify({'success': False, + 'error': result.get('error', 'No neighbours response')}), _repeater_result_status(result) + + data = result['data'] or {} + db = _get_db() + entries = [] + for n in data.get('neighbours', []): + prefix = (n.get('pubkey') or '').lower() + entry = { + 'pubkey_prefix': prefix, + 'secs_ago': n.get('secs_ago'), + 'snr': n.get('snr'), + 'name': '', + 'lat': None, + 'lon': None, + } + contact = dm.mc.get_contact_by_key_prefix(prefix) if dm.mc else None + if contact: + entry['name'] = contact.get('adv_name', '') or '' + lat = contact.get('adv_lat') + lon = contact.get('adv_lon') + if lat and lon and (lat != 0 or lon != 0): + entry['lat'] = lat + entry['lon'] = lon + elif db: + db_contact = db.get_contact_by_prefix(prefix) + if db_contact: + entry['name'] = db_contact.get('name', '') or '' + lat = db_contact.get('adv_lat') + lon = db_contact.get('adv_lon') + if lat and lon and (lat != 0 or lon != 0): + entry['lat'] = lat + entry['lon'] = lon + entries.append(entry) + + return jsonify({ + 'success': True, + 'total': data.get('neighbours_count', len(entries)), + 'fetched': data.get('results_count', len(entries)), + 'entries': entries, + }), 200 + except Exception as e: + logger.error(f"Error getting repeater neighbours: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//clock', methods=['GET']) +def repeater_clock(public_key): + """Current clock of a repeater (unix seconds + formatted).""" + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + login_error = _require_repeater_login(dm, pk) + if login_error: + return login_error + try: + result = dm.repeater_req_clock(pk) + if not result.get('success'): + return jsonify({'success': False, + 'error': result.get('error', 'No clock response')}), _repeater_result_status(result) + hex_data = (result['data'] or {}).get('data', '') + timestamp = int.from_bytes(bytes.fromhex(hex_data[0:8]), byteorder='little', signed=False) + return jsonify({'success': True, 'timestamp': timestamp}), 200 + except Exception as e: + logger.error(f"Error getting repeater clock: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters', methods=['POST']) +def add_my_repeater(): + """Add a device repeater contact to the My Repeaters list.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + data = request.get_json(silent=True) or {} + pk = _normalize_repeater_key(data.get('public_key')) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key (expected 64 hex chars)'}), 400 + try: + if not db.add_repeater(pk): + return jsonify({'success': False, 'error': 'Repeater already added'}), 409 + return jsonify({'success': True}), 201 + except Exception as e: + logger.error(f"Error adding repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters/', methods=['PUT']) +def update_my_repeater(public_key): + """Set or clear the saved password for a repeater ('' clears).""" + 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 + data = request.get_json(silent=True) or {} + if 'password' not in data or not isinstance(data['password'], str): + return jsonify({'success': False, 'error': 'Missing password field'}), 400 + try: + if not db.set_repeater_password(pk, data['password']): + return jsonify({'success': False, 'error': 'Repeater not in list'}), 404 + return jsonify({'success': True, 'password_set': bool(data['password'])}), 200 + except Exception as e: + logger.error(f"Error updating repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters/', methods=['DELETE']) +def delete_my_repeater(public_key): + """Remove a repeater from the list (device contact is untouched).""" + 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 + try: + if not db.delete_repeater(pk): + return jsonify({'success': False, 'error': 'Repeater not in list'}), 404 + return jsonify({'success': True}), 200 + except Exception as e: + logger.error(f"Error deleting repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/repeaters//login', methods=['POST']) +def login_my_repeater(public_key): + """Log into a repeater using the provided or saved password. + + Body: {password?: str, save?: bool}. When a password is provided with + save=true it is persisted (even if the login then times out — the + repeater may simply be offline while the password is correct). + """ + db = _get_db() + dm = _get_dm() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 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 + + data = request.get_json(silent=True) or {} + provided = data.get('password') + if provided is not None and not isinstance(provided, str): + return jsonify({'success': False, 'error': 'Invalid password'}), 400 + password = provided if provided else row.get('password', '') + if not password: + return jsonify({'success': False, 'error': 'No password set for this repeater', + 'need_password': True}), 400 + try: + if provided and data.get('save'): + db.set_repeater_password(pk, provided) + + result = dm.repeater_login(pk, password) + if result.get('success'): + role = 'admin' if result.get('is_admin') else 'guest' + db.update_repeater_login(pk, role) + return jsonify({ + 'success': True, + 'is_admin': result.get('is_admin', False), + 'permissions': result.get('permissions'), + 'name': result.get('name', ''), + }), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Login failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error logging into repeater: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/app/routes/views.py b/app/routes/views.py index 290e6cb..76e309e 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -103,6 +103,24 @@ def console(): ) +@views_bp.route('/repeaters') +def repeaters(): + """My Repeaters - repeater administration panel (list + login).""" + return render_template( + 'repeaters.html', + device_name=runtime_config.get_device_name() + ) + + +@views_bp.route('/repeaters/manage') +def repeater_manage(): + """Repeater Management panel for one repeater (?pubkey=<64 hex>).""" + return render_template( + 'repeater-manage.html', + device_name=runtime_config.get_device_name() + ) + + @views_bp.route('/logs') def logs(): """System log viewer - real-time log streaming with filters.""" diff --git a/app/schema.sql b/app/schema.sql index 4c5d775..cee1b53 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -223,6 +223,16 @@ CREATE TABLE IF NOT EXISTS app_settings ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- My Repeaters: repeaters selected for administration, with saved login. +-- No FK to contacts: a saved password must survive contact cleanup/re-add. +CREATE TABLE IF NOT EXISTS repeaters ( + public_key TEXT PRIMARY KEY, -- hex, lowercase (64 chars) + password TEXT NOT NULL DEFAULT '', -- plaintext (single-user LAN app) + added_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT, + last_login_role TEXT -- 'admin' | 'guest' +); + -- ============================================================ -- Indexes -- ============================================================ diff --git a/app/static/css/style.css b/app/static/css/style.css index 539d3d6..0caa26e 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1631,6 +1631,12 @@ main { color: white; } +/* My Repeaters FAB button (REP green) */ +.fab-repeaters { + background: linear-gradient(135deg, #198754 0%, #146c43 100%); + color: white; +} + /* Filter bar overlay - slides down from top of chat area */ .filter-bar { position: absolute; @@ -1876,7 +1882,8 @@ emoji-picker { #dmModal .modal-dialog.modal-fullscreen, #contactsModal .modal-dialog.modal-fullscreen, #logsModal .modal-dialog.modal-fullscreen, -#consoleModal .modal-dialog.modal-fullscreen { +#consoleModal .modal-dialog.modal-fullscreen, +#repeatersModal .modal-dialog.modal-fullscreen { margin: 0 !important; width: 100vw !important; max-width: 100vw !important; @@ -1887,7 +1894,8 @@ emoji-picker { #dmModal .modal-content, #contactsModal .modal-content, #logsModal .modal-content, -#consoleModal .modal-content { +#consoleModal .modal-content, +#repeatersModal .modal-content { border: none !important; border-radius: 0 !important; height: 100vh !important; @@ -1896,7 +1904,8 @@ emoji-picker { #dmModal .modal-body, #contactsModal .modal-body, #logsModal .modal-body, -#consoleModal .modal-body { +#consoleModal .modal-body, +#repeatersModal .modal-body { overflow: hidden !important; } diff --git a/app/static/js/app.js b/app/static/js/app.js index bb33f4d..9530d37 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -6029,6 +6029,7 @@ const ITEM_PLACEMENT_DEFS = { floodadvert: { fab: '#fab-floodadvert', menu: '#floodadvBtn' }, map: { fab: '#fab-map', menu: '#mapBtn' }, console: { fab: '#fab-console', menu: '#consoleBtn' }, + repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' }, deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' }, syslog: { fab: '#fab-syslog', menu: '#logsBtn' }, }; @@ -6036,7 +6037,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', deviceinfo: 'menu', syslog: 'menu' + console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu' }; function readItemPlacements() { diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js new file mode 100644 index 0000000..7288e60 --- /dev/null +++ b/app/static/js/repeater-manage.js @@ -0,0 +1,1756 @@ +// Repeater Management panel (one repeater, after login) +// Loaded as /repeaters/manage?pubkey=<64 hex> inside the My Repeaters iframe. + +// ================================================================ +// UI settings + toast (same behavior as repeaters.js) +// ================================================================ + +const RPT_UI_SETTINGS_DEFAULTS = { + toast_timeout_sec: 2, + toast_no_autoclose: false, + toast_position: 'top-left' +}; + +const RPT_TOAST_POSITION_CLASSES = { + 'top-left': ['top-0', 'start-0'], + 'top-right': ['top-0', 'end-0'], + 'bottom-left': ['bottom-0', 'start-0'], + 'bottom-right': ['bottom-0', 'end-0'], + 'center': ['top-50', 'start-50', 'translate-middle'] +}; +const RPT_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; + +window.uiSettingsCache = window.uiSettingsCache || { ...RPT_UI_SETTINGS_DEFAULTS }; + +function applyToastPosition(position) { + const classes = RPT_TOAST_POSITION_CLASSES[position] || RPT_TOAST_POSITION_CLASSES['top-left']; + document.querySelectorAll('[data-toast-container]').forEach(el => { + RPT_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c)); + classes.forEach(c => el.classList.add(c)); + }); +} + +async function loadUiSettings() { + try { + const resp = await fetch('/api/ui/settings'); + if (resp.ok) { + const data = await resp.json(); + window.uiSettingsCache = { ...RPT_UI_SETTINGS_DEFAULTS, ...data }; + applyToastPosition(window.uiSettingsCache.toast_position); + } + } catch (e) { + console.error('Failed to load UI settings:', e); + } +} + +function showNotification(message, type = 'info') { + const toastEl = document.getElementById('notificationToast'); + if (!toastEl) return; + + const toastBody = toastEl.querySelector('.toast-body'); + if (toastBody) { + toastBody.textContent = message; + } + + const toastHeader = toastEl.querySelector('.toast-header'); + if (toastHeader) { + toastHeader.className = 'toast-header'; + if (type === 'success') { + toastHeader.classList.add('bg-success', 'text-white'); + } else if (type === 'danger') { + toastHeader.classList.add('bg-danger', 'text-white'); + } else if (type === 'warning') { + toastHeader.classList.add('bg-warning'); + } + } + + const cfg = window.uiSettingsCache || {}; + const noAutoclose = !!cfg.toast_no_autoclose; + const timeoutSec = parseFloat(cfg.toast_timeout_sec); + const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000; + + const toast = new bootstrap.Toast(toastEl, { + autohide: !noAutoclose, + delay: delay + }); + toast.show(); +} + +function esc(s) { + return String(s ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +// ================================================================ +// Tools configuration +// ================================================================ + +const TOOLS = [ + { key: 'status', icon: 'bi-bar-chart-line', title: 'Status', + desc: 'Battery, radio and packet statistics', adminOnly: false }, + { key: 'telemetry', icon: 'bi-activity', title: 'Telemetry', + desc: 'Sensor channels (Cayenne LPP)', adminOnly: false }, + { key: 'neighbors', icon: 'bi-people', title: 'Neighbors', + desc: 'Zero-hop repeaters heard', adminOnly: false }, + { key: 'cli', icon: 'bi-terminal', title: 'CLI', + desc: 'Send text commands to the repeater', adminOnly: true }, + { key: 'settings', icon: 'bi-gear', title: 'Settings', + desc: 'Configure repeater parameters', adminOnly: true }, + { key: 'actions', icon: 'bi-lightning', title: 'Actions', + desc: 'Advert, clock sync, reboot', adminOnly: true }, +]; + +// ================================================================ +// State +// ================================================================ + +let _pubkey = null; +let _repeater = null; // merged entry from GET /api/repeaters/ +let _session = null; // {logged_in, is_admin, ...} +let _passwordModal = null; + +// ================================================================ +// State screens +// ================================================================ + +function showLoading(text) { + document.getElementById('loadingState').style.display = ''; + document.getElementById('loadingText').textContent = text || 'Loading…'; + document.getElementById('errorState').style.display = 'none'; + document.getElementById('panelContent').style.display = 'none'; +} + +function showError(text) { + document.getElementById('loadingState').style.display = 'none'; + document.getElementById('errorState').style.display = ''; + document.getElementById('errorText').textContent = text || 'Something went wrong.'; + document.getElementById('panelContent').style.display = 'none'; +} + +function showPanel() { + document.getElementById('loadingState').style.display = 'none'; + document.getElementById('errorState').style.display = 'none'; + document.getElementById('panelContent').style.display = ''; + document.getElementById('logoutBtn').classList.remove('d-none'); + renderHeader(); + renderTools(); + showToolsGrid(); +} + +function goBackToList() { + window.location.href = '/repeaters'; +} + +// ================================================================ +// Header + tools rendering +// ================================================================ + +function shortPubkey(pk) { + return `${pk.substring(0, 12)}…${pk.substring(pk.length - 8)}`; +} + +function renderHeader() { + const r = _repeater; + document.getElementById('rptName').textContent = r.name || r.public_key.substring(0, 12); + document.getElementById('rptPubkey').textContent = `<${shortPubkey(r.public_key)}>`; + document.getElementById('rptPath').textContent = r.path_or_mode || '—'; + + const loc = (r.adv_lat != null && r.adv_lon != null && (r.adv_lat !== 0 || r.adv_lon !== 0)) + ? `${r.adv_lat.toFixed(4)}, ${r.adv_lon.toFixed(4)}` + : '—'; + document.getElementById('rptLocation').textContent = loc; + + const badge = document.getElementById('roleBadge'); + if (_session && _session.logged_in) { + const admin = !!_session.is_admin; + badge.textContent = admin ? 'ADMIN' : 'GUEST'; + badge.className = 'badge ' + (admin ? 'bg-success' : 'bg-secondary'); + } else { + badge.textContent = ''; + badge.className = 'badge'; + } +} + +function renderTools() { + const row = document.getElementById('toolsRow'); + row.innerHTML = ''; + const isAdmin = !!(_session && _session.is_admin); + + TOOLS.forEach(tool => { + const locked = tool.adminOnly && !isAdmin; + const col = document.createElement('div'); + col.className = 'col-12 col-sm-6 col-lg-4'; + col.innerHTML = ` +
+
+
+
${esc(tool.title)}${locked ? ' ' : ''}
+

${esc(tool.desc)}

+
+ +
+ `; + const tile = col.querySelector('.tool-tile'); + tile.addEventListener('click', () => { + if (locked) { + showNotification('Admin login required for this tool', 'warning'); + return; + } + openToolPane(tool); + }); + row.appendChild(col); + }); +} + +// ================================================================ +// Tool panes +// ================================================================ + +function showToolsGrid() { + document.getElementById('toolsGrid').style.display = ''; + document.getElementById('toolPane').style.display = 'none'; +} + +function openToolPane(tool) { + document.getElementById('toolsGrid').style.display = 'none'; + const pane = document.getElementById('toolPane'); + pane.style.display = ''; + + const icon = document.getElementById('paneIcon'); + icon.className = `tool-icon ${tool.key}`; + icon.style.width = '32px'; + icon.style.height = '32px'; + icon.style.fontSize = '1rem'; + icon.innerHTML = ``; + document.getElementById('paneTitle').textContent = tool.title; + + const body = document.getElementById('paneBody'); + if (tool.key === 'status') { + renderStatusPane(body); + return; + } + if (tool.key === 'telemetry') { + renderTelemetryPane(body); + return; + } + if (tool.key === 'neighbors') { + renderNeighborsPane(body); + return; + } + if (tool.key === 'cli') { + renderCliPane(body); + return; + } + if (tool.key === 'settings') { + renderSettingsPane(body); + return; + } + if (tool.key === 'actions') { + renderActionsPane(body); + return; + } + body.innerHTML = ` +
+ +

The ${esc(tool.title)} tool is coming in a later stage.

+
+ `; +} + +// ================================================================ +// Formatting helpers +// ================================================================ + +function fmtDuration(seconds) { + if (seconds == null || isNaN(seconds)) return '—'; + seconds = Math.floor(seconds); + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts = []; + if (d) parts.push(`${d}d`); + if (h || d) parts.push(`${h}h`); + parts.push(`${m}m`); + return parts.join(' '); +} + +function fmtInt(n) { + if (n == null || isNaN(n)) return '—'; + return Number(n).toLocaleString('en-US'); +} + +function batteryPercent(mv) { + if (mv == null || isNaN(mv)) return null; + // Simple linear LiPo estimate over 3.3–4.2 V. + const pct = ((mv / 1000) - 3.3) / (4.2 - 3.3) * 100; + return Math.max(0, Math.min(100, Math.round(pct))); +} + +// ================================================================ +// Status tool +// ================================================================ + +let _statusUpdatedAt = null; +let _statusTimer = null; + +function renderStatusPane(body) { + body.innerHTML = ` +
+ + +
+
+ `; + body.querySelector('#statusRefreshBtn').addEventListener('click', loadStatus); + loadStatus(); +} + +function setStatusUpdatedLabel() { + const el = document.getElementById('statusUpdated'); + if (!el) return; + if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; } + if (!_statusUpdatedAt) { el.textContent = ''; return; } + const tick = () => { + const label = document.getElementById('statusUpdated'); + if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; } + const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000); + if (secs < 2) label.textContent = 'Updated just now'; + else if (secs < 60) label.textContent = `Updated ${secs}s ago`; + else label.textContent = `Updated ${fmtDuration(secs)} ago`; + }; + tick(); + _statusTimer = setInterval(tick, 5000); +} + +async function loadStatus() { + const container = document.getElementById('statusContainer'); + const btn = document.getElementById('statusRefreshBtn'); + if (!container) return; + if (btn) btn.disabled = true; + container.innerHTML = ` +
+
+

Requesting status from the repeater…

+
+ `; + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/status`); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + if (btn) btn.disabled = false; + + if (!data || !data.success) { + container.innerHTML = ` +
+ +

${esc((data && data.error) || 'Failed to get status')}

+ +
+ `; + const retry = document.getElementById('statusRetryBtn'); + if (retry) retry.addEventListener('click', loadStatus); + return; + } + + renderStatusTable(container, data.data); + _statusUpdatedAt = Date.now(); + setStatusUpdatedLabel(); + loadClock(); +} + +function statusSection(title, rows) { + const body = rows.map(([label, value]) => + `${esc(label)}${value}` + ).join(''); + return ` +
${esc(title)}
+ ${body}
+ `; +} + +function renderStatusTable(container, s) { + const bat = s.bat; + const pct = batteryPercent(bat); + const batStr = bat != null + ? `${pct != null ? pct + '% / ' : ''}${(bat / 1000).toFixed(2)} V` + : '—'; + const util = (s.airtime != null && s.rx_airtime != null && s.uptime) + ? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%' + : '—'; + + const system = statusSection('System Information', [ + ['Battery', batStr], + ['Uptime', fmtDuration(s.uptime)], + ['Clock', ''], + ['Queue length', fmtInt(s.tx_queue_len)], + ['Debug / error events', fmtInt(s.full_evts)], + ]); + const radio = statusSection('Radio Statistics', [ + ['Last RSSI', s.last_rssi != null ? `${s.last_rssi} dBm` : '—'], + ['Last SNR', s.last_snr != null ? `${s.last_snr} dB` : '—'], + ['Noise floor', s.noise_floor != null ? `${s.noise_floor} dBm` : '—'], + ['TX airtime', fmtDuration(s.airtime)], + ['RX airtime', fmtDuration(s.rx_airtime)], + ]); + const packets = statusSection('Packet Statistics', [ + ['Sent', `${fmtInt(s.nb_sent)} (flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})`], + ['Received', `${fmtInt(s.nb_recv)} (flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})`], + ['Duplicates', `flood ${fmtInt(s.flood_dups)} · direct ${fmtInt(s.direct_dups)}`], + ...(s.recv_errors != null ? [['RX errors', fmtInt(s.recv_errors)]] : []), + ['Channel utilization', util], + ]); + + container.innerHTML = system + radio + packets; +} + +// ================================================================ +// Telemetry tool +// ================================================================ + +// Cayenne LPP type name -> {unit, decimals, icon} +const LPP_DISPLAY = { + 'voltage': { unit: 'V', icon: 'bi-battery-half' }, + 'current': { unit: 'A', icon: 'bi-lightning-charge' }, + 'power': { unit: 'W', icon: 'bi-plug' }, + 'energy': { unit: 'kWh', icon: 'bi-plug-fill' }, + 'temperature': { unit: '°C', icon: 'bi-thermometer-half' }, + 'humidity': { unit: '%', icon: 'bi-droplet' }, + 'percentage': { unit: '%', icon: 'bi-percent' }, + 'barometer': { unit: 'hPa', icon: 'bi-speedometer2' }, + 'illuminance': { unit: 'lx', icon: 'bi-sun' }, + 'altitude': { unit: 'm', icon: 'bi-arrow-up-right' }, + 'distance': { unit: 'm', icon: 'bi-rulers' }, + 'frequency': { unit: 'Hz', icon: 'bi-soundwave' }, + 'concentration': { unit: 'ppm', icon: 'bi-cloud-haze' }, + 'load': { unit: 'kg', icon: 'bi-box' }, + 'direction': { unit: '°', icon: 'bi-compass' }, + 'gps': { unit: '', icon: 'bi-geo-alt' }, + 'digital input': { unit: '', icon: 'bi-toggle-on' }, + 'digital output': { unit: '', icon: 'bi-toggle-off' }, + 'analog input': { unit: '', icon: 'bi-sliders' }, + 'analog output': { unit: '', icon: 'bi-sliders' }, + 'generic sensor': { unit: '', icon: 'bi-cpu' }, + 'presence': { unit: '', icon: 'bi-person-check' }, + 'switch': { unit: '', icon: 'bi-toggle2-on' }, + 'time': { unit: '', icon: 'bi-clock' }, +}; + +function fmtLppValue(type, value) { + if (value == null) return '—'; + if (type === 'gps' && typeof value === 'object') { + // lib returns {latitude, longitude, altitude}; tolerate array form too + const lat = value.latitude ?? value[0]; + const lon = value.longitude ?? value[1]; + const alt = value.altitude ?? value[2]; + if (lat == null || lon == null) return esc(JSON.stringify(value)); + let s = `${Number(lat).toFixed(5)}, ${Number(lon).toFixed(5)}`; + if (alt != null) s += ` (${Number(alt).toFixed(0)} m)`; + return esc(s); + } + if (Array.isArray(value)) return esc(value.join(', ')); + if (typeof value === 'object') return esc(JSON.stringify(value)); + if (typeof value === 'number' && !Number.isInteger(value)) { + // Trim float noise, keep up to 3 decimals + return esc(String(Math.round(value * 1000) / 1000)); + } + return esc(String(value)); +} + +let _telemetryUpdatedAt = null; +let _telemetryTimer = null; + +function renderTelemetryPane(body) { + body.innerHTML = ` +
+ + +
+
+ `; + body.querySelector('#telemetryRefreshBtn').addEventListener('click', loadTelemetry); + loadTelemetry(); +} + +function setTelemetryUpdatedLabel() { + if (_telemetryTimer) { clearInterval(_telemetryTimer); _telemetryTimer = null; } + if (!_telemetryUpdatedAt) return; + const tick = () => { + const label = document.getElementById('telemetryUpdated'); + if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; } + const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000); + if (secs < 2) label.textContent = 'Updated just now'; + else if (secs < 60) label.textContent = `Updated ${secs}s ago`; + else label.textContent = `Updated ${fmtDuration(secs)} ago`; + }; + tick(); + _telemetryTimer = setInterval(tick, 5000); +} + +async function loadTelemetry() { + const container = document.getElementById('telemetryContainer'); + const btn = document.getElementById('telemetryRefreshBtn'); + if (!container) return; + if (btn) btn.disabled = true; + container.innerHTML = ` +
+
+

Requesting telemetry from the repeater…
+ Multi-hop paths can take up to a minute.

+
+ `; + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/telemetry`); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + if (btn) btn.disabled = false; + + if (!data || !data.success) { + container.innerHTML = ` +
+ +

${esc((data && data.error) || 'Failed to get telemetry')}

+ +
+ `; + const retry = document.getElementById('telemetryRetryBtn'); + if (retry) retry.addEventListener('click', loadTelemetry); + return; + } + + renderTelemetryCards(container, data.lpp || []); + _telemetryUpdatedAt = Date.now(); + setTelemetryUpdatedLabel(); +} + +function renderTelemetryCards(container, lpp) { + if (!lpp.length) { + container.innerHTML = '
No telemetry data reported.
'; + return; + } + + // Group entries by channel, keep entry order inside a channel + const byChannel = new Map(); + lpp.forEach(entry => { + const ch = entry.channel ?? 0; + if (!byChannel.has(ch)) byChannel.set(ch, []); + byChannel.get(ch).push(entry); + }); + const channels = [...byChannel.keys()].sort((a, b) => a - b); + + let html = '
'; + channels.forEach(ch => { + const rows = byChannel.get(ch).map(entry => { + const disp = LPP_DISPLAY[entry.type] || { unit: '', icon: 'bi-activity' }; + const label = esc(entry.type.charAt(0).toUpperCase() + entry.type.slice(1)); + const value = fmtLppValue(entry.type, entry.value); + return ` + + ${label} + ${value}${disp.unit ? ' ' + disp.unit : ''} + + `; + }).join(''); + // Channel 1 carries the repeater's own vitals (battery, MCU temp) + const chLabel = ch === 1 ? `Channel ${ch} · device` : `Channel ${ch}`; + html += ` +
+
+
${chLabel}
+ ${rows}
+
+
+ `; + }); + html += '
'; + container.innerHTML = html; +} + +// ================================================================ +// CLI tool +// ================================================================ + +const CLI_QUICK_COMMANDS = ['get name', 'get radio', 'get tx', 'ver', 'clock', 'neighbors']; +let _cliHistory = []; +let _cliHistoryIndex = -1; +let _cliPending = false; + +function cliHistoryKey() { + return `mc-webui-rpt-cli-history-${_pubkey}`; +} + +function loadCliHistory() { + try { + _cliHistory = JSON.parse(localStorage.getItem(cliHistoryKey()) || '[]'); + } catch (e) { + _cliHistory = []; + } + _cliHistoryIndex = -1; +} + +function pushCliHistory(cmd) { + _cliHistory = _cliHistory.filter(c => c !== cmd); + _cliHistory.push(cmd); + if (_cliHistory.length > 50) _cliHistory = _cliHistory.slice(-50); + localStorage.setItem(cliHistoryKey(), JSON.stringify(_cliHistory)); + _cliHistoryIndex = -1; +} + +function renderCliPane(body) { + loadCliHistory(); + _cliPending = false; + const chips = CLI_QUICK_COMMANDS.map(c => + `` + ).join(''); + body.innerHTML = ` +
+
Commands go to ${esc(_repeater ? _repeater.name : 'the repeater')}. One command at a time — replies travel over the mesh.
+
+
${chips}
+
+ + +
+ `; + + const form = body.querySelector('#cliForm'); + const input = body.querySelector('#cliInput'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + sendCliCommand(input.value); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!_cliHistory.length) return; + if (_cliHistoryIndex === -1) _cliHistoryIndex = _cliHistory.length; + if (_cliHistoryIndex > 0) _cliHistoryIndex--; + input.value = _cliHistory[_cliHistoryIndex] || ''; + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (_cliHistoryIndex === -1) return; + _cliHistoryIndex++; + if (_cliHistoryIndex >= _cliHistory.length) { + _cliHistoryIndex = -1; + input.value = ''; + } else { + input.value = _cliHistory[_cliHistoryIndex] || ''; + } + } + }); + + body.querySelectorAll('.cli-chip').forEach(chip => { + chip.addEventListener('click', () => { + input.value = chip.dataset.cmd; + input.focus(); + }); + }); + + setTimeout(() => input.focus(), 200); +} + +function cliAppend(cls, text) { + const out = document.getElementById('cliOutput'); + if (!out) return null; + const line = document.createElement('div'); + line.className = `cli-line ${cls}`; + line.textContent = text; + out.appendChild(line); + out.scrollTop = out.scrollHeight; + return line; +} + +async function sendCliCommand(raw) { + const command = (raw || '').trim(); + const input = document.getElementById('cliInput'); + const sendBtn = document.getElementById('cliSendBtn'); + if (!command || _cliPending) return; + + _cliPending = true; + if (input) { input.value = ''; input.disabled = true; } + if (sendBtn) sendBtn.disabled = true; + pushCliHistory(command); + + cliAppend('cmd', command); + const pendingLine = cliAppend('meta cli-pending', 'Waiting for reply…'); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/cli`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command }) + }); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + if (pendingLine) pendingLine.remove(); + if (data && data.success) { + cliAppend('reply', data.output || '(empty reply)'); + if (data.elapsed_ms != null) { + cliAppend('meta', `(${(data.elapsed_ms / 1000).toFixed(1)} s)`); + } + } else { + cliAppend('error', (data && data.error) || 'Command failed'); + } + + _cliPending = false; + if (input) { input.disabled = false; input.focus(); } + if (sendBtn) sendBtn.disabled = false; +} + +// ================================================================ +// Neighbors tool +// ================================================================ + +let _neighborsData = null; // last successful response +let _neighborsView = 'list'; // 'list' | 'map' +let _nbMap = null; +let _nbMapLayers = null; + +function renderNeighborsPane(body) { + _neighborsView = 'list'; + _nbMap = null; // pane body was rebuilt; force map re-init + body.innerHTML = ` +
+ + + +
+
+ + `; + body.querySelector('#neighborsRefreshBtn').addEventListener('click', loadNeighbors); + body.querySelector('#nbListBtn').addEventListener('click', () => setNeighborsView('list')); + body.querySelector('#nbMapBtn').addEventListener('click', () => setNeighborsView('map')); + loadNeighbors(); +} + +async function loadNeighbors() { + const container = document.getElementById('neighborsContainer'); + const btn = document.getElementById('neighborsRefreshBtn'); + if (!container) return; + if (btn) btn.disabled = true; + setNeighborsView('list'); + container.innerHTML = ` +
+
+

Requesting neighbours from the repeater…
+ Long lists are fetched in pages and can take a while.

+
+ `; + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/neighbours`); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + if (btn) btn.disabled = false; + + if (!data || !data.success) { + const countEl = document.getElementById('neighborsCount'); + if (countEl) countEl.textContent = ''; + container.innerHTML = ` +
+ +

${esc((data && data.error) || 'Failed to get neighbours')}

+ +
+ `; + const retry = document.getElementById('neighborsRetryBtn'); + if (retry) retry.addEventListener('click', loadNeighbors); + return; + } + + _neighborsData = data; + renderNeighborsList(); +} + +function neighborLabel(n) { + return n.name || `[${n.pubkey_prefix}]`; +} + +function renderNeighborsList() { + const container = document.getElementById('neighborsContainer'); + const countEl = document.getElementById('neighborsCount'); + const toggle = document.getElementById('neighborsViewToggle'); + const data = _neighborsData; + if (!container || !data) return; + + const entries = data.entries || []; + if (countEl) { + let label = `${data.total} neighbor${data.total === 1 ? '' : 's'}`; + if (data.fetched < data.total) label += ` (showing ${data.fetched})`; + countEl.textContent = label; + } + + // Map view is offered when the repeater or any neighbour has a position + const mappable = entries.some(n => n.lat != null && n.lon != null) + || (_repeater && _repeater.adv_lat && _repeater.adv_lon); + if (toggle) toggle.style.display = mappable ? '' : 'none'; + + if (!entries.length) { + container.innerHTML = '
No neighbours reported yet.
Neighbours are learned from zero-hop repeater adverts.
'; + return; + } + + const rows = entries.map(n => ` + + + ${n.name ? esc(n.name) : `[${esc(n.pubkey_prefix)}]`} + ${n.lat != null ? '' : ''} + + ${fmtHeardAgo(n.secs_ago)} + ${n.snr != null ? n.snr + ' dB' : '—'} + + `).join(''); + container.innerHTML = ` + + + + + + + ${rows} +
RepeaterHeardSNR
+ `; +} + +function fmtHeardAgo(secs) { + if (secs == null) return '—'; + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; + return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h ago`; +} + +function setNeighborsView(view) { + _neighborsView = view; + const listWrap = document.getElementById('neighborsContainer'); + const mapWrap = document.getElementById('neighborsMapWrap'); + const listBtn = document.getElementById('nbListBtn'); + const mapBtn = document.getElementById('nbMapBtn'); + if (!listWrap || !mapWrap) return; + const isMap = view === 'map'; + listWrap.style.display = isMap ? 'none' : ''; + mapWrap.style.display = isMap ? '' : 'none'; + if (listBtn) listBtn.classList.toggle('active', !isMap); + if (mapBtn) mapBtn.classList.toggle('active', isMap); + if (isMap) renderNeighborsMap(); +} + +function renderNeighborsMap() { + const data = _neighborsData; + if (!data) return; + + if (!_nbMap) { + _nbMap = L.map('nbLeafletMap').setView([52.0, 19.0], 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap' + }).addTo(_nbMap); + _nbMapLayers = L.layerGroup().addTo(_nbMap); + } + _nbMapLayers.clearLayers(); + + const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon; + const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null; + const bounds = []; + + if (hasCenter) { + const centerMarker = L.circleMarker(center, { + radius: 11, + fillColor: '#dc3545', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.9 + }).addTo(_nbMapLayers); + centerMarker.bindPopup(`${esc(_repeater.name || 'This repeater')}
managed repeater`); + bounds.push(center); + } + + let placed = 0; + let skipped = 0; + (data.entries || []).forEach(n => { + if (n.lat == null || n.lon == null) { skipped++; return; } + const pos = [n.lat, n.lon]; + const marker = L.circleMarker(pos, { + radius: 9, + fillColor: '#198754', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.85 + }).addTo(_nbMapLayers); + marker.bindPopup( + `${esc(neighborLabel(n))}
` + + `SNR: ${n.snr != null ? n.snr + ' dB' : '—'}
` + + `Heard: ${fmtHeardAgo(n.secs_ago)}` + ); + if (hasCenter) { + const line = L.polyline([center, pos], { + color: '#6c757d', + weight: 2, + dashArray: '6 6', + opacity: 0.8 + }).addTo(_nbMapLayers); + line.bindTooltip(`${n.snr != null ? n.snr + ' dB' : '?'}`, { + permanent: true, + direction: 'center', + className: 'nb-snr-tooltip' + }); + } + bounds.push(pos); + placed++; + }); + + const note = document.getElementById('nbMapNote'); + if (note) { + const parts = [`${placed} neighbor${placed === 1 ? '' : 's'} with known position`]; + if (skipped) parts.push(`${skipped} without position not shown`); + if (!hasCenter) parts.push('managed repeater has no position — connection lines unavailable'); + note.textContent = parts.join(' · '); + } + + // Leaflet needs a size recalc after the container becomes visible + setTimeout(() => { + _nbMap.invalidateSize(); + if (bounds.length > 0) { + _nbMap.fitBounds(bounds, { padding: [30, 30] }); + } + }, 50); +} + +async function loadClock() { + const el = document.getElementById('statusClock'); + if (!el) return; + el.innerHTML = ''; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/clock`); + const data = await resp.json(); + if (data.success && data.timestamp) { + const d = new Date(data.timestamp * 1000); + el.classList.remove('text-muted'); + el.textContent = d.toLocaleString(); + } else { + el.className = ''; + el.innerHTML = ``; + const b = document.getElementById('clockRetryBtn'); + if (b) b.addEventListener('click', loadClock); + } + } catch (e) { + el.className = ''; + el.innerHTML = ``; + const b = document.getElementById('clockRetryBtn'); + if (b) b.addEventListener('click', loadClock); + } +} + +// ================================================================ +// Settings tool +// ================================================================ + +// Sections and fields mirror _REPEATER_SETTINGS_FIELDS in api.py. +// Values travel as raw CLI strings (`get X` → `> value`, `set X value`). +const SETTINGS_SECTIONS = [ + { key: 'basic', title: 'Basic', icon: 'bi-tag', fields: [ + { key: 'name', label: 'Repeater name', type: 'text' }, + { key: 'password', label: 'Admin password', type: 'password', writeOnly: true, + help: 'Write-only — the current password is never shown. Changing it does not log out already active sessions.' }, + { key: 'guest.password', label: 'Guest password', type: 'text', + help: 'Password for read-only guest logins.' }, + ]}, + { key: 'radio', title: 'Radio', icon: 'bi-broadcast', + note: 'Frequency, bandwidth, SF and CR are applied after a reboot. TX power applies immediately.', + fields: [ + { key: 'radio', label: 'Radio parameters', type: 'radio4' }, + { key: 'tx', label: 'TX power (dBm)', type: 'number', min: 0, max: 30, step: 1 }, + { key: 'radio.rxgain', label: 'RX boosted gain', type: 'onoff' }, + ]}, + { key: 'location', title: 'Location', icon: 'bi-geo-alt', fields: [ + { key: 'lat', label: 'Latitude', type: 'text' }, + { key: 'lon', label: 'Longitude', type: 'text' }, + ]}, + { key: 'features', title: 'Features', icon: 'bi-toggles', fields: [ + { key: 'repeat', label: 'Repeat packets', type: 'onoff' }, + { key: 'allow.read.only', label: 'Allow read-only (guest) access', type: 'onoff' }, + { key: 'multi.acks', label: 'Send multiple ACKs', type: 'zeroone' }, + ]}, + { key: 'network', title: 'Network health', icon: 'bi-heart-pulse', fields: [ + { key: 'loop.detect', label: 'Loop detection', type: 'select', + options: ['off', 'minimal', 'moderate', 'strict'] }, + { key: 'dutycycle', label: 'Duty cycle (%)', type: 'number', min: 1, max: 100, step: 1 }, + ]}, + { key: 'advert', title: 'Advertisement', icon: 'bi-megaphone', fields: [ + { key: 'advert.interval', label: 'Local advert interval (minutes)', type: 'number', min: 0, max: 240, step: 1, + help: 'Firmware accepts 60–240 minutes, or 0 to disable.' }, + { key: 'flood.advert.interval', label: 'Flood advert interval (hours)', type: 'number', min: 0, step: 1 }, + { key: 'flood.max', label: 'Max flood hops', type: 'number', min: 0, max: 64, step: 1 }, + ]}, + { key: 'operator', title: 'Operator info', icon: 'bi-person-vcard', fields: [ + { key: 'owner.info', label: 'Owner info', type: 'textarea', + help: 'Free-form operator / contact info shown to clients. Multiple lines allowed.' }, + ]}, + { key: 'advanced', title: 'Advanced', icon: 'bi-sliders', fields: [ + { key: 'path.hash.mode', label: 'Path hash mode (0–2)', type: 'number', min: 0, max: 2, step: 1 }, + { key: 'txdelay', label: 'TX delay factor (0–2)', type: 'number', min: 0, max: 2, step: 'any' }, + { key: 'direct.txdelay', label: 'Direct TX delay factor (0–2)', type: 'number', min: 0, max: 2, step: 'any' }, + { key: 'int.thresh', label: 'Interference threshold', type: 'number', step: 1 }, + { key: 'agc.reset.interval', label: 'AGC reset interval', type: 'number', min: 0, step: 4, + help: 'Multiple of 4; 0 disables periodic AGC resets.' }, + ]}, +]; + +let _settingsState = {}; // section key → {loaded, loadedOnce, loading, applying} + +function settingsSection(secKey) { + return SETTINGS_SECTIONS.find(s => s.key === secKey); +} + +function settingsSectionEl(secKey) { + return document.querySelector(`#settingsAccordion .accordion-item[data-section="${secKey}"]`); +} + +function sfControl(item, fieldKey) { + return item.querySelector(`[data-field="${fieldKey}"]`); +} + +function settingsControlHtml(f) { + const df = `data-field="${esc(f.key)}"`; + if (f.type === 'onoff' || f.type === 'zeroone') { + return `
+ +
`; + } + if (f.type === 'select') { + const opts = (f.options || []).map(o => ``).join(''); + return ``; + } + if (f.type === 'textarea') { + return ``; + } + if (f.type === 'password') { + // Write-only: usable without loading, never prefilled + return ``; + } + if (f.type === 'radio4') { + const subs = [ + ['Frequency (MHz)', 'any'], + ['Bandwidth (kHz)', 'any'], + ['Spreading factor', '1'], + ['Coding rate', '1'], + ].map(([lbl, step], i) => ` +
+ + +
`).join(''); + return `
${subs}
`; + } + const attrs = [ + f.min != null ? `min="${f.min}"` : '', + f.max != null ? `max="${f.max}"` : '', + f.step != null ? `step="${f.step}"` : '', + ].filter(Boolean).join(' '); + const type = f.type === 'number' ? 'number' : 'text'; + return ``; +} + +function settingsFieldHtml(f) { + return ` +
+ + ${settingsControlHtml(f)} +
+ ${f.help ? `
${esc(f.help)}
` : ''} +
`; +} + +function renderSettingsPane(body) { + _settingsState = {}; + const items = SETTINGS_SECTIONS.map(sec => { + _settingsState[sec.key] = { loaded: {}, loadedOnce: false, loading: false, applying: false }; + return ` +
+

+ +

+
+
+ ${sec.note ? `
${esc(sec.note)}
` : ''} +
+
+ Some changes take effect after a reboot — use Actions → Reboot. +
+
${sec.fields.map(settingsFieldHtml).join('')}
+
+ + +
+
+
+
`; + }).join(''); + + body.innerHTML = ` +

+ Settings are read live from the repeater. Expand a section to load it — + every field is one mesh round-trip, so a section can take a few seconds. +

+
${items}
+ `; + + SETTINGS_SECTIONS.forEach(sec => { + const item = settingsSectionEl(sec.key); + const collapse = item.querySelector('.accordion-collapse'); + collapse.addEventListener('show.bs.collapse', () => { + if (!_settingsState[sec.key].loadedOnce) loadSettingsSection(sec.key); + }); + item.querySelector('.sec-refresh').addEventListener('click', () => loadSettingsSection(sec.key)); + item.querySelector('.sec-apply').addEventListener('click', () => applySettingsSection(sec.key)); + item.querySelectorAll('.sf-input, .sf-sub').forEach(inp => { + const evt = (inp.type === 'checkbox' || inp.tagName === 'SELECT') ? 'change' : 'input'; + inp.addEventListener(evt, () => updateSectionDirty(sec.key)); + }); + }); +} + +function setSettingsFieldValue(item, f, raw) { + const el = sfControl(item, f.key); + if (!el) return; + const v = String(raw ?? ''); + if (f.type === 'onoff' || f.type === 'zeroone') { + const low = v.trim().toLowerCase(); + el.checked = (low === 'on' || low === '1' || low === 'true' || low === 'yes'); + } else if (f.type === 'select') { + const val = v.trim(); + if (![...el.options].some(o => o.value === val)) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = val; + el.appendChild(opt); + } + el.value = val; + } else if (f.type === 'radio4') { + const parts = v.split(',').map(s => s.trim()); + el.querySelectorAll('.sf-sub').forEach((inp, i) => { inp.value = parts[i] ?? ''; }); + } else if (f.type === 'textarea') { + el.value = v.split('|').join('\n'); + } else if (f.type === 'number') { + // Some replies carry a unit suffix (e.g. `get dutycycle` → "70.0%") + // that a number input would reject wholesale. + el.value = v.trim().replace(/%$/, ''); + } else { + el.value = v; + } +} + +function getSettingsFieldValue(item, f) { + const el = sfControl(item, f.key); + if (!el) return ''; + if (f.type === 'onoff') return el.checked ? 'on' : 'off'; + if (f.type === 'zeroone') return el.checked ? '1' : '0'; + if (f.type === 'radio4') { + return [...el.querySelectorAll('.sf-sub')].map(inp => inp.value.trim()).join(','); + } + if (f.type === 'textarea') { + return el.value.replace(/\r/g, '').split('\n').map(s => s.trim()).join('|').replace(/\|+$/, ''); + } + return el.value.trim(); +} + +function disableSettingsField(item, f, disabled) { + const el = sfControl(item, f.key); + if (!el) return; + if (f.type === 'radio4') { + el.querySelectorAll('.sf-sub').forEach(inp => { inp.disabled = disabled; }); + } else { + el.disabled = disabled; + } +} + +function setFieldBadge(item, fieldKey, kind, text) { + const row = item.querySelector(`[data-field-row="${fieldKey}"]`); + if (!row) return; + const badge = row.querySelector('.sf-badge'); + const msg = row.querySelector('.sf-msg'); + msg.classList.add('d-none'); + msg.textContent = ''; + if (kind === 'pending') { + badge.innerHTML = ''; + } else if (kind === 'ok') { + badge.innerHTML = ''; + } else if (kind === 'reboot') { + badge.innerHTML = 'reboot required'; + } else if (kind === 'error') { + badge.innerHTML = ''; + if (text) { + msg.textContent = text; + msg.classList.remove('d-none'); + } + } else { + badge.innerHTML = ''; + } +} + +function isFieldDirty(item, st, f) { + if (f.writeOnly) { + const el = sfControl(item, f.key); + return !!(el && el.value); + } + if (!(f.key in st.loaded)) return false; // never loaded → not editable + return getSettingsFieldValue(item, f) !== st.loaded[f.key]; +} + +function updateSectionDirty(secKey) { + const item = settingsSectionEl(secKey); + const st = _settingsState[secKey]; + const sec = settingsSection(secKey); + if (!item || !st || !sec) return 0; + let count = 0; + sec.fields.forEach(f => { + const dirty = isFieldDirty(item, st, f); + if (dirty) count++; + const row = item.querySelector(`[data-field-row="${f.key}"]`); + if (row) row.classList.toggle('sf-dirty', dirty); + }); + const applyBtn = item.querySelector('.sec-apply'); + applyBtn.disabled = count === 0 || st.loading || st.applying; + applyBtn.innerHTML = ` Apply${count ? ` (${count})` : ''}`; + const badge = item.querySelector('.sec-dirty-badge'); + badge.classList.toggle('d-none', count === 0); + badge.textContent = count; + return count; +} + +async function loadSettingsSection(secKey) { + const st = _settingsState[secKey]; + const item = settingsSectionEl(secKey); + const sec = settingsSection(secKey); + if (!st || !item || !sec || st.loading || st.applying) return; + st.loading = true; + + const statusEl = item.querySelector('.sec-status'); + statusEl.classList.remove('d-none', 'text-danger'); + statusEl.classList.add('text-muted'); + statusEl.innerHTML = '' + + 'Reading from repeater… (one mesh round-trip per field)'; + item.querySelector('.sec-refresh').disabled = true; + item.querySelector('.sec-apply').disabled = true; + sec.fields.forEach(f => { + if (f.writeOnly) return; + setFieldBadge(item, f.key, 'clear'); + disableSettingsField(item, f, true); + }); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/settings?section=${encodeURIComponent(secKey)}`); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + st.loading = false; + item.querySelector('.sec-refresh').disabled = false; + + if (!data || !data.success) { + statusEl.classList.remove('text-muted'); + statusEl.classList.add('text-danger'); + statusEl.textContent = (data && data.error) || 'Failed to read settings'; + updateSectionDirty(secKey); + return; + } + + const values = data.values || {}; + const errors = data.errors || {}; + st.loaded = {}; + let errCount = 0; + sec.fields.forEach(f => { + if (f.writeOnly) return; + if (f.key in values) { + setSettingsFieldValue(item, f, values[f.key]); + disableSettingsField(item, f, false); + // Baseline = the value as the control round-trips it, so + // firmware formatting quirks never show up as dirty fields. + st.loaded[f.key] = getSettingsFieldValue(item, f); + setFieldBadge(item, f.key, 'clear'); + } else { + errCount++; + disableSettingsField(item, f, true); + setFieldBadge(item, f.key, 'error', errors[f.key] || 'Failed to read'); + } + }); + st.loadedOnce = true; + + if (errCount) { + statusEl.classList.remove('text-muted'); + statusEl.classList.add('text-danger'); + statusEl.textContent = `${errCount} field${errCount > 1 ? 's' : ''} failed to load — Refresh retries the whole section.`; + } else { + statusEl.classList.add('d-none'); + } + updateSectionDirty(secKey); +} + +async function applySettingsSection(secKey) { + const st = _settingsState[secKey]; + const item = settingsSectionEl(secKey); + const sec = settingsSection(secKey); + if (!st || !item || !sec || st.loading || st.applying) return; + + const dirty = {}; + sec.fields.forEach(f => { + if (isFieldDirty(item, st, f)) { + dirty[f.key] = f.writeOnly ? sfControl(item, f.key).value : getSettingsFieldValue(item, f); + } + }); + const keys = Object.keys(dirty); + if (!keys.length) return; + + const emptyKey = keys.find(k => dirty[k] === '' && k !== 'owner.info'); + if (emptyKey) { + showNotification(`"${emptyKey}" cannot be empty`, 'warning'); + return; + } + + if ('radio' in dirty && !window.confirm( + `Change radio parameters to ${dirty.radio}?\n\n` + + 'Wrong values can make the repeater unreachable over the mesh. ' + + 'The change takes effect after a reboot.')) { + return; + } + + st.applying = true; + item.querySelector('.sec-refresh').disabled = true; + item.querySelector('.sec-apply').disabled = true; + keys.forEach(k => setFieldBadge(item, k, 'pending')); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: dirty }) + }); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + st.applying = false; + item.querySelector('.sec-refresh').disabled = false; + + if (!data || !data.success) { + keys.forEach(k => setFieldBadge(item, k, 'error')); + showNotification((data && data.error) || 'Failed to apply settings', 'danger'); + updateSectionDirty(secKey); + return; + } + + const results = data.results || {}; + let okCount = 0, failCount = 0, rebootCount = 0; + for (const f of sec.fields) { + if (!(f.key in dirty)) continue; + const res = results[f.key] || { status: 'failed', error: 'No result' }; + if (res.status === 'ok' || res.status === 'reboot_required') { + if (res.status === 'reboot_required') { + rebootCount++; + setFieldBadge(item, f.key, 'reboot'); + } else { + okCount++; + setFieldBadge(item, f.key, 'ok'); + } + if (f.writeOnly) { + sfControl(item, f.key).value = ''; + if (f.key === 'password') await syncSavedPassword(dirty[f.key]); + } else { + st.loaded[f.key] = dirty[f.key]; + if (f.key === 'name' && _repeater) { + _repeater.name = dirty[f.key]; + renderHeader(); + } + } + } else { + failCount++; + setFieldBadge(item, f.key, 'error', res.error || res.reply || 'Failed'); + } + } + + if (rebootCount) item.querySelector('.sec-reboot').classList.remove('d-none'); + updateSectionDirty(secKey); + + if (failCount === 0) { + showNotification(rebootCount ? 'Applied — reboot required for some changes' : 'Settings applied', 'success'); + } else { + showNotification(`${failCount} setting${failCount > 1 ? 's' : ''} failed to apply`, 'danger'); + } + if (okCount) { + setTimeout(() => { + sec.fields.forEach(f => { + const row = item.querySelector(`[data-field-row="${f.key}"]`); + if (row && row.querySelector('.sf-badge .bi-check-circle-fill')) { + setFieldBadge(item, f.key, 'clear'); + } + }); + }, 4000); + } +} + +async function syncSavedPassword(newPassword) { + // Keep auto-login working: when a password is saved for this repeater, + // replace it with the one just set on the repeater itself. + if (!_repeater || !_repeater.password_set) return; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: newPassword }) + }); + const data = await resp.json(); + if (data && data.success) { + showNotification('Saved password updated to the new one', 'success'); + } + } catch (e) { + console.error('Failed to update saved password:', e); + } +} + +// ================================================================ +// Actions tool +// ================================================================ + +// Keys mirror _REPEATER_ACTIONS in api.py. +const REPEATER_ACTIONS = [ + { key: 'zerohop_advert', icon: 'bi-megaphone', iconClass: 'text-primary', + title: 'Send zero-hop advert', btn: 'Send', btnClass: 'btn-outline-primary', + desc: 'Announce this repeater to its direct neighbours only.' }, + { key: 'flood_advert', icon: 'bi-broadcast-pin', iconClass: 'text-warning', + title: 'Send flood advert', btn: 'Send', btnClass: 'btn-outline-warning', + desc: 'Not recommended — the advert is flooded across the whole mesh (high network load).' }, + { key: 'clock_sync', icon: 'bi-clock-history', iconClass: 'text-primary', + title: 'Sync clock', btn: 'Sync', btnClass: 'btn-outline-primary', + desc: "Set the repeater's clock from this device's current time. The firmware refuses to move the clock backwards." }, +]; + +let _actionPending = false; + +function actionRowHtml(a) { + return ` +
+ +
+
${esc(a.title)}
+
${esc(a.desc)}
+
+
+ +
`; +} + +function renderActionsPane(body) { + _actionPending = false; + body.innerHTML = ` +
+
+ ${REPEATER_ACTIONS.map(actionRowHtml).join('
')} +
+
+
+
+ Danger zone +
+
+ ${actionRowHtml({ + key: 'reboot', icon: 'bi-arrow-clockwise', iconClass: 'text-danger', + title: 'Reboot repeater', btn: 'Reboot', btnClass: 'btn-danger', + desc: 'The repeater drops off the mesh for a few seconds while it restarts.', + })} +
+ Erase file system is not available over the mesh — + the firmware only accepts it on the USB serial console (use the MeshCore flasher instead). +
+
+
+ `; + + body.querySelectorAll('.action-row .action-btn').forEach(btn => { + const row = btn.closest('.action-row'); + btn.addEventListener('click', () => runRepeaterAction(row.dataset.action)); + }); +} + +async function runRepeaterAction(action) { + if (_actionPending) return; + if (action === 'reboot' && !window.confirm( + `Reboot ${(_repeater && _repeater.name) || 'this repeater'}?\n\n` + + 'It will drop off the mesh for a few seconds. The firmware does not reply to this command.')) { + return; + } + + const row = document.querySelector(`.action-row[data-action="${action}"]`); + const btn = row ? row.querySelector('.action-btn') : null; + const resultEl = row ? row.querySelector('.action-result') : null; + _actionPending = true; + document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = true; }); + const btnLabel = btn ? btn.innerHTML : ''; + if (btn) btn.innerHTML = ''; + if (resultEl) resultEl.classList.add('d-none'); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + _actionPending = false; + document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = false; }); + if (btn) btn.innerHTML = btnLabel; + + if (resultEl) { + resultEl.classList.remove('d-none', 'text-success', 'text-danger'); + if (data && data.success) { + resultEl.classList.add(data.ok ? 'text-success' : 'text-danger'); + const elapsed = data.elapsed_ms != null ? ` (${(data.elapsed_ms / 1000).toFixed(1)} s)` : ''; + resultEl.textContent = (data.reply || 'Done') + elapsed; + } else { + resultEl.classList.add('text-danger'); + resultEl.textContent = (data && data.error) || 'Action failed'; + } + } + if (!data || !data.success) { + showNotification((data && data.error) || 'Action failed', 'danger'); + } +} + +// ================================================================ +// Login flow +// ================================================================ + +async function fetchRepeater() { + const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`); + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to load repeater'); + } + _repeater = data.repeater; + _session = data.session; +} + +async function doLogin(password, save) { + const name = (_repeater && _repeater.name) || 'repeater'; + showLoading(`Logging in to ${name}… (may take up to 60 s on flood paths)`); + + let data = null; + try { + const body = {}; + if (password) { + body.password = password; + body.save = !!save; + } + const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + data = await response.json(); + } catch (e) { + console.error('Login request failed:', e); + data = { success: false, error: 'Login request failed' }; + } + + if (data && data.success) { + _session = { + logged_in: true, + is_admin: !!data.is_admin, + permissions: data.permissions + }; + const role = data.is_admin ? 'ADMIN' : 'GUEST'; + showNotification(`Logged in as ${role}`, 'success'); + showPanel(); + } else { + const error = (data && data.error) || 'Login failed'; + openPasswordModal(error); + } +} + +function openPasswordModal(errorHint = '') { + // Keep the loading screen behind the modal but stop the spinner text + showLoading('Waiting for password…'); + + const name = (_repeater && _repeater.name) || _pubkey.substring(0, 12); + document.getElementById('passwordModalTitle').textContent = `Log in — ${name}`; + const info = document.getElementById('passwordModalInfo'); + info.innerHTML = errorHint + ? `${esc(errorHint)}
Check the password and try again.` + : 'Enter the repeater password to log in.'; + const input = document.getElementById('passwordInput'); + input.value = ''; + input.type = 'password'; + document.getElementById('savePasswordCheck').checked = true; + + _passwordModal.show(); + setTimeout(() => input.focus(), 300); +} + +async function submitPasswordModal() { + const input = document.getElementById('passwordInput'); + const password = input.value; + if (!password) { + showNotification('Password cannot be empty', 'warning'); + return; + } + const save = document.getElementById('savePasswordCheck').checked; + _passwordModal.hide(); + await doLogin(password, save); +} + +async function logout() { + const logoutBtn = document.getElementById('logoutBtn'); + logoutBtn.disabled = true; + try { + const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/logout`, { method: 'POST' }); + const data = await response.json(); + if (!data.success) { + showNotification(data.error || 'Logout failed', 'danger'); + logoutBtn.disabled = false; + return; + } + } catch (e) { + console.error('Logout failed:', e); + } + goBackToList(); +} + +// ================================================================ +// Init +// ================================================================ + +async function init() { + const params = new URLSearchParams(window.location.search); + _pubkey = (params.get('pubkey') || '').toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(_pubkey)) { + showError('Invalid repeater public key in URL.'); + return; + } + + showLoading('Loading…'); + try { + await fetchRepeater(); + } catch (e) { + showError(e.message); + return; + } + + if (!_repeater.on_device) { + showError('This repeater is not stored on the device — it cannot be managed.'); + return; + } + + if (_session && _session.logged_in) { + showPanel(); + } else if (_repeater.password_set) { + // Saved password: log in automatically (e.g. after app restart) + await doLogin(null, false); + } else { + openPasswordModal(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + _passwordModal = new bootstrap.Modal(document.getElementById('passwordModal')); + + loadUiSettings(); + + document.getElementById('backBtn').addEventListener('click', goBackToList); + document.getElementById('errorBackBtn').addEventListener('click', goBackToList); + document.getElementById('errorRetryBtn').addEventListener('click', init); + document.getElementById('logoutBtn').addEventListener('click', logout); + document.getElementById('paneBackBtn').addEventListener('click', showToolsGrid); + + document.getElementById('passwordSubmitBtn').addEventListener('click', submitPasswordModal); + document.getElementById('passwordCancelBtn').addEventListener('click', () => { + _passwordModal.hide(); + goBackToList(); + }); + document.getElementById('passwordInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitPasswordModal(); + } + }); + document.getElementById('togglePasswordBtn').addEventListener('click', () => { + const input = document.getElementById('passwordInput'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); + + document.getElementById('copyPubkeyBtn').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey); + showNotification('Public key copied', 'info'); + } catch (e) { + showNotification('Copy failed', 'warning'); + } + }); + + init(); +}); diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js new file mode 100644 index 0000000..2976467 --- /dev/null +++ b/app/static/js/repeaters.js @@ -0,0 +1,1099 @@ +// My Repeaters panel +// - saved repeater list merged with device truth (GET /api/repeaters) +// - add-repeater picker (device contacts of type REP only) +// - password set / login (password persisted server-side) +// - path editor (same /api/contacts//paths endpoints as the DM panel) + +// ================================================================ +// UI settings + toast (same behavior as dm.js) +// ================================================================ + +const RPT_UI_SETTINGS_DEFAULTS = { + toast_timeout_sec: 2, + toast_no_autoclose: false, + toast_position: 'top-left' +}; + +const RPT_TOAST_POSITION_CLASSES = { + 'top-left': ['top-0', 'start-0'], + 'top-right': ['top-0', 'end-0'], + 'bottom-left': ['bottom-0', 'start-0'], + 'bottom-right': ['bottom-0', 'end-0'], + 'center': ['top-50', 'start-50', 'translate-middle'] +}; +const RPT_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; + +window.uiSettingsCache = window.uiSettingsCache || { ...RPT_UI_SETTINGS_DEFAULTS }; + +function applyToastPosition(position) { + const classes = RPT_TOAST_POSITION_CLASSES[position] || RPT_TOAST_POSITION_CLASSES['top-left']; + document.querySelectorAll('[data-toast-container]').forEach(el => { + RPT_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c)); + classes.forEach(c => el.classList.add(c)); + }); +} + +async function loadUiSettings() { + try { + const resp = await fetch('/api/ui/settings'); + if (resp.ok) { + const data = await resp.json(); + window.uiSettingsCache = { ...RPT_UI_SETTINGS_DEFAULTS, ...data }; + applyToastPosition(window.uiSettingsCache.toast_position); + } + } catch (e) { + console.error('Failed to load UI settings:', e); + } +} + +function showNotification(message, type = 'info') { + const toastEl = document.getElementById('notificationToast'); + if (!toastEl) return; + + const toastBody = toastEl.querySelector('.toast-body'); + if (toastBody) { + toastBody.textContent = message; + } + + const toastHeader = toastEl.querySelector('.toast-header'); + if (toastHeader) { + toastHeader.className = 'toast-header'; + if (type === 'success') { + toastHeader.classList.add('bg-success', 'text-white'); + } else if (type === 'danger') { + toastHeader.classList.add('bg-danger', 'text-white'); + } else if (type === 'warning') { + toastHeader.classList.add('bg-warning'); + } + } + + const cfg = window.uiSettingsCache || {}; + const noAutoclose = !!cfg.toast_no_autoclose; + const timeoutSec = parseFloat(cfg.toast_timeout_sec); + const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000; + + const toast = new bootstrap.Toast(toastEl, { + autohide: !noAutoclose, + delay: delay + }); + toast.show(); +} + +// ================================================================ +// Helpers +// ================================================================ + +function esc(s) { + return String(s ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function formatRelativeTime(timestamp) { + if (!timestamp) return 'Never'; + const diff = Math.floor(Date.now() / 1000) - timestamp; + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// ================================================================ +// State +// ================================================================ + +let myRepeaters = []; // rows from GET /api/repeaters +let _deviceRepeaters = null; // device contacts of type REP (add picker) +let _repeatersCache = null; // /api/contacts/repeaters (path hop picker) +let _loginPubkey = null; // pubkey with login in progress +let _passwordCtx = null; // {mode: 'set'|'login', pubkey, name} +let _removeCtx = null; // {pubkey, name} +let _pathPubkey = null; // pubkey whose paths are being edited +let _passwordModal = null; +let _addRepeaterModal = null; +let _pathModal = null; +let _addPathModal = null; +let _removeModal = null; + +function findRepeater(pubkey) { + return myRepeaters.find(r => r.public_key === pubkey) || null; +} + +// ================================================================ +// Repeater list +// ================================================================ + +async function loadRepeaters(forceRefresh = false) { + const listEl = document.getElementById('repeaterList'); + try { + const url = forceRefresh ? '/api/repeaters?refresh=true' : '/api/repeaters'; + const response = await fetch(url); + const data = await response.json(); + if (!data.success) { + listEl.innerHTML = `
${esc(data.error || 'Failed to load repeaters')}
`; + return; + } + myRepeaters = data.repeaters || []; + renderRepeaterRows(); + } catch (e) { + console.error('Failed to load repeaters:', e); + listEl.innerHTML = '
Failed to load repeaters
'; + } +} + +function renderRepeaterRows() { + const listEl = document.getElementById('repeaterList'); + const emptyEl = document.getElementById('emptyState'); + const countEl = document.getElementById('repeaterCount'); + + if (countEl) { + countEl.textContent = myRepeaters.length + ? `${myRepeaters.length} repeater${myRepeaters.length === 1 ? '' : 's'}` + : ''; + } + + listEl.innerHTML = ''; + if (!myRepeaters.length) { + if (emptyEl) emptyEl.style.display = ''; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + + myRepeaters.forEach(r => { + const row = document.createElement('div'); + row.className = 'repeater-row' + (r.on_device ? '' : ' rpt-offline'); + row.dataset.pubkey = r.public_key; + + const isLoggingIn = _loginPubkey === r.public_key; + const icon = isLoggingIn + ? '' + : ''; + + const roleInfo = r.last_login_at + ? ` · last login: ${esc(r.last_login_role || '?')}` + : ''; + const statusLine = isLoggingIn + ? 'Logging in… (may take up to 60 s on flood paths)' + : (r.on_device + ? `${esc(r.path_or_mode || '—')}${roleInfo}` + : 'Not stored on the device'); + + row.innerHTML = ` +
+ ${icon} +
+
${esc(r.name || r.public_key.substring(0, 12))}
+ ${statusLine} +
+
+ + + +
+
+ `; + + row.querySelector('.rpt-main').addEventListener('click', () => onRepeaterClick(r.public_key)); + row.querySelector('[data-action="path"]').addEventListener('click', (e) => { + e.stopPropagation(); + openPathModal(r.public_key); + }); + row.querySelector('[data-action="password"]').addEventListener('click', (e) => { + e.stopPropagation(); + openPasswordModal('set', r.public_key); + }); + row.querySelector('[data-action="remove"]').addEventListener('click', (e) => { + e.stopPropagation(); + openRemoveModal(r.public_key); + }); + + listEl.appendChild(row); + }); +} + +// ================================================================ +// Login flow +// ================================================================ + +function onRepeaterClick(pubkey) { + const r = findRepeater(pubkey); + if (!r) return; + if (!r.on_device) { + showNotification('This repeater is not stored on the device — it cannot be managed', 'warning'); + return; + } + if (_loginPubkey) { + showNotification('Another login is already in progress', 'warning'); + return; + } + if (!r.password_set) { + openPasswordModal('login', pubkey); + return; + } + doLogin(pubkey, null, false); +} + +async function doLogin(pubkey, password, save) { + const r = findRepeater(pubkey); + if (!r) return; + + _loginPubkey = pubkey; + renderRepeaterRows(); + + let data = null; + try { + const body = {}; + if (password) { + body.password = password; + body.save = !!save; + } + const response = await fetch(`/api/repeaters/${encodeURIComponent(pubkey)}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + data = await response.json(); + } catch (e) { + console.error('Login request failed:', e); + data = { success: false, error: 'Login request failed' }; + } + + _loginPubkey = null; + + if (data && data.success) { + const role = data.is_admin ? 'ADMIN' : 'GUEST'; + showNotification(`Logged in to ${r.name || 'repeater'} as ${role}`, 'success'); + window.location.href = `/repeaters/manage?pubkey=${encodeURIComponent(pubkey)}`; + return; + } else { + await loadRepeaters(); + const error = (data && data.error) || 'Login failed'; + showNotification(error, 'danger'); + // Offer a retry with password correction (wrong password and + // unreachable repeater are indistinguishable at protocol level). + openPasswordModal('login', pubkey, error); + } +} + +// ================================================================ +// Password modal (set password / login prompt) +// ================================================================ + +function openPasswordModal(mode, pubkey, errorHint = '') { + const r = findRepeater(pubkey); + if (!r) return; + _passwordCtx = { mode, pubkey }; + + const title = document.getElementById('passwordModalTitle'); + const info = document.getElementById('passwordModalInfo'); + const submitBtn = document.getElementById('passwordSubmitBtn'); + const input = document.getElementById('passwordInput'); + const saveWrap = document.getElementById('savePasswordWrap'); + const saveCheck = document.getElementById('savePasswordCheck'); + + const name = r.name || r.public_key.substring(0, 12); + if (mode === 'set') { + title.textContent = `Set password — ${name}`; + info.textContent = 'The password is stored in the app database and used to log in to this repeater without asking again.'; + submitBtn.textContent = 'Save'; + saveWrap.style.display = 'none'; + } else { + title.textContent = `Log in — ${name}`; + info.innerHTML = errorHint + ? `${esc(errorHint)}
Check the password and try again.` + : 'Enter the repeater password to log in.'; + submitBtn.textContent = 'Log in'; + saveWrap.style.display = ''; + saveCheck.checked = true; + } + input.value = ''; + input.type = 'password'; + + _passwordModal.show(); + setTimeout(() => input.focus(), 300); +} + +async function submitPasswordModal() { + if (!_passwordCtx) return; + const { mode, pubkey } = _passwordCtx; + const input = document.getElementById('passwordInput'); + const saveCheck = document.getElementById('savePasswordCheck'); + const password = input.value; + + if (!password) { + showNotification('Password cannot be empty', 'warning'); + return; + } + + _passwordModal.hide(); + + if (mode === 'set') { + try { + const response = await fetch(`/api/repeaters/${encodeURIComponent(pubkey)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + const data = await response.json(); + if (data.success) { + showNotification('Password saved', 'success'); + await loadRepeaters(); + } else { + showNotification(data.error || 'Failed to save password', 'danger'); + } + } catch (e) { + showNotification('Failed to save password', 'danger'); + } + } else { + doLogin(pubkey, password, saveCheck.checked); + } +} + +// ================================================================ +// Remove modal +// ================================================================ + +function openRemoveModal(pubkey) { + const r = findRepeater(pubkey); + if (!r) return; + _removeCtx = { pubkey }; + document.getElementById('removeRepeaterName').textContent = r.name || r.public_key.substring(0, 12); + _removeModal.show(); +} + +async function confirmRemoveRepeater() { + if (!_removeCtx) return; + const { pubkey } = _removeCtx; + _removeModal.hide(); + try { + const response = await fetch(`/api/repeaters/${encodeURIComponent(pubkey)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showNotification('Repeater removed from list', 'info'); + await loadRepeaters(); + } else { + showNotification(data.error || 'Failed to remove repeater', 'danger'); + } + } catch (e) { + showNotification('Failed to remove repeater', 'danger'); + } +} + +// ================================================================ +// Add repeater picker (device contacts of type REP) +// ================================================================ + +async function openAddRepeaterModal() { + _addRepeaterModal.show(); + const listEl = document.getElementById('deviceRptList'); + listEl.innerHTML = '
Loading device contacts...
'; + document.getElementById('deviceRptSearch').value = ''; + + try { + const response = await fetch('/api/contacts/detailed'); + const data = await response.json(); + if (!data.success) { + listEl.innerHTML = `
${esc(data.error || 'Failed to load device contacts')}
`; + return; + } + _deviceRepeaters = (data.contacts || []) + .filter(c => c.type === 2) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + renderDeviceRepeaterList(); + } catch (e) { + console.error('Failed to load device contacts:', e); + listEl.innerHTML = '
Failed to load device contacts
'; + } +} + +function renderDeviceRepeaterList() { + const listEl = document.getElementById('deviceRptList'); + const countEl = document.getElementById('deviceRptCount'); + const searchVal = (document.getElementById('deviceRptSearch').value || '').toLowerCase().trim(); + + const addedKeys = new Set(myRepeaters.map(r => r.public_key)); + let items = _deviceRepeaters || []; + if (searchVal) { + items = items.filter(c => (c.name || '').toLowerCase().includes(searchVal)); + } + + if (countEl) countEl.textContent = `${(_deviceRepeaters || []).length} repeaters on device`; + + if (!items.length) { + listEl.innerHTML = '
No repeaters found on the device.
'; + return; + } + + listEl.innerHTML = ''; + items.forEach(c => { + const added = addedKeys.has(c.public_key.toLowerCase()); + const item = document.createElement('div'); + item.className = 'device-rpt-item' + (added ? ' added' : ''); + item.innerHTML = ` + +
+
${esc(c.name || c.public_key_prefix)}
+ ${esc(c.public_key_prefix)} · ${esc(c.path_or_mode || '')} +
+ ${added + ? 'Added' + : ''} + `; + if (!added) { + item.addEventListener('click', () => addRepeater(c)); + } + listEl.appendChild(item); + }); +} + +async function addRepeater(contact) { + try { + const response = await fetch('/api/repeaters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ public_key: contact.public_key }) + }); + const data = await response.json(); + if (data.success) { + showNotification(`${contact.name || 'Repeater'} added`, 'success'); + await loadRepeaters(); + renderDeviceRepeaterList(); + } else { + showNotification(data.error || 'Failed to add repeater', 'danger'); + } + } catch (e) { + showNotification('Failed to add repeater', 'danger'); + } +} + +// ================================================================ +// Path management (adapted from the DM panel; same REST endpoints) +// ================================================================ + +function openPathModal(pubkey) { + const r = findRepeater(pubkey); + if (!r) return; + _pathPubkey = pubkey; + document.getElementById('pathModalName').textContent = r.name || pubkey.substring(0, 12); + document.getElementById('pathModalCurrent').textContent = r.path_or_mode || '—'; + _pathModal.show(); + renderPathList(pubkey); +} + +async function refreshDevicePathDisplay() { + // Path applied/reset on device — the server invalidated the contacts + // cache, so a plain reload picks up the fresh out_path. + await loadRepeaters(); + const r = findRepeater(_pathPubkey); + const el = document.getElementById('pathModalCurrent'); + if (el && r) el.textContent = r.path_or_mode || '—'; +} + +async function renderPathList(pubkey) { + const listEl = document.getElementById('pathList'); + if (!listEl) return; + + listEl.innerHTML = '
Loading...
'; + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`); + const data = await response.json(); + if (!data.success || !data.paths.length) { + listEl.innerHTML = '
No paths configured. Use + to add.
'; + return; + } + + listEl.innerHTML = ''; + data.paths.forEach((path, index) => { + const item = document.createElement('div'); + item.className = 'path-list-item' + (path.is_primary ? ' primary' : ''); + + const chunk = path.hash_size * 2; + const hops = []; + for (let i = 0; i < path.path_hex.length; i += chunk) { + hops.push(path.path_hex.substring(i, i + chunk).toUpperCase()); + } + const pathDisplay = hops.join('→'); + const hashLabel = path.hash_size + 'B'; + + item.innerHTML = ` + ${esc(pathDisplay)} + ${hashLabel} + ${path.label ? `${esc(path.label)}` : ''} + + + + ${index > 0 ? `` : ''} + ${index < data.paths.length - 1 ? `` : ''} + + + `; + listEl.appendChild(item); + }); + + listEl.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const action = btn.dataset.action; + const pathId = parseInt(btn.dataset.id); + + if (action === 'primary') { + await setPathPrimary(pubkey, pathId); + } else if (action === 'apply') { + await applyPathToDevice(pubkey, pathId); + } else if (action === 'delete') { + await deletePathItem(pubkey, pathId); + } else if (action === 'up' || action === 'down') { + await movePathItem(pubkey, data.paths, parseInt(btn.dataset.index), action); + } + }); + }); + } catch (e) { + listEl.innerHTML = '
Failed to load paths
'; + console.error('Failed to load paths:', e); + } +} + +async function setPathPrimary(pubkey, pathId) { + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_primary: true }) + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to set primary path:', e); + } +} + +async function applyPathToDevice(pubkey, pathId) { + try { + const response = await fetch( + `/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}/apply`, + { method: 'POST' } + ); + const data = await response.json(); + if (data.success) { + showNotification('Device path updated', 'info'); + await refreshDevicePathDisplay(); + } else { + showNotification(data.error || 'Failed to set device path', 'danger'); + } + } catch (e) { + console.error('Failed to apply path to device:', e); + showNotification('Failed to set device path', 'danger'); + } +} + +async function deletePathItem(pubkey, pathId) { + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, { + method: 'DELETE' + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to delete path:', e); + } +} + +async function movePathItem(pubkey, paths, currentIndex, direction) { + const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (newIndex < 0 || newIndex >= paths.length) return; + + const ids = paths.map(p => p.id); + [ids[currentIndex], ids[newIndex]] = [ids[newIndex], ids[currentIndex]]; + + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path_ids: ids }) + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to reorder paths:', e); + } +} + +// ---------------- Add Path modal ---------------- + +function openAddPathModal() { + document.getElementById('pathHexInput').value = ''; + document.getElementById('pathLabelInput').value = ''; + document.getElementById('pathUniquenessWarning').style.display = 'none'; + document.getElementById('repeaterPicker').style.display = 'none'; + _addPathModal.show(); +} + +async function saveNewPath() { + const pubkey = _pathPubkey; + if (!pubkey) return; + const pathHex = document.getElementById('pathHexInput').value.replace(/[,\s→]/g, '').trim(); + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value); + const label = document.getElementById('pathLabelInput').value.trim(); + + if (!pathHex) { + showNotification('Path hex is required', 'danger'); + return; + } + + const chunk = hashSize * 2; + const hops = []; + for (let i = 0; i < pathHex.length; i += chunk) { + hops.push(pathHex.substring(i, i + chunk).toLowerCase()); + } + if (hashSize === 1) { + const adjDupes = hops.filter((h, i) => i > 0 && hops[i - 1] === h); + if (adjDupes.length > 0) { + showNotification(`Adjacent duplicate hop(s): ${[...new Set(adjDupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger'); + return; + } + } else { + const dupes = hops.filter((h, i) => hops.indexOf(h) !== i); + if (dupes.length > 0) { + showNotification(`Duplicate hop(s): ${[...new Set(dupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger'); + return; + } + } + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path_hex: pathHex, hash_size: hashSize, label: label }) + }); + const data = await response.json(); + if (data.success) { + _addPathModal.hide(); + await renderPathList(pubkey); + showNotification('Path added', 'info'); + } else { + showNotification(data.error || 'Failed to add path', 'danger'); + } + } catch (e) { + showNotification('Failed to add path', 'danger'); + } +} + +async function resetPathToFlood() { + const pubkey = _pathPubkey; + if (!pubkey) return; + if (!confirm('Reset device path to FLOOD?\n\nThis resets the path on the device only. Your configured paths will be kept.')) { + return; + } + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reset_flood`, { + method: 'POST' + }); + const data = await response.json(); + if (data.success) { + showNotification('Device path reset to FLOOD', 'info'); + await refreshDevicePathDisplay(); + } else { + showNotification(data.error || 'Reset failed', 'danger'); + } + } catch (e) { + showNotification('Reset failed', 'danger'); + } +} + +async function clearAllPaths() { + const pubkey = _pathPubkey; + if (!pubkey) return; + if (!confirm('Clear all configured paths?\n\nThis will delete all paths from the database. The device path will not be changed.')) { + return; + } + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/clear`, { + method: 'POST' + }); + const data = await response.json(); + if (data.success) { + await renderPathList(pubkey); + showNotification(`${data.paths_deleted || 0} path(s) cleared`, 'info'); + } else { + showNotification(data.error || 'Clear failed', 'danger'); + } + } catch (e) { + showNotification('Clear failed', 'danger'); + } +} + +// ---------------- Hop picker (repeater list) ---------------- + +async function toggleHopPicker() { + const picker = document.getElementById('repeaterPicker'); + if (picker.style.display === 'none') { + picker.style.display = ''; + await loadHopPicker(); + } else { + picker.style.display = 'none'; + } +} + +async function loadHopPicker() { + const listEl = document.getElementById('repeaterList2'); + if (!listEl) return; + + if (!_repeatersCache) { + try { + const response = await fetch('/api/contacts/repeaters'); + const data = await response.json(); + if (data.success) { + _repeatersCache = data.repeaters; + } + } catch (e) { + listEl.innerHTML = '
Failed to load repeaters
'; + return; + } + } + + renderHopPickerList(listEl, _repeatersCache); +} + +function getRepeaterSearchMode() { + const checked = document.querySelector('input[name="repeaterSearchMode"]:checked'); + return checked ? checked.value : 'name'; +} + +function renderHopPickerList(listEl, repeaters) { + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value); + const hexInput = document.getElementById('pathHexInput'); + const searchVal = (document.getElementById('repeaterSearch')?.value || '').toLowerCase().trim(); + const searchMode = getRepeaterSearchMode(); + const prefixLen = hashSize * 2; + + const filtered = repeaters.filter(r => { + if (!searchVal) return true; + if (searchMode === 'id') { + const idPrefix = r.public_key.substring(0, prefixLen).toLowerCase(); + return idPrefix.startsWith(searchVal.substring(0, prefixLen)); + } + return r.name.toLowerCase().includes(searchVal); + }); + + if (!filtered.length) { + listEl.innerHTML = '
No repeaters found
'; + return; + } + + listEl.innerHTML = ''; + filtered.forEach(rpt => { + const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase(); + const samePrefix = repeaters.filter(r => + r.public_key.substring(0, hashSize * 2).toLowerCase() === prefix.toLowerCase() + ).length; + + const item = document.createElement('div'); + item.className = 'repeater-picker-item'; + item.innerHTML = ` + ${esc(prefix)} + ${esc(rpt.name)} + ${samePrefix > 1 ? '' : ''} + `; + item.addEventListener('click', () => { + appendHopToPathInput(prefix.toLowerCase(), hashSize); + }); + listEl.appendChild(item); + }); +} + +function getCurrentPathHops(hashSize) { + const hexInput = document.getElementById('pathHexInput'); + if (!hexInput) return []; + const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim().toLowerCase(); + const chunk = hashSize * 2; + const hops = []; + for (let i = 0; i < rawHex.length; i += chunk) { + hops.push(rawHex.substring(i, i + chunk)); + } + return hops; +} + +function appendHopToPathInput(prefixLc, hashSize) { + const existingHops = getCurrentPathHops(hashSize); + if (hashSize === 1) { + if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) { + showNotification(`${prefixLc.toUpperCase()} cannot be adjacent to itself`, 'warning'); + return false; + } + } else { + if (existingHops.includes(prefixLc)) { + showNotification(`${prefixLc.toUpperCase()} is already in the path`, 'warning'); + return false; + } + } + + const hexInput = document.getElementById('pathHexInput'); + const current = hexInput.value.replace(/[,\s→]/g, '').trim(); + const newVal = current + prefixLc; + const chunk = hashSize * 2; + const parts = []; + for (let i = 0; i < newVal.length; i += chunk) { + parts.push(newVal.substring(i, i + chunk)); + } + hexInput.value = parts.join(','); + + if (_repeatersCache) { + checkUniquenessWarning(_repeatersCache, hashSize); + } + return true; +} + +function checkUniquenessWarning(repeaters, hashSize) { + const warningEl = document.getElementById('pathUniquenessWarning'); + if (!warningEl) return; + + const hexInput = document.getElementById('pathHexInput'); + const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim(); + const chunk = hashSize * 2; + const hops = []; + for (let i = 0; i < rawHex.length; i += chunk) { + hops.push(rawHex.substring(i, i + chunk).toLowerCase()); + } + + const ambiguous = hops.filter(hop => { + const count = repeaters.filter(r => + r.public_key.substring(0, chunk).toLowerCase() === hop + ).length; + return count > 1; + }); + + if (ambiguous.length > 0) { + warningEl.textContent = `⚠ Ambiguous prefix(es): ${ambiguous.map(h => h.toUpperCase()).join(', ')}. Consider using a larger hash size.`; + warningEl.style.display = ''; + } else { + warningEl.style.display = 'none'; + } +} + +// ---------------- Repeater map picker ---------------- + +let _rptMap = null; +let _rptMapMarkers = null; +let _rptMapSelectedRepeater = null; + +function openRepeaterMapPicker() { + _rptMapSelectedRepeater = null; + + const modalEl = document.getElementById('repeaterMapModal'); + if (!modalEl) return; + + const addBtn = document.getElementById('rptMapAddBtn'); + const selectedLabel = document.getElementById('rptMapSelected'); + if (addBtn) addBtn.disabled = true; + if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + + const modal = new bootstrap.Modal(modalEl); + + const onShown = async function () { + // Raise backdrop z-index so it covers the modals behind + const backdrops = document.querySelectorAll('.modal-backdrop'); + if (backdrops.length > 0) { + backdrops[backdrops.length - 1].style.zIndex = '1075'; + } + + if (!_rptMap) { + _rptMap = L.map('rptLeafletMap').setView([52.0, 19.0], 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap' + }).addTo(_rptMap); + _rptMapMarkers = L.layerGroup().addTo(_rptMap); + } + _rptMap.invalidateSize(); + await loadRepeaterMapMarkers(); + modalEl.removeEventListener('shown.bs.modal', onShown); + }; + + const cachedSwitch = document.getElementById('rptMapCachedSwitch'); + if (cachedSwitch) { + cachedSwitch.onchange = () => loadRepeaterMapMarkers(); + } + + if (addBtn) { + addBtn.onclick = () => { + if (!_rptMapSelectedRepeater) return; + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value); + const prefix = _rptMapSelectedRepeater.public_key.substring(0, hashSize * 2).toLowerCase(); + if (appendHopToPathInput(prefix, hashSize)) { + _rptMapSelectedRepeater = null; + addBtn.disabled = true; + if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + } + }; + } + + modalEl.addEventListener('shown.bs.modal', onShown); + modal.show(); +} + +async function loadRepeaterMapMarkers() { + if (!_rptMapMarkers) return; + _rptMapMarkers.clearLayers(); + + const cachedSwitch = document.getElementById('rptMapCachedSwitch'); + const showCached = cachedSwitch && cachedSwitch.checked; + const countEl = document.getElementById('rptMapCount'); + const addBtn = document.getElementById('rptMapAddBtn'); + const selectedLabel = document.getElementById('rptMapSelected'); + + _rptMapSelectedRepeater = null; + if (addBtn) addBtn.disabled = true; + if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + + if (!_repeatersCache) { + try { + const response = await fetch('/api/contacts/repeaters'); + const data = await response.json(); + if (data.success) _repeatersCache = data.repeaters; + } catch (e) { + if (countEl) countEl.textContent = 'Failed to load'; + return; + } + } + + let repeaters = (_repeatersCache || []).filter(r => + r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0) + ); + + if (!showCached) { + // Non-cached: only repeaters that are on the device + try { + const response = await fetch('/api/contacts/detailed'); + const data = await response.json(); + if (data.success && data.contacts) { + const deviceKeys = new Set(data.contacts + .filter(c => c.type === 2) + .map(c => c.public_key.toLowerCase())); + repeaters = repeaters.filter(r => deviceKeys.has(r.public_key.toLowerCase())); + } + } catch (e) { /* show all on error */ } + } + + if (countEl) countEl.textContent = `${repeaters.length} repeaters`; + + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1'); + const bounds = []; + + repeaters.forEach(rpt => { + const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase(); + const lastSeen = rpt.last_advert ? formatRelativeTime(rpt.last_advert) : ''; + + const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], { + radius: 10, + fillColor: '#4CAF50', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(_rptMapMarkers); + + marker.bindPopup( + `${esc(rpt.name)}
` + + `${esc(prefix)}` + + (lastSeen ? `
Last seen: ${lastSeen}` : '') + ); + + marker.on('click', () => { + _rptMapSelectedRepeater = rpt; + const addBtn2 = document.getElementById('rptMapAddBtn'); + const selectedLabel2 = document.getElementById('rptMapSelected'); + if (addBtn2) addBtn2.disabled = false; + if (selectedLabel2) { + selectedLabel2.innerHTML = `${esc(prefix)} ${esc(rpt.name)}`; + } + }); + + bounds.push([rpt.adv_lat, rpt.adv_lon]); + }); + + if (bounds.length > 0) { + _rptMap.fitBounds(bounds, { padding: [20, 20] }); + } +} + +// ================================================================ +// Init +// ================================================================ + +document.addEventListener('DOMContentLoaded', () => { + _passwordModal = new bootstrap.Modal(document.getElementById('passwordModal')); + _addRepeaterModal = new bootstrap.Modal(document.getElementById('addRepeaterModal')); + _pathModal = new bootstrap.Modal(document.getElementById('pathModal')); + _addPathModal = new bootstrap.Modal(document.getElementById('addPathModal')); + _removeModal = new bootstrap.Modal(document.getElementById('removeRepeaterModal')); + + loadUiSettings(); + loadRepeaters(); + + document.getElementById('addRepeaterBtn').addEventListener('click', openAddRepeaterModal); + document.getElementById('refreshBtn').addEventListener('click', () => loadRepeaters(true)); + document.getElementById('deviceRptSearch').addEventListener('input', renderDeviceRepeaterList); + + document.getElementById('passwordSubmitBtn').addEventListener('click', submitPasswordModal); + document.getElementById('passwordInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitPasswordModal(); + } + }); + document.getElementById('togglePasswordBtn').addEventListener('click', () => { + const input = document.getElementById('passwordInput'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); + + document.getElementById('removeRepeaterConfirmBtn').addEventListener('click', confirmRemoveRepeater); + + // Path editor + document.getElementById('addPathBtn').addEventListener('click', openAddPathModal); + document.getElementById('savePathBtn').addEventListener('click', saveNewPath); + document.getElementById('clearPathsBtn').addEventListener('click', clearAllPaths); + document.getElementById('resetFloodBtn').addEventListener('click', resetPathToFlood); + document.getElementById('pickRepeaterBtn').addEventListener('click', toggleHopPicker); + document.getElementById('pickRepeaterMapBtn').addEventListener('click', openRepeaterMapPicker); + document.getElementById('repeaterSearch').addEventListener('input', () => { + const listEl = document.getElementById('repeaterList2'); + if (listEl && _repeatersCache) renderHopPickerList(listEl, _repeatersCache); + }); + document.querySelectorAll('input[name="repeaterSearchMode"]').forEach(radio => { + radio.addEventListener('change', () => { + const listEl = document.getElementById('repeaterList2'); + if (listEl && _repeatersCache) renderHopPickerList(listEl, _repeatersCache); + }); + }); + document.querySelectorAll('input[name="pathHashSize"]').forEach(radio => { + radio.addEventListener('change', () => { + const listEl = document.getElementById('repeaterList2'); + if (listEl && _repeatersCache) renderHopPickerList(listEl, _repeatersCache); + }); + }); + + // Raise backdrop when Add Path modal opens above the Paths modal + document.getElementById('addPathModal').addEventListener('shown.bs.modal', () => { + const backdrops = document.querySelectorAll('.modal-backdrop'); + if (backdrops.length > 1) { + backdrops[backdrops.length - 1].style.zIndex = '1060'; + } + }); +}); diff --git a/app/templates/base.html b/app/templates/base.html index b154945..ecc0ff2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -198,6 +198,13 @@ Direct meshcli commands +
@@ -876,6 +883,17 @@
+ + My Repeaters + +
+ + + + +
+ + Device Info diff --git a/app/templates/index.html b/app/templates/index.html index 55d89cf..3595839 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -145,6 +145,9 @@ + @@ -204,6 +207,23 @@ + + +