mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-28 04:23:26 +02:00
feat(repeaters): My Repeaters panel (stage 1)
New full-screen panel (main menu / FAB) for repeater administration: - repeaters table (saved per-pubkey admin password, login metadata); plaintext per observer_brokers precedent (single-user LAN app) - REST /api/repeaters: list merged with device contact truth, add (device REP contacts only), set/clear password, remove, login - device_manager: _repeater_lock serializes all repeater ops (companion firmware has a single pending-request slot); repeater_login now filters LOGIN_SUCCESS by pubkey_prefix and captures is_admin/ permissions into an in-memory session store - login timeout UX: wrong password and unreachable repeater are indistinguishable (firmware stays silent) - error message names both - panel UI: add-picker, password modal (remembered password), per- repeater path editor reusing /api/contacts/<pk>/paths + Leaflet repeater map picker (adapted from the DM panel) - meshcore pin bumped to >=2.3.7 (send_login_sync era API, fixed LOGIN_SUCCESS/LOGIN_FAILED parsing; container already ships 2.3.7) Stage 2 (management panel with Status/Telemetry/Neighbors/CLI/ Settings/Actions tools) follows after user review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+121
-60
@@ -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)}
|
||||
|
||||
+2
-1
@@ -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':
|
||||
|
||||
+216
-16
@@ -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/<public_key>', 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/<public_key>', 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/<public_key>/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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,6 +198,13 @@
|
||||
<small class="d-block text-muted">Direct meshcli commands</small>
|
||||
</div>
|
||||
</button>
|
||||
<button id="repeatersBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#repeatersModal" data-bs-dismiss="offcanvas">
|
||||
<i class="bi bi-diagram-3" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<span>My Repeaters</span>
|
||||
<small class="d-block text-muted">Repeater administration</small>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- System -->
|
||||
<div class="list-group-item py-2 mt-2">
|
||||
@@ -876,6 +883,17 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-placement-key="repeaters">
|
||||
<td class="ps-0">My Repeaters</td>
|
||||
<td class="pe-0 text-end">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="My Repeaters placement">
|
||||
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-fab" value="fab" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="place-repeaters-fab">Quick Access</label>
|
||||
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-menu" value="menu" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="place-repeaters-menu">Main Menu</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-placement-key="deviceinfo">
|
||||
<td class="ps-0">Device Info</td>
|
||||
<td class="pe-0 text-end">
|
||||
|
||||
@@ -145,6 +145,9 @@
|
||||
<button class="fab fab-console d-none" id="fab-console" data-bs-toggle="modal" data-bs-target="#consoleModal" title="Console">
|
||||
<i class="bi bi-terminal"></i>
|
||||
</button>
|
||||
<button class="fab fab-repeaters d-none" id="fab-repeaters" data-bs-toggle="modal" data-bs-target="#repeatersModal" title="My Repeaters">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
</button>
|
||||
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="Device Info">
|
||||
<i class="bi bi-cpu"></i>
|
||||
</button>
|
||||
@@ -204,6 +207,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Repeaters Modal (Full Screen) -->
|
||||
<div class="modal fade" id="repeatersModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title"><i class="bi bi-diagram-3"></i> My Repeaters</h5>
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-lg"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<iframe id="repeatersFrame" style="width: 100%; height: 100%; border: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Log Modal (Full Screen) -->
|
||||
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
@@ -293,6 +313,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
// My Repeaters modal - (re)load iframe on open for fresh data
|
||||
const repeatersModal = document.getElementById('repeatersModal');
|
||||
if (repeatersModal) {
|
||||
repeatersModal.addEventListener('show.bs.modal', function () {
|
||||
const repeatersFrame = document.getElementById('repeatersFrame');
|
||||
if (repeatersFrame) {
|
||||
repeatersFrame.src = '/repeaters';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// System Log modal - load iframe on open, clear on close
|
||||
const logsModal = document.getElementById('logsModal');
|
||||
if (logsModal) {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>My Repeaters - mc-webui</title>
|
||||
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Bootstrap 5 CSS (local) -->
|
||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
||||
<!-- Leaflet CSS (for the repeater map picker) -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Theme CSS (light/dark mode) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
|
||||
<style>
|
||||
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
|
||||
html, body {
|
||||
overflow: auto !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.repeaters-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.repeaters-list-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.repeater-row {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.repeater-row .rpt-main {
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repeater-row.rpt-offline .rpt-main {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.repeater-row.rpt-offline {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.rpt-path {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Device repeater picker rows */
|
||||
.device-rpt-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.device-rpt-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.device-rpt-item:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.device-rpt-item.added {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Path list items (adapted from DM Contact Info) */
|
||||
.path-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.path-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.path-list-item.primary {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.path-list-item .path-hex {
|
||||
font-family: var(--bs-font-monospace, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-list-item .path-label {
|
||||
color: var(--text-secondary, #6c757d);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-list-item .path-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-uniqueness-warning {
|
||||
font-size: 0.75rem;
|
||||
color: #b58900;
|
||||
}
|
||||
|
||||
.repeater-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repeater-picker-item:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toolbar -->
|
||||
<div class="repeaters-toolbar">
|
||||
<span class="text-muted small flex-grow-1" id="repeaterCount"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="Refresh list">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" id="addRepeaterBtn">
|
||||
<i class="bi bi-plus-lg"></i> Add repeater
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Repeater list -->
|
||||
<div class="repeaters-list-wrap">
|
||||
<div id="repeaterList"></div>
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
<p class="mb-1">No repeaters yet.</p>
|
||||
<p class="small mb-0">Use <strong>Add repeater</strong> to pick repeaters stored on your device.<br>
|
||||
Only repeaters saved in the device contacts can be managed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Repeater Picker Modal -->
|
||||
<div class="modal fade" id="addRepeaterModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> Add Repeater</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0 d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<input type="text" class="form-control form-control-sm" id="deviceRptSearch"
|
||||
placeholder="Search repeaters on device..." autocomplete="off">
|
||||
</div>
|
||||
<div id="deviceRptList" style="overflow-y: auto;">
|
||||
<div class="text-muted small p-3">Loading device contacts...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<span class="me-auto small text-muted" id="deviceRptCount"></span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Modal (set password / login prompt) -->
|
||||
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">Set password</span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="password" class="form-control" id="passwordInput"
|
||||
placeholder="Repeater password" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="Show/hide password">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-check" id="savePasswordWrap">
|
||||
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
|
||||
<label class="form-check-label small" for="savePasswordCheck">
|
||||
Remember password (stored in the app database)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Path Management Modal (per repeater) -->
|
||||
<div class="modal fade" id="pathModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Paths — <span id="pathModalName"></span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body pb-1">
|
||||
<div class="small text-muted mb-2">
|
||||
Device path: <span class="font-monospace" id="pathModalCurrent">—</span>
|
||||
</div>
|
||||
<div class="path-section-header">
|
||||
<h6 class="mb-0 small fw-bold">Configured paths</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="Add path">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="pathList"></div>
|
||||
<div class="d-flex justify-content-end gap-2 mt-1 mb-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="clearPathsBtn"
|
||||
title="Delete all configured paths from database">
|
||||
<i class="bi bi-trash"></i> Clear Paths
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="resetFloodBtn"
|
||||
title="Reset device path to FLOOD mode">
|
||||
<i class="bi bi-broadcast"></i> Reset to FLOOD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Path Modal (adapted from DM panel) -->
|
||||
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Add Path</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Hash Size</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
|
||||
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
|
||||
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
|
||||
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Path (hex)</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control font-monospace" id="pathHexInput"
|
||||
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterBtn"
|
||||
title="Pick repeater from list">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterMapBtn"
|
||||
title="Pick repeater from map">
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="pathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
|
||||
</div>
|
||||
<!-- Repeater picker (hidden by default) -->
|
||||
<div id="repeaterPicker" style="display: none;" class="border rounded mb-2">
|
||||
<div class="d-flex border-bottom">
|
||||
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
|
||||
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">Name</label>
|
||||
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">ID</label>
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm border-0"
|
||||
id="repeaterSearch" placeholder="Search by name..." autocomplete="off">
|
||||
</div>
|
||||
<div id="repeaterList2" style="max-height: 180px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Label (optional)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="pathLabelInput"
|
||||
placeholder="e.g. via Mountain RPT" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">Add Path</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeater Map Picker Modal (adapted from DM panel) -->
|
||||
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Select Repeater from Map</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
|
||||
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
|
||||
</div>
|
||||
<span class="text-muted small ms-auto" id="rptMapCount"></span>
|
||||
</div>
|
||||
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Confirmation Modal -->
|
||||
<div class="modal fade" id="removeRepeaterModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-trash"></i> Remove Repeater</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-1">Remove <strong id="removeRepeaterName"></strong> from My Repeaters?</p>
|
||||
<p class="small text-muted mb-0">The saved password will be deleted.
|
||||
The contact on the device is not affected.</p>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="removeRepeaterConfirmBtn">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
|
||||
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
|
||||
<div id="notificationToast" class="toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">My Repeaters</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS Bundle (local) -->
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
<!-- My Repeaters JS -->
|
||||
<script src="{{ url_for('static', filename='js/repeaters.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -35,4 +35,4 @@ python-socketio==5.10.0
|
||||
python-engineio==4.8.1
|
||||
|
||||
# v2: Direct MeshCore device communication (replaces bridge subprocess)
|
||||
meshcore>=2.2.0
|
||||
meshcore>=2.3.7
|
||||
|
||||
Reference in New Issue
Block a user