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..4432348 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -220,6 +220,12 @@ 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}} + # 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 @@ -3047,37 +3053,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 +3148,40 @@ 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_req_status(self, name_or_key: str) -> Dict: """Request status from a repeater.""" @@ -3117,13 +3192,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 +3210,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 +3228,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 +3246,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 +3264,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,13 +3282,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_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)} @@ -3237,13 +3300,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..3f49e00 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,197 @@ 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 = [] + for row in rows: + 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'), + }) + repeaters.append(entry) + + 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 + + +@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..5d94894 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -103,6 +103,15 @@ 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('/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..dc9673f 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; 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/repeaters.js b/app/static/js/repeaters.js new file mode 100644 index 0000000..e7012f2 --- /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'); + // Stage 2 will navigate to the management panel here. + await loadRepeaters(); + } 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 @@ + + +